diff --git a/layer/nrlf/core/errors.py b/layer/nrlf/core/errors.py index 615ef9a2a..f47df92bc 100644 --- a/layer/nrlf/core/errors.py +++ b/layer/nrlf/core/errors.py @@ -79,6 +79,9 @@ def response(self) -> Response: body=self.operation_outcome.model_dump_json(exclude_none=True, indent=2), ) + def __str__(self): + return f"OperationOutcomeError: {self.operation_outcome}" + class ParseError(Exception): issues: List[OperationOutcomeIssue] diff --git a/layer/nrlf/core/json_duplicate_checker.py b/layer/nrlf/core/json_duplicate_checker.py new file mode 100644 index 000000000..7c9d8de6c --- /dev/null +++ b/layer/nrlf/core/json_duplicate_checker.py @@ -0,0 +1,74 @@ +import json +from typing import Any + + +def check_for_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Custom JSON object_pairs_hook that checks for duplicate keys.""" + keys: dict[str, Any] = {} + dupes: dict[str, Any] = {} + normalized_keys: list[str] = [] + + for key, value in pairs: + normalized_key = key.lower() + if normalized_key in normalized_keys: + dupes.setdefault(key, []).append(value) + else: + keys[key] = value + normalized_keys += [normalized_key] + + if dupes: + keys["__duplicates__"] = dupes + + return keys + + +def flatten_duplicates(data: dict[str, Any] | list[Any]) -> list[str]: + """Flattens a JSON structure and returns a list of duplicate paths.""" + duplicates: list[str] = [] + items = data.items() if isinstance(data, dict) else enumerate(data) + + for key, value in items: + if key == "__duplicates__": + duplicates.extend(value.keys()) + elif isinstance(value, (dict, list)): + path = f"{key}" if isinstance(data, dict) else f"[{key}]" + dupes = flatten_duplicates(value) + duplicates.extend([f"{path}.{dupe}" for dupe in dupes]) + + return duplicates + + +def format_path(path: str) -> str: + """Transforms a path like key1.[2].key2 into key1[2].key2""" + parts = path.split(".") + formatted_parts: list[str] = [] + for part in parts: + if part.startswith("["): + formatted_parts[-1] += part + else: + formatted_parts.append(part) + return ".".join(formatted_parts) + + +def check_duplicate_keys(json_content: str) -> tuple[list[str], list[str]]: + """Find all duplicate keys in a JSON string. + + Traverses the entire JSON structure and reports: + - List of keys that appear multiple times at the same level + - Full paths to each duplicate key occurrkeysence + + A key is considered duplicate if it appears multiple times within + the same object, regardless of nesting level or array position. + """ + try: + dupe_data = json.loads(json_content, object_pairs_hook=check_for_duplicate_keys) + duplicate_paths = [ + f"DocumentReference.{format_path(path)}" + for path in flatten_duplicates(dupe_data) + ] + duplicate_keys = list( + dict.fromkeys([key.split(".")[-1] for key in duplicate_paths]) + ) + return duplicate_keys, duplicate_paths + except json.JSONDecodeError: + raise ValueError("Error: Invalid JSON format") diff --git a/layer/nrlf/core/log_references.py b/layer/nrlf/core/log_references.py index 295cea4ac..5c812c66a 100644 --- a/layer/nrlf/core/log_references.py +++ b/layer/nrlf/core/log_references.py @@ -35,6 +35,8 @@ class LogReference(Enum): ) HANDLER016 = _Reference("INFO", "Set response headers") HANDLER017 = _Reference("WARN", "Correlation ID not found in request headers") + HANDLER018 = _Reference("INFO", "Checking for duplicate keys in request body") + HANDLER019 = _Reference("ERROR", "Duplicate keys found in the request body") HANDLER999 = _Reference("INFO", "Request handler returned successfully") # Error Logs diff --git a/layer/nrlf/core/request.py b/layer/nrlf/core/request.py index 7878711be..499e40889 100644 --- a/layer/nrlf/core/request.py +++ b/layer/nrlf/core/request.py @@ -6,6 +6,7 @@ from nrlf.core.codes import SpineErrorConcept from nrlf.core.constants import CLIENT_RP_DETAILS, CONNECTION_METADATA from nrlf.core.errors import OperationOutcomeError, ParseError +from nrlf.core.json_duplicate_checker import check_duplicate_keys from nrlf.core.logger import LogReference, logger from nrlf.core.model import ClientRpDetails, ConnectionMetadata @@ -88,6 +89,7 @@ def parse_body( try: result = model.model_validate_json(body) + raise_when_duplicate_keys(body) logger.log(LogReference.HANDLER009, parsed_body=result.model_dump()) return result @@ -99,6 +101,24 @@ def parse_body( ) from None +def raise_when_duplicate_keys(json_content: str) -> None: + """ + Raises an error if duplicate keys are found in the JSON content. + """ + logger.log(LogReference.HANDLER018) + duplicates, paths = check_duplicate_keys(json_content) + if duplicates: + error = OperationOutcomeError( + severity="error", + code="invalid", + details=SpineErrorConcept.from_code("MESSAGE_NOT_WELL_FORMED"), + diagnostics=f"Duplicate keys found in FHIR document: {duplicates}", + expression=paths, + ) + logger.log(LogReference.HANDLER019, error=str(error)) + raise error + + def parse_path( model: Type[BaseModel] | None, path_params: Dict[str, str] | None, diff --git a/layer/nrlf/core/tests/test_json_duplicate_checker.py b/layer/nrlf/core/tests/test_json_duplicate_checker.py new file mode 100644 index 000000000..c8c0e6bf4 --- /dev/null +++ b/layer/nrlf/core/tests/test_json_duplicate_checker.py @@ -0,0 +1,343 @@ +import unittest + +from json_duplicate_checker import check_duplicate_keys + + +class TestJsonDuplicateChecker(unittest.TestCase): + def test_no_duplicates(self): + json_content = '{"a": 1, "b": 2, "c": {"d": 3, "e": 4}}' + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual(duplicates, []) + self.assertEqual(paths, []) + + def test_simple_duplicates(self): + json_content = '{"a": 1, "b": 2, "a": 3}' + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual(duplicates, ["a"]) + self.assertEqual(paths, ["DocumentReference.a"]) + + def test_nested_duplicates(self): + # This JSON has no duplicates because the 'b' keys are at different levels + json_content = '{"a": {"b": 1}, "c": {"b": 2}, "d": {"e": {"b": 3}}}' + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual(duplicates, []) + self.assertEqual(paths, []) + + def test_same_level_duplicates(self): + # This JSON has duplicates because there are two 'b' keys at the same level + json_content = '{"a": {"b": 1, "b": 2}, "c": {"d": 3}}' + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual(duplicates, ["b"]) + self.assertEqual(paths, ["DocumentReference.a.b"]) + + def test_same_level_duplicates_objects(self): + # This JSON has duplicates because there are two 'b' keys at the same level + # The difference with above is that the 'b' keys are objects and every element in the object is the same + json_content = ( + '{"a": {"b": { "f": 4, "g": 5 }, "b": { "f": 4, "g": 5 } }, "c": {"d": 3}}' + ) + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual(duplicates, ["b"]) + self.assertEqual(paths, ["DocumentReference.a.b"]) + + def test_multiple_level_duplicates(self): + # This JSON has duplicates at multiple levels + json_content = '{"a": 1, "b": {"c": 2, "c": 3}, "a": 4}' + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual(sorted(duplicates), sorted(["a", "c"])) + self.assertEqual( + sorted(paths), sorted(["DocumentReference.a", "DocumentReference.b.c"]) + ) + + def test_invalid_json(self): + json_content = "{invalid json}" + with self.assertRaises(ValueError): + check_duplicate_keys(json_content) + + def test_complex_nested_duplicates(self): + json_content = '{"a": {"b": 1, "c": {"d": 2, "c": 3}}, "a": {"e": 4}}' + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual(sorted(duplicates), sorted(["a"])) + self.assertEqual(sorted(paths), sorted(["DocumentReference.a"])) + + def test_multiple_duplicates_same_path(self): + json_content = """ + { + "a": 1, + "b": { + "c": 2, + "c": 3, + "d": { + "e": 4, + "e": 5, + "f": { + "g": 6, + "g": 7 + } + } + }, + "b": { + "h": 8 + } + } + """ + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual(sorted(duplicates), sorted(["b", "c", "e", "g"])) + self.assertEqual( + sorted(paths), + sorted( + [ + "DocumentReference.b", + "DocumentReference.b.c", + "DocumentReference.b.d.e", + "DocumentReference.b.d.f.g", + ] + ), + ) + + def test_no_duplicates_deeply_nested(self): + json_content = """ + { + "a": { + "b": { + "c": 1 + }, + "d": { + "e": 2 + } + }, + "f": { + "g": { + "h": 3 + } + } + } + """ + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual(duplicates, []) + self.assertEqual(paths, []) + + def test_duplicates_with_arrays(self): + json_content = """ + { + "a": [ + {"b": 1, "b": 2}, + {"c": 3, "c": 4} + ], + "d": 5 + } + """ + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual(sorted(duplicates), sorted(["b", "c"])) + self.assertEqual( + sorted(paths), + sorted(["DocumentReference.a[0].b", "DocumentReference.a[1].c"]), + ) + + def test_large_json_with_mixed_duplicates(self): + json_content = """ + { + "a": 1, + "b": { + "c": 2, + "d": 3, + "c": 4, + "e": { + "f": 5, + "f": 6, + "g": { + "h": 7, + "h": 8 + } + } + }, + "i": { + "j": 10, + "j": 11 + } + } + """ + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual(sorted(duplicates), sorted(["c", "f", "h", "j"])) + self.assertEqual( + sorted(paths), + sorted( + [ + "DocumentReference.b.c", + "DocumentReference.b.e.f", + "DocumentReference.b.e.g.h", + "DocumentReference.i.j", + ] + ), + ) + + def test_complex_nested_arrays_with_duplicates(self): + json_content = """ + { + "level1": { + "arrays": [ + { + "a": 1, + "a": 2, + "nested": { + "b": [ + {"c": 3, "c": 4}, + {"d": 5} + ], + "b": "duplicate" + } + }, + { + "mixed": [ + {"e": 6}, + {"e": 7, "f": [ + {"g": 8, "g": 9}, + {"h": {"i": 10, "i": 11}} + ]} + ], + "mixed": "duplicate" + } + ], + "arrays": "duplicate_at_parent" + } + } + """ + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual( + sorted(duplicates), sorted(["a", "b", "c", "g", "i", "mixed", "arrays"]) + ) + self.assertEqual( + sorted(paths), + sorted( + [ + "DocumentReference.level1.arrays", + "DocumentReference.level1.arrays[0].a", + "DocumentReference.level1.arrays[0].nested.b", + "DocumentReference.level1.arrays[0].nested.b[0].c", + "DocumentReference.level1.arrays[1].mixed", + "DocumentReference.level1.arrays[1].mixed[1].f[0].g", + "DocumentReference.level1.arrays[1].mixed[1].f[1].h.i", + ] + ), + ) + + def test_deep_nested_array_object_duplicates(self): + json_content = """ + { + "root": { + "level1": [ + { + "level2": [ + [ + { + "data": 1, + "data": 2, + "unique": 3 + } + ], + [ + { + "other": 4, + "other": 5 + } + ] + ] + } + ] + } + } + """ + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual(sorted(duplicates), sorted(["data", "other"])) + self.assertEqual( + sorted(paths), + sorted( + [ + "DocumentReference.root.level1[0].level2[0][0].data", + "DocumentReference.root.level1[0].level2[1][0].other", + ] + ), + ) + + def generate_nested_json(self, depth, current_level=0): + """Helper function to generate nested JSON with duplicates at each level.""" + if depth == 0: + return '{"key": 1, "key": 2}' + + next_json = self.generate_nested_json(depth - 1, current_level + 1) + return f"""{{ + "level{current_level}": [ + {{ + "array_obj": {next_json}, + "array_obj": "duplicate_at_depth_{current_level}" + }} + ] + }}""" + + def get_expected_duplicates(self, max_depth): + """Helper function to get expected duplicate keys.""" + duplicates = ["array_obj"] # array_obj appears at each level + duplicates.extend(["key"]) # key appears at the innermost level + return sorted(list(set(duplicates))) + + def get_expected_paths(self, max_depth): + """Helper function to get expected duplicate paths.""" + paths = [] + current_path = "DocumentReference" + + # Start from level0 and increment + for i in range(max_depth): + current_path += f".level{i}[0]" + paths.append(f"{current_path}.array_obj") + if i < max_depth - 1: # If not at the last level + current_path += ".array_obj" # Navigate into the nested object + + # Add the key duplicate at the innermost level + if max_depth > 0: + paths.append(f"{current_path}.array_obj.key") + + return sorted(paths) + + def test_parametrized_nested_arrays(self): + """Test different depths of nested arrays with duplicates at each level.""" + for depth in range(1, 11): # Test depths 1 through 10 + with self.subTest(depth=depth): + json_content = self.generate_nested_json(depth) + + duplicates, paths = check_duplicate_keys(json_content) + + expected_duplicates = self.get_expected_duplicates(depth) + expected_paths = self.get_expected_paths(depth) + + self.assertEqual( + sorted(duplicates), + sorted(expected_duplicates), + f"Failed for depth {depth} - duplicates mismatch", + ) + self.assertEqual( + sorted(paths), + sorted(expected_paths), + f"Failed for depth {depth} - paths mismatch", + ) + + def test_array_edge_case_duplicate(self): + json_content = """ + { + "array": [ + 1, + "string", + {"key": "value"}, + [1, 2, 3] + ], + "array": "duplicate" + } + """ + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual(duplicates, ["array"]) + self.assertEqual(paths, ["DocumentReference.array"]) + + def test_case_sensitive_keys(self): + json_content = '{"a": 1, "A": 2, "aA": 3, "Aa": 4}' + duplicates, paths = check_duplicate_keys(json_content) + self.assertEqual(duplicates, ["A", "Aa"]) + self.assertEqual(paths, ["DocumentReference.A", "DocumentReference.Aa"]) diff --git a/layer/nrlf/core/tests/test_json_duplicate_checker_nrlf.py b/layer/nrlf/core/tests/test_json_duplicate_checker_nrlf.py new file mode 100644 index 000000000..66595a909 --- /dev/null +++ b/layer/nrlf/core/tests/test_json_duplicate_checker_nrlf.py @@ -0,0 +1,62 @@ +import json + +import pytest +from json_duplicate_checker import check_duplicate_keys + +from layer.nrlf.tests.data import load_document_reference_data + + +def get_all_fields_from_json(json_str): + def extract_fields(data, parent_key=""): + fields = [] + if isinstance(data, dict): + for k, v in data.items(): + full_key = f"{parent_key}.{k}" if parent_key else k + fields.append(full_key) + fields.extend(extract_fields(v, full_key)) + elif isinstance(data, list): + for i, item in enumerate(data): + full_key = f"{parent_key}[{i}]" + fields.extend(extract_fields(item, full_key)) + return fields + + data = json.loads(json_str) + return extract_fields(data) + + +def duplicate_field_in_json(json_str, field_path): + data = json.loads(json_str) + path = field_path.replace("]", "").replace("[", ".").split(".") + current = data + for key in path[:-1]: + current = current[int(key) if key.isdigit() else key] + field = path[-1] + + if field in current: + duplicate_field = f"{field}_duplicate" + current[duplicate_field] = current[field] + modified_json_str = json.dumps(data) + # Replace the duplicate field name with the original field name to simulate duplication + duplicated_json_str = modified_json_str.replace( + f'"{duplicate_field}":', f'"{field}":', 1 + ) + return duplicated_json_str + return json_str + + +def load_document_reference_data_with_all_fields(): + docref_body = load_document_reference_data("Y05868-736253002-Valid") + return get_all_fields_from_json(docref_body) + + +@pytest.mark.parametrize("field", load_document_reference_data_with_all_fields()) +def test_parse_body_valid_docref_with_duplicate_keys(field): + docref_body = load_document_reference_data("Y05868-736253002-Valid") + + docref_body = duplicate_field_in_json(docref_body, field) + + result = check_duplicate_keys(docref_body) + + node = field.split(".")[-1] + assert result[0] == [node] + assert result[1] == [f"DocumentReference.{field}"] diff --git a/layer/nrlf/core/tests/test_request.py b/layer/nrlf/core/tests/test_request.py index 6f4ec8d92..e9d8706a5 100644 --- a/layer/nrlf/core/tests/test_request.py +++ b/layer/nrlf/core/tests/test_request.py @@ -151,6 +151,42 @@ def test_parse_body_valid_docref(): assert isinstance(result, DocumentReference) +# another test similar to test_parse_body_valid_docref but with a duplicate key +def test_parse_body_valid_docref_with_duplicate_key(): + model = DocumentReference + docref_body = load_document_reference_data("Y05868-736253002-Valid") + + str_to_duplicate = '"docStatus": "final",' + docref_body = docref_body.replace(str_to_duplicate, str_to_duplicate * 2) + + with pytest.raises(OperationOutcomeError) as error: + parse_body(model, docref_body) + + response = error.value.response + + assert response.statusCode == "400" + assert json.loads(response.body) == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "MESSAGE_NOT_WELL_FORMED", + "display": "Message not well formed", + } + ] + }, + "diagnostics": "Duplicate keys found in FHIR document: ['docStatus']", + "expression": ["DocumentReference.docStatus"], + } + ], + } + + def test_parse_body_no_body(): model = DocumentReference body = None diff --git a/tests/features/producer/createDocumentReference-duplicateField.feature b/tests/features/producer/createDocumentReference-duplicateField.feature new file mode 100644 index 000000000..beed7225c --- /dev/null +++ b/tests/features/producer/createDocumentReference-duplicateField.feature @@ -0,0 +1,128 @@ +Feature: Producer - createDocumentReference - Duplicate Field Scenarios + + Scenario: Duplicate url field in attachment + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + And the organisation 'TSTCUS' is authorised to access pointer types: + | system | value | + | http://snomed.info/sct | 736253002 | + When producer 'TSTCUS' requests creation of a DocumentReference with default test values except 'content' is: + """ + "content": [ + { + "attachment": { + "contentType": "application/pdf", + "url": "https://example.org/my-doc.pdf", + "url": "https://example.org/duplicate-url.pdf" + }, + "format": { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode", + "code": "urn:nhs-ic:unstructured", + "display": "Unstructured Document" + }, + "extension": [ + { + "url": "https://fhir.nhs.uk/England/StructureDefinition/Extension-England-ContentStability", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLContentStability", + "code": "static", + "display": "Static" + } + ] + } + } + ] + } + ] + """ + Then the response status code is 400 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "error", + "code": "invalid", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "MESSAGE_NOT_WELL_FORMED", + "display": "Message not well formed" + } + ] + }, + "diagnostics": "Duplicate keys found in FHIR document: ['url']", + "expression": [ + "DocumentReference.content[0].attachment.url" + ] + } + """ + + Scenario: Duplicate format and attachement field in content + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + And the organisation 'TSTCUS' is authorised to access pointer types: + | system | value | + | http://snomed.info/sct | 736253002 | + When producer 'TSTCUS' requests creation of a DocumentReference with default test values except 'content' is: + """ + "content": [ + { + "attachment": { + "contentType": "application/pdf", + "url": "https://example.org/my-doc.pdf" + }, + "attachment": { + "contentType": "text/html", + "url": "https://example.org/contact-details.html" + }, + "format": { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode", + "code": "urn:nhs-ic:unstructured", + "display": "Unstructured Document" + }, + "format": { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode", + "code": "urn:nhs-ic:record-contact", + "display": "Contact details (HTTP Unsecured)" + }, + "extension": [ + { + "url": "https://fhir.nhs.uk/England/StructureDefinition/Extension-England-ContentStability", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLContentStability", + "code": "static", + "display": "Static" + } + ] + } + } + ] + } + ] + """ + Then the response status code is 400 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "error", + "code": "invalid", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "MESSAGE_NOT_WELL_FORMED", + "display": "Message not well formed" + } + ] + }, + "diagnostics": "Duplicate keys found in FHIR document: ['attachment', 'format']", + "expression": [ + "DocumentReference.content[0].attachment", + "DocumentReference.content[0].format" + ] + } + """ diff --git a/tests/features/producer/updateDocumentReference-duplicateField.feature b/tests/features/producer/updateDocumentReference-duplicateField.feature new file mode 100644 index 000000000..89c73c843 --- /dev/null +++ b/tests/features/producer/updateDocumentReference-duplicateField.feature @@ -0,0 +1,150 @@ +Feature: Producer - updateDocumentReference - Duplicate Field Scenarios + + Scenario: Duplicate url field in attachment + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + And the organisation 'TSTCUS' is authorised to access pointer types: + | system | value | + | http://snomed.info/sct | 736253002 | + And a DocumentReference resource exists with values: + | property | value | + | id | TSTCUS-updateDuplicateTest-1234 | + | subject | 9999999999 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc.pdf | + | custodian | TSTCUS | + | author | TSTCUS | + When producer 'TSTCUS' requests update of a DocumentReference with pointerId 'TSTCUS-updateDuplicateTest-1234' but replacing 'content': + """ + [ + { + "attachment": { + "contentType": "application/pdf", + "url": "https://example.org/my-doc.pdf", + "url": "https://example.org/duplicate-url.pdf" + }, + "format": { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode", + "code": "urn:nhs-ic:unstructured", + "display": "Unstructured Document" + }, + "extension": [ + { + "url": "https://fhir.nhs.uk/England/StructureDefinition/Extension-England-ContentStability", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLContentStability", + "code": "static", + "display": "Static" + } + ] + } + } + ] + } + ] + """ + Then the response status code is 400 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "error", + "code": "invalid", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "MESSAGE_NOT_WELL_FORMED", + "display": "Message not well formed" + } + ] + }, + "diagnostics": "Duplicate keys found in FHIR document: ['url']", + "expression": [ + "DocumentReference.content[0].attachment.url" + ] + } + """ + + Scenario: Duplicate format and attachment field in content + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + And the organisation 'TSTCUS' is authorised to access pointer types: + | system | value | + | http://snomed.info/sct | 736253002 | + And a DocumentReference resource exists with values: + | property | value | + | id | TSTCUS-updateDuplicateTest-1235 | + | subject | 9999999999 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc.pdf | + | custodian | TSTCUS | + | author | TSTCUS | + When producer 'TSTCUS' requests update of a DocumentReference with pointerId 'TSTCUS-updateDuplicateTest-1235' but replacing 'content': + """ + [ + { + "attachment": { + "contentType": "application/pdf", + "url": "https://example.org/my-doc.pdf" + }, + "attachment": { + "contentType": "text/html", + "url": "https://example.org/contact-details.html" + }, + "format": { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode", + "code": "urn:nhs-ic:unstructured", + "display": "Unstructured Document" + }, + "format": { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode", + "code": "urn:nhs-ic:record-contact", + "display": "Contact details (HTTP Unsecured)" + }, + "extension": [ + { + "url": "https://fhir.nhs.uk/England/StructureDefinition/Extension-England-ContentStability", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLContentStability", + "code": "static", + "display": "Static" + } + ] + } + } + ] + } + ] + """ + Then the response status code is 400 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "error", + "code": "invalid", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "MESSAGE_NOT_WELL_FORMED", + "display": "Message not well formed" + } + ] + }, + "diagnostics": "Duplicate keys found in FHIR document: ['attachment', 'format']", + "expression": [ + "DocumentReference.content[0].attachment", + "DocumentReference.content[0].format" + ] + } + """ diff --git a/tests/features/producer/upsertDocumentReference-duplicateField.feature b/tests/features/producer/upsertDocumentReference-duplicateField.feature new file mode 100644 index 000000000..5fcada22f --- /dev/null +++ b/tests/features/producer/upsertDocumentReference-duplicateField.feature @@ -0,0 +1,128 @@ +Feature: Producer - upsertDocumentReference - Duplicate Field Scenarios + + Scenario: Duplicate url field in attachment + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + And the organisation 'TSTCUS' is authorised to access pointer types: + | system | value | + | http://snomed.info/sct | 736253002 | + When producer 'TSTCUS' requests upsert of a DocumentReference with pointerId 'TSTCUS-testduplicates-upsert-0001-0001' and default test values except 'content' is: + """ + "content": [ + { + "attachment": { + "contentType": "application/pdf", + "url": "https://example.org/my-doc.pdf", + "url": "https://example.org/duplicate-url.pdf" + }, + "format": { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode", + "code": "urn:nhs-ic:unstructured", + "display": "Unstructured Document" + }, + "extension": [ + { + "url": "https://fhir.nhs.uk/England/StructureDefinition/Extension-England-ContentStability", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLContentStability", + "code": "static", + "display": "Static" + } + ] + } + } + ] + } + ] + """ + Then the response status code is 400 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "error", + "code": "invalid", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "MESSAGE_NOT_WELL_FORMED", + "display": "Message not well formed" + } + ] + }, + "diagnostics": "Duplicate keys found in FHIR document: ['url']", + "expression": [ + "DocumentReference.content[0].attachment.url" + ] + } + """ + + Scenario: Duplicate format and attachment field in content + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + And the organisation 'TSTCUS' is authorised to access pointer types: + | system | value | + | http://snomed.info/sct | 736253002 | + When producer 'TSTCUS' requests upsert of a DocumentReference with pointerId 'TSTCUS-testduplicates-upsert-0001-0002' and default test values except 'content' is: + """ + "content": [ + { + "attachment": { + "contentType": "application/pdf", + "url": "https://example.org/my-doc.pdf" + }, + "attachment": { + "contentType": "text/html", + "url": "https://example.org/contact-details.html" + }, + "format": { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode", + "code": "urn:nhs-ic:unstructured", + "display": "Unstructured Document" + }, + "format": { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode", + "code": "urn:nhs-ic:record-contact", + "display": "Contact details (HTTP Unsecured)" + }, + "extension": [ + { + "url": "https://fhir.nhs.uk/England/StructureDefinition/Extension-England-ContentStability", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLContentStability", + "code": "static", + "display": "Static" + } + ] + } + } + ] + } + ] + """ + Then the response status code is 400 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "error", + "code": "invalid", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "MESSAGE_NOT_WELL_FORMED", + "display": "Message not well formed" + } + ] + }, + "diagnostics": "Duplicate keys found in FHIR document: ['attachment', 'format']", + "expression": [ + "DocumentReference.content[0].attachment", + "DocumentReference.content[0].format" + ] + } + """ diff --git a/tests/features/steps/2_request.py b/tests/features/steps/2_request.py index 993fae1c7..a904982a7 100644 --- a/tests/features/steps/2_request.py +++ b/tests/features/steps/2_request.py @@ -134,6 +134,26 @@ def upsert_post_body_step(context: Context, section: str, pointer_id: str): _create_or_upsert_body_step(context, "upsert_text", section, pointer_id) +@when( + "producer 'TSTCUS' requests update of a DocumentReference with pointerId '{pointer_id}' but replacing '{section}'" +) +def update_post_body_step(context: Context, section: str, pointer_id: str): + """This can only update top level fields""" + consumer_client = consumer_client_from_context(context, "TSTCUS") + context.response = consumer_client.read(pointer_id) + + if context.response.status_code != 200: + raise ValueError(f"Failed to read existing pointer: {context.response.text}") + + doc_ref = context.response.json() + doc_ref[section] = "placeholder" + doc_ref_text = json.dumps(doc_ref) + doc_ref_text = doc_ref_text.replace('"placeholder"', context.text) + + producer_client = producer_client_from_context(context, "TSTCUS") + context.response = producer_client.update_text(doc_ref_text, pointer_id) + + @when( "producer 'TSTCUS' requests update of a DocumentReference with pointerId '{pointer_id}' and only changing" ) diff --git a/tests/utilities/api_clients.py b/tests/utilities/api_clients.py index 3b1b78e9f..762b1c1b4 100644 --- a/tests/utilities/api_clients.py +++ b/tests/utilities/api_clients.py @@ -229,6 +229,14 @@ def update(self, doc_ref, doc_ref_id: str): cert=self.config.client_cert, ) + def update_text(self, doc_ref, doc_ref_id: str): + return requests.put( + f"{self.api_url}/DocumentReference/{doc_ref_id}", + data=doc_ref, + headers=self.request_headers, + cert=self.config.client_cert, + ) + def delete(self, doc_ref_id: str): return requests.delete( f"{self.api_url}/DocumentReference/{doc_ref_id}",