From f1cdd67606ed7f7d6bf5b8e479aa215c50a324d9 Mon Sep 17 00:00:00 2001 From: lleeoo Date: Mon, 28 Aug 2023 14:29:33 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(api)=20enrich=20statements=20on=20pos?= =?UTF-8?q?t=20and=20put?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The xAPI specification indicates to infer the fields `authority`, `stored`, `timestamp` and `id` (discouraging use of `version`), when recieving statements. This commit implements this requirement, thus paving the way to proper permissions management (through `authority`). --- CHANGELOG.md | 4 +- src/ralph/api/routers/statements.py | 93 ++++++++++++---- src/ralph/utils.py | 22 ++++ tests/api/test_statements_post.py | 151 ++++++++++++++++++++++++-- tests/api/test_statements_put.py | 136 ++++++++++++++++++++++- tests/backends/http/test_async_lrs.py | 4 +- tests/helpers.py | 51 +++++++++ tests/test_helpers.py | 96 ++++++++++++++++ 8 files changed, 519 insertions(+), 38 deletions(-) create mode 100644 tests/helpers.py create mode 100644 tests/test_helpers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ef19d01..5e62cf651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,9 @@ have an authority field matching that of the user - API: Forwarding PUT now uses PUT (instead of POST) - Models: The xAPI `context.contextActivities.category` field is now mandatory in the video and virtual classroom profiles. [BC] -- Backends: `LRSHTTP` methods must not be used in `asyncio` events loop (BC) +- Backends: `LRSHTTP` methods must not be used in `asyncio` events loop (BC) +- API: Incoming statements are enriched with `id`, `timestamp`, `stored` + and `authority` ## [3.9.0] - 2023-07-21 diff --git a/src/ralph/api/routers/statements.py b/src/ralph/api/routers/statements.py index 74f8ed7c8..b03be4c26 100644 --- a/src/ralph/api/routers/statements.py +++ b/src/ralph/api/routers/statements.py @@ -25,6 +25,7 @@ from ralph.api.auth import get_authenticated_user from ralph.api.auth.user import AuthenticatedUser from ralph.api.forwarding import forward_xapi_statements, get_active_xapi_forwardings +from ralph.api.models import ErrorDetail, LaxStatement from ralph.backends.database.base import ( AgentParameters, BaseDatabase, @@ -40,8 +41,7 @@ BaseXapiAgentWithOpenId, ) from ralph.models.xapi.base.common import IRI - -from ..models import ErrorDetail, LaxStatement +from ralph.utils import now, statements_are_equivalent logger = logging.getLogger(__name__) @@ -67,6 +67,49 @@ } +def _enrich_statement_with_id(statement: dict): + # id: UUID + # https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#24-statement-properties + statement["id"] = str(statement.get("id", uuid4())) + return statement["id"] + + +def _enrich_statement_with_stored(statement: dict, value=None): + # stored: The time at which a Statement is stored by the LRS + # https://github.com/adlnet/xAPI-Spec/blob/1.0.3/xAPI-Data.md#248-stored + if value is None: + statement["stored"] = now() + else: + statement["stored"] = value + return statement["stored"] + + +def _enrich_statement_with_timestamp(statement: dict): + # timestamp: If not provided, same value as stored + # https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#247-timestamp + statement["timestamp"] = statement.get("timestamp", statement.get("stored", now())) + return statement["timestamp"] + + +def _enrich_statement_with_authority(statement: dict, current_user: AuthenticatedUser): + # authority: Information about whom or what has asserted that this statement is true + # https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#249-authority + authority = current_user.agent + if "authority" in statement and statement["authority"] != authority: + logger.error( + "Failed to index submitted statements. Submitted authority does not match." + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + "Stated `authority` does not match credentials. Change or remove" + "`authority` field from incoming statement.", + ), + ) + statement["authority"] = authority + return authority + + def _parse_agent_parameters(agent_obj: dict): """Parse a dict and return an AgentParameters object to use in queries.""" # Transform agent to `dict` as FastAPI cannot parse JSON (seen as string) @@ -356,8 +399,9 @@ async def get( @router.put("", responses=POST_PUT_RESPONSES, status_code=status.HTTP_204_NO_CONTENT) # pylint: disable=unused-argument async def put( + current_user: Annotated[AuthenticatedUser, Depends(get_authenticated_user)], # pylint: disable=invalid-name - statementId: str, + statementId: UUID, statement: LaxStatement, background_tasks: BackgroundTasks, _=Depends(strict_query_params), @@ -367,23 +411,27 @@ async def put( LRS Specification: https://github.com/adlnet/xAPI-Spec/blob/1.0.3/xAPI-Communication.md#211-put-statements """ - statement_dict = {statementId: statement.dict(exclude_unset=True)} - - # Force the UUID id in the statement to string, make sure it matches the - # statementId given in the URL. - statement_dict[statementId]["id"] = str(statement_dict[statementId]["id"]) + statement_as_dict = statement.dict(exclude_unset=True) - if not statementId == statement_dict[statementId]["id"]: + statement_as_dict.update(id=str(statement_as_dict.get("id", statementId))) + if statementId != statement_as_dict["id"]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="xAPI statement id does not match given statementId", ) + # Enrich statement before forwarding (NB: id is already set) + timestamp = _enrich_statement_with_timestamp(statement_as_dict) + if get_active_xapi_forwardings(): background_tasks.add_task( - forward_xapi_statements, statement_dict[statementId], method="put" + forward_xapi_statements, statement_as_dict, method="put" ) + # Finish enriching statements after forwarding + _enrich_statement_with_stored(statement_as_dict, timestamp) + _enrich_statement_with_authority(statement_as_dict, current_user) + try: existing_statement = DATABASE_CLIENT.query_statements_by_ids([statementId]) except BackendException as error: @@ -397,7 +445,7 @@ async def put( # In the case that the current statement is not an exact duplicate of the one # found in the database we return a 409, otherwise the usual 204. for existing in existing_statement: - if statement_dict != existing["_source"]: + if not statements_are_equivalent(statement_as_dict, existing["_source"]): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="A different statement already exists with the same ID", @@ -406,9 +454,7 @@ async def put( # For valid requests, perform the bulk indexing of all incoming statements try: - success_count = DATABASE_CLIENT.put( - statement_dict.values(), ignore_errors=False - ) + success_count = DATABASE_CLIENT.put([statement_as_dict], ignore_errors=False) except (BackendException, BadFormatException) as exc: logger.error("Failed to index submitted statement") raise HTTPException( @@ -422,6 +468,7 @@ async def put( @router.post("/", responses=POST_PUT_RESPONSES) @router.post("", responses=POST_PUT_RESPONSES) async def post( + current_user: Annotated[AuthenticatedUser, Depends(get_authenticated_user)], statements: Union[LaxStatement, List[LaxStatement]], background_tasks: BackgroundTasks, response: Response, @@ -438,14 +485,11 @@ async def post( if not isinstance(statements, list): statements = [statements] - # The statements dict has multiple functions: - # - generate IDs for statements that are missing them; - # - use the list of keys to perform validations and as a final return value; - # - provide an iterable containing both the statements and generated IDs for bulk. + # Enrich statements before forwarding statements_dict = {} for statement in map(lambda x: x.dict(exclude_unset=True), statements): - statement_id = str(statement.get("id", uuid4())) - statement["id"] = statement_id + statement_id = _enrich_statement_with_id(statement) + timestamp = _enrich_statement_with_timestamp(statement) statements_dict[statement_id] = statement # Requests with duplicate statement IDs are considered invalid @@ -462,6 +506,11 @@ async def post( forward_xapi_statements, list(statements_dict.values()), method="post" ) + # Finish enriching statements after forwarding + for statement in statements_dict.values(): + _enrich_statement_with_stored(statement, value=timestamp) + _enrich_statement_with_authority(statement, current_user) + try: existing_statements = DATABASE_CLIENT.query_statements_by_ids(statements_ids) except BackendException as error: @@ -482,7 +531,9 @@ async def post( # The LRS specification calls for deep comparison of duplicates. This # is done here. If they are not exactly the same, we raise an error. - if statements_dict[existing["_id"]] != existing["_source"]: + if not statements_are_equivalent( + statements_dict[existing["_id"]], existing["_source"] + ): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Differing statements already exist with the same ID: " diff --git a/src/ralph/utils.py b/src/ralph/utils.py index 8ed9c5996..9456c48b7 100644 --- a/src/ralph/utils.py +++ b/src/ralph/utils.py @@ -120,3 +120,25 @@ async def sem_task(task): except Exception as exception: group.cancel() raise exception + + +def statements_are_equivalent(statement_1: dict, statement_2: dict): + """Check if statements are equivalent. + + To be equivalent, they must be identical on all fields not modified on input by the + LRS and identical on other fields, if these fields are present in both + statements. For example, if an "authority" field is present in only one statement, + they may still be equivalent. + """ + # Check that unmutable fields have the same values + fields = ["actor", "verb", "object", "id", "result", "context", "attachements"] + + # Check that some fields enriched by the LRS are equal when in both statements + # The LRS specification excludes the fields below from equivalency. It was + # decided to include them anyway as their value is inherent to the statements. + other_fields = {"timestamp", "version"} # "authority" and "stored" remain ignored. + fields.extend(other_fields & statement_1.keys() & statement_2.keys()) + + if any(statement_1.get(field) != statement_2.get(field) for field in fields): + return False + return True diff --git a/tests/api/test_statements_post.py b/tests/api/test_statements_post.py index 16c0f9eb3..e338feef3 100644 --- a/tests/api/test_statements_post.py +++ b/tests/api/test_statements_post.py @@ -26,6 +26,12 @@ get_mongo_test_backend, ) +from ..helpers import ( + assert_statement_get_responses_are_equivalent, + string_is_date, + string_is_uuid, +) + client = TestClient(app) @@ -99,7 +105,124 @@ def test_api_statements_post_single_statement_directly( "/xAPI/statements/", headers={"Authorization": f"Basic {auth_credentials}"} ) assert response.status_code == 200 - assert response.json() == {"statements": [statement]} + assert_statement_get_responses_are_equivalent( + response.json(), {"statements": [statement]} + ) + + +# pylint: disable=too-many-arguments +def test_api_statements_post_enriching_without_existing_values( + monkeypatch, auth_credentials, es +): + """Test that statements are properly enriched when statement provides no values.""" + # pylint: disable=invalid-name,unused-argument + + monkeypatch.setattr( + "ralph.api.routers.statements.DATABASE_CLIENT", get_es_test_backend() + ) + statement = { + "actor": { + "account": { + "homePage": "https://example.com/homepage/", + "name": str(uuid4()), + }, + "objectType": "Agent", + }, + "object": {"id": "https://example.com/object-id/1/"}, + "verb": {"id": "https://example.com/verb-id/1/"}, + } + + response = client.post( + "/xAPI/statements/", + headers={"Authorization": f"Basic {auth_credentials}"}, + json=statement, + ) + + assert response.status_code == 200 + + es.indices.refresh() + + response = client.get( + "/xAPI/statements/", headers={"Authorization": f"Basic {auth_credentials}"} + ) + + statement = response.json()["statements"][0] + + # Test pre-processing: id + assert "id" in statement + assert string_is_uuid(statement["id"]) + + # Test pre-processing: timestamp + assert "timestamp" in statement + assert string_is_date(statement["timestamp"]) + + # Test pre-processing: stored + assert "stored" in statement + assert string_is_date(statement["stored"]) + + # Test pre-processing: authority + assert "authority" in statement + assert statement["authority"] == {"mbox": "mailto:test_ralph@example.com"} + + +@pytest.mark.parametrize( + "field,value,status", + [ + ("id", str(uuid4()), 200), + ("timestamp", "2022-06-22T08:31:38Z", 200), + ("stored", "2022-06-22T08:31:38Z", 200), + ("authority", {"mbox": "mailto:test_ralph@example.com"}, 200), + ("authority", {"mbox": "mailto:wrongaccount@example.com"}, 403), + ], +) +# pylint: disable=too-many-arguments +def test_api_statements_post_enriching_with_existing_values( + field, value, status, monkeypatch, auth_credentials, es +): + """Test that statements are properly enriched when values are provided.""" + # pylint: disable=invalid-name,unused-argument + + monkeypatch.setattr( + "ralph.api.routers.statements.DATABASE_CLIENT", get_es_test_backend() + ) + statement = { + "actor": { + "account": { + "homePage": "https://example.com/homepage/", + "name": str(uuid4()), + }, + "objectType": "Agent", + }, + "object": {"id": "https://example.com/object-id/1/"}, + "verb": {"id": "https://example.com/verb-id/1/"}, + } + # Add the field to be tested + statement[field] = value + + response = client.post( + "/xAPI/statements/", + headers={"Authorization": f"Basic {auth_credentials}"}, + json=statement, + ) + + assert response.status_code == status + + # Check that values match when they should + if status == 200: + es.indices.refresh() + response = client.get( + "/xAPI/statements/", headers={"Authorization": f"Basic {auth_credentials}"} + ) + statement = response.json()["statements"][0] + + # Test enriching + + assert field in statement + if value == "stored": + # Check that stored value was overwritten + assert statement[field] != value + else: + assert statement[field] == value @pytest.mark.parametrize( @@ -178,7 +301,9 @@ def test_api_statements_post_statements_list_of_one( "/xAPI/statements/", headers={"Authorization": f"Basic {auth_credentials}"} ) assert response.status_code == 200 - assert response.json() == {"statements": [statement]} + assert_statement_get_responses_are_equivalent( + response.json(), {"statements": [statement]} + ) @pytest.mark.parametrize( @@ -239,9 +364,13 @@ def test_api_statements_post_statements_list( "/xAPI/statements/", headers={"Authorization": f"Basic {auth_credentials}"} ) assert get_response.status_code == 200 + # Update statements with the generated id. statements[1] = dict(statements[1], **{"id": generated_id}) - assert get_response.json() == {"statements": statements} + + assert_statement_get_responses_are_equivalent( + get_response.json(), {"statements": statements} + ) @pytest.mark.parametrize( @@ -345,11 +474,11 @@ def test_api_statements_post_statements_list_with_duplicate_of_existing_statemen es.indices.refresh() - # Post the statement again, trying to change the version field which is not allowed. + # Post the statement again, trying to change the timestamp which is not allowed. response = client.post( "/xAPI/statements/", headers={"Authorization": f"Basic {auth_credentials}"}, - json=[dict(statement, **{"version": "1.0.0"})], + json=[dict(statement, **{"timestamp": "2023-03-15T14:07:51Z"})], ) assert response.status_code == 409 @@ -362,7 +491,9 @@ def test_api_statements_post_statements_list_with_duplicate_of_existing_statemen "/xAPI/statements/", headers={"Authorization": f"Basic {auth_credentials}"} ) assert response.status_code == 200 - assert response.json() == {"statements": [statement]} + assert_statement_get_responses_are_equivalent( + response.json(), {"statements": [statement]} + ) @pytest.mark.parametrize( @@ -613,7 +744,9 @@ async def test_post_statements_list_with_statement_forwarding( headers={"Authorization": f"Basic {auth_credentials}"}, ) assert response.status_code == 200 - assert response.json() == {"statements": [statement]} + assert_statement_get_responses_are_equivalent( + response.json(), {"statements": [statement]} + ) # The statement should also be stored on the receiving client async with AsyncClient() as receiving_client: @@ -622,7 +755,9 @@ async def test_post_statements_list_with_statement_forwarding( headers={"Authorization": f"Basic {auth_credentials}"}, ) assert response.status_code == 200 - assert response.json() == {"statements": [statement]} + assert_statement_get_responses_are_equivalent( + response.json(), {"statements": [statement]} + ) # Stop receiving LRS client await lrs_context.__aexit__(None, None, None) diff --git a/tests/api/test_statements_put.py b/tests/api/test_statements_put.py index ba90207b5..e42d24482 100644 --- a/tests/api/test_statements_put.py +++ b/tests/api/test_statements_put.py @@ -25,6 +25,8 @@ get_mongo_test_backend, ) +from ..helpers import assert_statement_get_responses_are_equivalent, string_is_date + client = TestClient(app) @@ -96,7 +98,123 @@ def test_api_statements_put_single_statement_directly( "/xAPI/statements/", headers={"Authorization": f"Basic {auth_credentials}"} ) assert response.status_code == 200 - assert response.json() == {"statements": [statement]} + assert_statement_get_responses_are_equivalent( + response.json(), {"statements": [statement]} + ) + + +# pylint: disable=too-many-arguments +def test_api_statements_put_enriching_without_existing_values( + monkeypatch, auth_credentials, es +): + """Test that statements are properly enriched when statement provides no values.""" + # pylint: disable=invalid-name,unused-argument + + monkeypatch.setattr( + "ralph.api.routers.statements.DATABASE_CLIENT", get_es_test_backend() + ) + statement = { + "actor": { + "account": { + "homePage": "https://example.com/homepage/", + "name": str(uuid4()), + }, + "objectType": "Agent", + }, + "object": {"id": "https://example.com/object-id/1/"}, + "verb": {"id": "https://example.com/verb-id/1/"}, + "id": str(uuid4()), + } + + response = client.put( + f"/xAPI/statements/?statementId={statement['id']}", + headers={"Authorization": f"Basic {auth_credentials}"}, + json=statement, + ) + assert response.status_code == 204 + + es.indices.refresh() + + response = client.get( + "/xAPI/statements/", headers={"Authorization": f"Basic {auth_credentials}"} + ) + + statement = response.json()["statements"][0] + + # Test pre-processing: id + assert "id" in statement + assert statement + + # Test pre-processing: timestamp + assert "timestamp" in statement + assert string_is_date(statement["timestamp"]) + + # Test pre-processing: stored + assert "stored" in statement + assert string_is_date(statement["stored"]) + + # Test pre-processing: authority + assert "authority" in statement + assert statement["authority"] == {"mbox": "mailto:test_ralph@example.com"} + + +@pytest.mark.parametrize( + "field,value,status", + [ + ("timestamp", "2022-06-22T08:31:38Z", 204), + ("stored", "2022-06-22T08:31:38Z", 204), + ("authority", {"mbox": "mailto:test_ralph@example.com"}, 204), + ("authority", {"mbox": "mailto:wrongaccount@example.com"}, 403), + ], +) +# pylint: disable=too-many-arguments +def test_api_statements_put_enriching_with_existing_values( + field, value, status, monkeypatch, auth_credentials, es +): + """Test that statements are properly enriched when values are provided.""" + # pylint: disable=invalid-name,unused-argument + + monkeypatch.setattr( + "ralph.api.routers.statements.DATABASE_CLIENT", get_es_test_backend() + ) + statement = { + "actor": { + "account": { + "homePage": "https://example.com/homepage/", + "name": str(uuid4()), + }, + "objectType": "Agent", + }, + "object": {"id": "https://example.com/object-id/1/"}, + "verb": {"id": "https://example.com/verb-id/1/"}, + "id": str(uuid4()), + } + # Add the field to be tested + statement[field] = value + + response = client.put( + f"/xAPI/statements/?statementId={statement['id']}", + headers={"Authorization": f"Basic {auth_credentials}"}, + json=statement, + ) + + assert response.status_code == status + + # Check that values match when they should + if status == 204: + es.indices.refresh() + response = client.get( + "/xAPI/statements/", headers={"Authorization": f"Basic {auth_credentials}"} + ) + statement = response.json()["statements"][0] + + # Test enriching + assert field in statement + if value == "stored": + # Check that stored value was overwritten + assert statement[field] != value + else: + assert statement[field] == value @pytest.mark.parametrize( @@ -244,11 +362,11 @@ def test_api_statements_put_statement_duplicate_of_existing_statement( es.indices.refresh() - # Put the statement twice, trying to change the version field which is not allowed. + # Put the statement twice, trying to change the timestamp, which is not allowed response = client.put( f"/xAPI/statements/?statementId={statement['id']}", headers={"Authorization": f"Basic {auth_credentials}"}, - json=dict(statement, **{"version": "1.0.0"}), + json=dict(statement, **{"timestamp": "2023-03-15T14:07:51Z"}), ) assert response.status_code == 409 @@ -261,7 +379,9 @@ def test_api_statements_put_statement_duplicate_of_existing_statement( headers={"Authorization": f"Basic {auth_credentials}"}, ) assert response.status_code == 200 - assert response.json() == {"statements": [statement]} + assert_statement_get_responses_are_equivalent( + response.json(), {"statements": [statement]} + ) @pytest.mark.parametrize( @@ -514,7 +634,9 @@ async def test_put_statement_with_statement_forwarding( headers={"Authorization": f"Basic {auth_credentials}"}, ) assert response.status_code == 200 - assert response.json() == {"statements": [statement]} + assert_statement_get_responses_are_equivalent( + response.json(), {"statements": [statement]} + ) # The statement should also be stored on the receiving client async with AsyncClient() as receiving_client: @@ -523,7 +645,9 @@ async def test_put_statement_with_statement_forwarding( headers={"Authorization": f"Basic {auth_credentials}"}, ) assert response.status_code == 200 - assert response.json() == {"statements": [statement]} + assert_statement_get_responses_are_equivalent( + response.json(), {"statements": [statement]} + ) # Stop receiving LRS client await lrs_context.__aexit__(None, None, None) diff --git a/tests/backends/http/test_async_lrs.py b/tests/backends/http/test_async_lrs.py index 3e7acb1ea..5e8015b70 100644 --- a/tests/backends/http/test_async_lrs.py +++ b/tests/backends/http/test_async_lrs.py @@ -44,11 +44,11 @@ def _gen_statement(id_=None, verb=None, timestamp=None): if timestamp is None: timestamp = datetime.strftime( datetime.fromtimestamp(time.time() - random.random()), - "%y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M:%S", ) elif isinstance(timestamp, int): timestamp = datetime.strftime( - datetime.fromtimestamp((time.time() - timestamp), "%y-%m-%dT%H:%M:%S") + datetime.fromtimestamp((time.time() - timestamp), "%Y-%m-%dT%H:%M:%S") ) return {"id": id_, "verb": verb, "timestamp": timestamp} diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 000000000..43fbbf97e --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,51 @@ +"""Utilities for testing Ralph.""" +import datetime +import uuid + +from ralph.utils import statements_are_equivalent + + +def string_is_date(string: str): + """Check if string can be parsed as a date.""" + try: + datetime.datetime.fromisoformat(string) + return True + except ValueError: + return False + + +def string_is_uuid(string: str): + """Check if string is a valid uuid.""" + try: + uuid.UUID(string) + return True + except ValueError: + return False + + +def _assert_statements_are_equivalent(statement_1: dict, statement_2: dict): + """Assert that statements are identical on fields not modified by the LRS.""" + assert statements_are_equivalent(statement_1, statement_2) + + +def assert_statement_get_responses_are_equivalent(response_1: dict, response_2: dict): + """Check that responses to GET /statements are equivalent. + + Check that all statements in response are equivalent, meaning that all + fields not modified by the LRS are equal. + """ + + assert response_1.keys() == response_2.keys() + + def _all_but_statements(response): + return {key: val for key, val in response.items() if key != "statements"} + + assert _all_but_statements(response_1) == _all_but_statements(response_2) + + # Assert the statements part of the response is equivalent + assert "statements" in response_1.keys() + assert len(response_1["statements"]) == len(response_2["statements"]) + for statement_1, statement_2 in zip( + response_1["statements"], response_2["statements"] + ): + _assert_statements_are_equivalent(statement_1, statement_2) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 000000000..b4d7c7fd8 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,96 @@ +"""Tests for test helpers.""" + +from uuid import uuid4 + +import pytest + +from .helpers import ( + assert_statement_get_responses_are_equivalent, + string_is_date, + string_is_uuid, +) + + +def test_utils_string_is_date(): + """Test that strings representing dates are properly identified.""" + string = "2022-06-22T08:31:38+00:00" + assert string_is_date(string) + + string = "a2023-06-22T08:31:38+00:00" + assert not string_is_date(string) + + +def test_utils_string_is_uuid(): + """Test that strings representing uuids are properly identified.""" + string = str(uuid4()) + assert string_is_uuid(string) + + string = "not_a_valid_uuid" + assert not string_is_uuid(string) + + +@pytest.mark.parametrize( + "modified_fields,are_equivalent", + [ + ( + { + "timestamp": None, + "version": None, + "authority": "authority_2", + "stored": "stored_2", + }, + True, + ), + ({"actor": {"actor_field": "actor_2"}}, False), + ({"verb": "verb_2"}, False), + ({"object": "object_2"}, False), + ({"id": "id_2"}, False), + ({"result": "result_2"}, False), + ({"context": "context_2"}, False), + ({"attachements": "attachements_2"}, False), + ({"timestamp": "timestamp_2"}, False), + ({"version": "version_2"}, False), + ], +) +def test_utils_assert_statement_get_responses_are_equivalent( + modified_fields, are_equivalent +): + """Test the equivalency assertion for get responses. + + Equivalency (term NOT in specification) means that two statements have + identical values on all fields except `authority`, `stored` and `timestamp` + (where the value may or may not be identical). + """ + statement_1 = { + "actor": {"actor_field": "actor_1"}, + "verb": "verb_1", + "object": "object_1", + "id": "id_1", + "result": "result_1", + "context": "context_1", + "attachements": "attachements_1", + "authority": "authority_1", + "stored": "stored_1", + "timestamp": "timestamp_1", + "version": "version_1", + } + + # Statement to compare to + statement_2 = statement_1.copy() + statement_2.update(modified_fields) + statement_2 = {key: value for key, value in statement_2.items() if value} + + get_response_1 = {"statements": [statement_1]} + get_response_2 = {"statements": [statement_2]} + + if are_equivalent: + assert_statement_get_responses_are_equivalent(get_response_1, get_response_2) + assert_statement_get_responses_are_equivalent(get_response_2, get_response_1) + else: + with pytest.raises(AssertionError): + assert_statement_get_responses_are_equivalent( + get_response_1, get_response_2 + ) + assert_statement_get_responses_are_equivalent( + get_response_2, get_response_1 + )