Skip to content

Commit 1de68e9

Browse files
Use consistent error format in delta. (#580)
* Use consistent error format in delta. Wait until service is ready to run e2e tests. * Increase number of attempts. Run check for sandbox too. * Remove unused switch. Tidy up error handling in Converter init. * Refactor error handling and improve test coverage for Converter and Extractor classes * Enhance error reporting in DeltaHandlerTestCase to include detailed conversion error records * Refactor Converter class to improve type annotations and enhance code clarity --------- Co-authored-by: Thomas-Boyle <thomasboyle@kainos.com> Co-authored-by: Thomas-Boyle <45789537+Thomas-Boyle@users.noreply.github.com>
1 parent 14a4b79 commit 1de68e9

4 files changed

Lines changed: 99 additions & 107 deletions

File tree

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,29 @@
11
# Main validation engine
2+
from typing import Any
3+
24
import exception_messages
35
from conversion_layout import ConversionField, ConversionLayout
46
from extractor import Extractor
57
from mappings import ActionFlag
68

9+
ConversionErrorRecord = dict[str, Any]
10+
ConvertedRecord = dict[str, Any]
11+
712

813
class Converter:
9-
def __init__(self, fhir_data, action_flag=ActionFlag.UPDATE, report_unexpected_exception=True):
10-
self.converted = {}
11-
self.error_records = []
14+
def __init__(self, fhir_data: str | dict[str, Any], action_flag: str = ActionFlag.UPDATE) -> None:
15+
self.converted: ConvertedRecord = {}
16+
self.error_records: list[ConversionErrorRecord] = []
1217
self.action_flag = action_flag
13-
self.report_unexpected_exception = report_unexpected_exception
14-
15-
try:
16-
if not fhir_data:
17-
raise ValueError("FHIR data is required for initialization.")
1818

19-
self.extractor = Extractor(fhir_data, self.report_unexpected_exception)
20-
self.conversion_layout = ConversionLayout(self.extractor)
21-
except Exception as e:
22-
if report_unexpected_exception:
23-
self._log_error(f"Initialization failed: [{e.__class__.__name__}] {e}")
24-
raise
19+
if not fhir_data:
20+
raise ValueError("FHIR data is required for initialization.")
2521

26-
def run_conversion(self):
27-
conversions = self.conversion_layout.get_conversion_layout()
22+
self.extractor = Extractor(fhir_data)
23+
self.conversion_layout = ConversionLayout(self.extractor)
2824

29-
for conversion in conversions:
25+
def run_conversion(self) -> ConvertedRecord:
26+
for conversion in self.conversion_layout.get_conversion_layout():
3027
self._convert_data(conversion)
3128

3229
self.error_records.extend(self.extractor.get_error_records())
@@ -35,29 +32,33 @@ def run_conversion(self):
3532
self.converted["CONVERSION_ERRORS"] = self.error_records
3633
return self.converted
3734

38-
def _convert_data(self, conversion: ConversionField):
39-
try:
40-
flat_field = conversion.field_name_flat
35+
def _convert_data(self, conversion: ConversionField) -> None:
36+
flat_field = conversion.field_name_flat
4137

38+
try:
4239
if flat_field == "ACTION_FLAG":
4340
self.converted[flat_field] = self.action_flag
44-
else:
45-
converted = conversion.expression_rule()
46-
if converted is not None:
47-
self.converted[flat_field] = converted
41+
return
4842

49-
except Exception as e:
43+
if (converted := conversion.expression_rule()) is not None:
44+
self.converted[flat_field] = converted
45+
except Exception as error:
5046
self._log_error(
51-
f"Conversion error [{e.__class__.__name__}]: {e}",
47+
flat_field,
48+
f"Conversion error [{error.__class__.__name__}]: {error}",
5249
code=exception_messages.PARSING_ERROR,
5350
)
5451
self.converted[flat_field] = ""
5552

56-
def _log_error(self, e, code=exception_messages.UNEXPECTED_EXCEPTION):
57-
error_obj = {"code": code, "message": str(e)}
58-
59-
if self.report_unexpected_exception:
60-
self.error_records.append(error_obj)
53+
def _log_error(self, field_name: str, e: Exception | str, code: str) -> None:
54+
self.error_records.append(
55+
{
56+
"code": code,
57+
"field": field_name,
58+
"value": None,
59+
"message": str(e),
60+
}
61+
)
6162

62-
def get_error_records(self):
63+
def get_error_records(self) -> list[ConversionErrorRecord]:
6364
return self.error_records

lambdas/delta_backend/src/extractor.py

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,12 @@ class Extractor:
2222
DATE_CONVERT_FORMAT = "%Y%m%d"
2323
DEFAULT_POSTCODE = "ZZ99 3CZ"
2424

25-
def __init__(self, fhir_json_data, report_unexpected_exception=True):
25+
def __init__(self, fhir_json_data):
2626
self.fhir_json_data = (
2727
json.loads(fhir_json_data, parse_float=decimal.Decimal)
2828
if isinstance(fhir_json_data, str)
2929
else fhir_json_data
3030
)
31-
self.report_unexpected_exception = report_unexpected_exception
3231
self.error_records = []
3332

3433
def _get_patient(self):
@@ -174,23 +173,20 @@ def _get_site_information(self):
174173
return site_code, site_code_type_uri
175174

176175
def _log_error(self, field_name, field_value, e, code=exception_messages.RECORD_CHECK_FAILED):
177-
if self.report_unexpected_exception:
178-
if isinstance(e, Exception):
179-
message = exception_messages.MESSAGES[exception_messages.UNEXPECTED_EXCEPTION] % (
180-
e.__class__.__name__,
181-
str(e),
182-
)
183-
else:
184-
message = str(e)
185-
186-
self.error_records.append(
187-
{
188-
"code": code,
189-
"field": field_name,
190-
"value": field_value,
191-
"message": message,
192-
}
193-
)
176+
message = (
177+
exception_messages.MESSAGES[exception_messages.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, str(e))
178+
if isinstance(e, Exception)
179+
else str(e)
180+
)
181+
182+
self.error_records.append(
183+
{
184+
"code": code,
185+
"field": field_name,
186+
"value": field_value,
187+
"message": message,
188+
}
189+
)
194190

195191
def _convert_date(self, field_name, date, format) -> str:
196192
"""

lambdas/delta_backend/tests/test_convert.py

Lines changed: 35 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ def get_event(event_name=EventName.CREATE, operation="operation", supplier="EMIS
8989
"""Returns test event data."""
9090
return ValuesForTests.get_event(event_name, operation, supplier)
9191

92+
def assert_structured_error_records(self, error_records):
93+
self.assertTrue(error_records)
94+
95+
for error_record in error_records:
96+
self.assertEqual(set(error_record), {"code", "field", "value", "message"})
97+
self.assertTrue(error_record["message"])
98+
9299
def assert_dynamodb_record(
93100
self,
94101
operation_flag,
@@ -139,68 +146,47 @@ def test_fhir_converter_json_direct_data(self):
139146
json_data = json.dumps(ValuesForTests.json_data)
140147

141148
fhir_converter = Converter(json_data)
142-
FlatFile = fhir_converter.run_conversion()
149+
result = fhir_converter.run_conversion()
143150

144-
flatJSON = json.dumps(FlatFile)
145151
expected_imms_value = deepcopy(ValuesForTests.expected_imms2) # UPDATE is currently the default action-flag
146-
expected_imms = json.dumps(expected_imms_value)
147-
self.assertEqual(flatJSON, expected_imms)
148-
149-
errorRecords = fhir_converter.get_error_records()
152+
self.assertEqual(result, expected_imms_value)
153+
self.assertFalse(fhir_converter.get_error_records())
150154

151-
self.assertEqual(len(errorRecords), 0)
152-
153-
def test_fhir_converter_json_error_scenario_reporting_on(self):
155+
def test_fhir_converter_json_error_scenario(self):
154156
"""it should convert fhir json data to flat json - error scenarios"""
155-
error_test_cases = [
156-
ErrorValuesForTests.missing_json,
157-
ErrorValuesForTests.json_dob_error,
158-
]
157+
error_test_cases = {
158+
"missing_json": ErrorValuesForTests.missing_json,
159+
"json_dob_error": ErrorValuesForTests.json_dob_error,
160+
}
159161

160-
for test_case in error_test_cases:
161-
json_data = json.dumps(test_case)
162+
for test_name, test_case in error_test_cases.items():
163+
with self.subTest(test_name=test_name):
164+
fhir_converter = Converter(json.dumps(test_case))
165+
fhir_converter.run_conversion()
162166

163-
fhir_converter = Converter(json_data)
164-
fhir_converter.run_conversion()
167+
self.assert_structured_error_records(fhir_converter.get_error_records())
165168

166-
errorRecords = fhir_converter.get_error_records()
167-
168-
# Check if bad data creates error records
169-
self.assertTrue(len(errorRecords) > 0)
170-
171-
def test_fhir_converter_json_error_scenario_reporting_off(self):
169+
def test_fhir_converter_json_incorrect_data_scenario(self):
172170
"""it should convert fhir json data to flat json - error scenarios"""
173-
error_test_cases = [
174-
ErrorValuesForTests.missing_json,
175-
ErrorValuesForTests.json_dob_error,
176-
]
177-
178-
for test_case in error_test_cases:
179-
json_data = json.dumps(test_case)
180-
181-
fhir_converter = Converter(json_data, report_unexpected_exception=False)
182-
fhir_converter.run_conversion()
183171

184-
errorRecords = fhir_converter.get_error_records()
172+
with self.assertRaisesRegex(ValueError, "FHIR data is required for initialization."):
173+
Converter(None)
185174

186-
# Check if bad data creates error records
187-
self.assertTrue(len(errorRecords) == 0)
175+
def test_handler_persists_structured_conversion_errors(self):
176+
event = self.get_event(operation=Operation.UPDATE)
177+
event["Records"][0]["dynamodb"]["NewImage"]["Resource"]["S"] = json.dumps(ErrorValuesForTests.json_dob_error)
188178

189-
def test_fhir_converter_json_incorrect_data_scenario_reporting_on(self):
190-
"""it should convert fhir json data to flat json - error scenarios"""
191-
192-
with self.assertRaises(ValueError):
193-
fhir_converter = Converter(None)
194-
errorRecords = fhir_converter.get_error_records()
195-
self.assertTrue(len(errorRecords) > 0)
179+
response = handler(event, None)
196180

197-
def test_fhir_converter_json_incorrect_data_scenario_reporting_off(self):
198-
"""it should convert fhir json data to flat json - error scenarios"""
181+
result = self.table.scan()
182+
items = result.get("Items", [])
183+
self.assertTrue(response)
184+
self.assertEqual(len(items), 1)
199185

200-
with self.assertRaises(ValueError):
201-
fhir_converter = Converter(None, report_unexpected_exception=False)
202-
errorRecords = fhir_converter.get_error_records()
203-
self.assertTrue(len(errorRecords) == 0)
186+
error_records = items[0]["Imms"]["CONVERSION_ERRORS"]
187+
self.assert_structured_error_records(error_records)
188+
self.assertEqual(error_records[0]["field"], "PERSON_DOB")
189+
self.assertEqual(error_records[0]["value"], "196513-28")
204190

205191
def test_handler_imms_convert_to_flat_json(self):
206192
"""Test that the Imms field contains the correct flat JSON data for CREATE, UPDATE, and DELETE operations."""
@@ -235,8 +221,6 @@ def test_handler_imms_convert_to_flat_json(self):
235221
response,
236222
)
237223

238-
result = self.table.scan()
239-
items = result.get("Items", [])
240224
self.clear_table()
241225

242226
def test_handler_imms_convert_to_flat_json_legacy_patientsk_compatibility(self):

lambdas/delta_backend/tests/test_delta.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -511,9 +511,17 @@ def test_dps_record_skipped(self, mock_logger_info):
511511

512512
@patch("delta.Converter")
513513
def test_partial_success_with_errors(self, mock_converter):
514+
expected_error_records = [
515+
{
516+
"code": 10,
517+
"field": "PERSON_DOB",
518+
"value": "196513-28",
519+
"message": "Unexpected exception [ValueError]: Invalid isoformat string: '196513-28'",
520+
}
521+
]
514522
mock_converter_instance = MagicMock()
515523
mock_converter_instance.run_conversion.return_value = {"ABC": "DEF"}
516-
mock_converter_instance.get_error_records.return_value = [{"error": "Invalid field"}]
524+
mock_converter_instance.get_error_records.return_value = expected_error_records
517525
mock_converter.return_value = mock_converter_instance
518526

519527
# Mock DynamoDB put_item success
@@ -533,13 +541,16 @@ def test_partial_success_with_errors(self, mock_converter):
533541
args, kwargs = self.mock_send_log_to_firehose.call_args
534542
sent_payload = args[1] # Second positional arg
535543

536-
# Navigate to the specific message
537-
status_desc = sent_payload["operation_outcome"]["statusDesc"]
544+
operation_outcome = sent_payload["operation_outcome"]
538545

539-
# Assert the expected message is present
540546
self.assertIn(
541547
"Partial success: successfully synced into delta, but issues found within record",
542-
status_desc,
548+
operation_outcome["statusDesc"],
549+
)
550+
self.assertEqual(operation_outcome["diagnostics"], expected_error_records)
551+
self.mock_logger.warning.assert_called_once_with(
552+
"Partial success: record synced with conversion errors",
553+
extra={"conversion_errors": expected_error_records},
543554
)
544555

545556
def test_send_message_multi_records_diverse(self):

0 commit comments

Comments
 (0)