Skip to content

Commit b9a48fd

Browse files
authored
Merge branch 'master' into feature/VED-810-backend-deploy-pipeline
2 parents 01bed26 + 95dcfbc commit b9a48fd

23 files changed

+1030
-526
lines changed

azure/templates/post-deploy.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,6 @@ steps:
225225
226226
displayName: Run full batch test suite
227227
workingDirectory: "$(Pipeline.Workspace)/s/$(SERVICE_NAME)/$(SERVICE_ARTIFACT_NAME)/e2e_batch"
228-
condition: eq(1, 2) # Disable task but make this step visible in the pipeline
229228
230229
- task: PublishTestResults@2
231230
displayName: 'Publish test results'

backend/tests/utils/generic_utils.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from decimal import Decimal
77
from typing import Literal, Any
88
from jsonpath_ng.ext import parse
9+
from datetime import datetime, date
10+
from typing import Union, List
911

1012

1113
def load_json_data(filename: str):
@@ -91,3 +93,21 @@ def update_contained_resource_field(
9193
{field_to_update: update_value}
9294
)
9395
return json_data
96+
97+
def format_date_types(dates: List[Union[date, datetime]], mode: str = "auto") -> List[str]:
98+
"""
99+
Accepts a list of date or datetime objects and returns them as strings:
100+
- datetime → ISO 8601 string with timezone if present
101+
- date → 'YYYY-MM-DD'
102+
"""
103+
formatted = []
104+
105+
for future_date in dates:
106+
if mode == "datetime":
107+
formatted.append(future_date.isoformat()) # full datetime with timezone
108+
elif mode == "date":
109+
formatted.append(future_date.strftime('%Y-%m-%d')) # just date
110+
else:
111+
raise TypeError(f"Unsupported type {type(future_date)}; expected date or datetime.")
112+
113+
return formatted

backend/tests/utils/test_generic_utils.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import unittest
44
from src.models.utils.generic_utils import form_json
5-
from tests.utils.generic_utils import load_json_data
5+
from tests.utils.generic_utils import load_json_data, format_date_types
6+
7+
import unittest
8+
from datetime import datetime, date
69

710

811
class TestFormJson(unittest.TestCase):
@@ -70,3 +73,25 @@ def test_elements_whitespace_and_case_are_handled(self):
7073
)
7174
self.assertEqual(res["id"], self.response["id"])
7275
self.assertEqual(res["meta"]["versionId"], self.response["version"])
76+
77+
78+
class TestFormatFutureDates(unittest.TestCase):
79+
def test_date_mode_formats_dates_and_datetimes(self):
80+
inputs = [date(2100, 1, 2), datetime(2100, 1, 3, 12, 0, 0)]
81+
expected = ["2100-01-02", "2100-01-03"]
82+
self.assertEqual(format_date_types(inputs, mode="date"), expected)
83+
84+
def test_datetime_mode_formats_dates_and_datetimes(self):
85+
inputs = [date(2100, 1, 2), datetime(2100, 1, 3, 12, 0, 0)]
86+
expected = ["2100-01-02", "2100-01-03T12:00:00"]
87+
self.assertEqual(format_date_types(inputs, mode="datetime"), expected)
88+
89+
def test_default_auto_mode_is_currently_unsupported(self):
90+
# Current implementation raises TypeError when mode is not 'date' or 'datetime'
91+
inputs = [date(2100, 1, 2)]
92+
with self.assertRaises(TypeError):
93+
format_date_types(inputs) # default mode is 'auto'
94+
95+
96+
if __name__ == "__main__":
97+
unittest.main()

backend/tests/utils/values_for_tests.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
from dataclasses import dataclass
44
from decimal import Decimal
5+
from .generic_utils import format_date_types
6+
from datetime import datetime, timedelta
7+
58

69
# Lists of data types for 'invalid data type' testing
710
integers = [-1, 0, 1]
@@ -291,16 +294,20 @@ class InvalidValues:
291294
"2000-02-30", # Invalid combination of month and day
292295
]
293296

294-
for_future_dates = [
295-
"2100-01-01", # Year in future
296-
"2050-12-31", # Year in future
297-
"2029-06-15", # Year in future
297+
now = datetime.now()
298+
sample_inputs = [
299+
now + timedelta(days=1),
300+
now + timedelta(days=365),
301+
now + timedelta(days=730)
298302
]
299303

304+
for_future_dates = format_date_types(sample_inputs, mode = "date")
305+
300306
# Strings which are not in acceptable date time format
301307
for_date_time_string_formats_for_relaxed_timezone = [
302308
"", # Empty string
303309
"invalid", # Invalid format
310+
*format_date_types(sample_inputs, mode = "datetime"),
304311
"20000101", # Date digits only (i.e. without hypens)
305312
"20000101000000", # Date and time digits only
306313
"200001010000000000", # Date, time and timezone digits only
@@ -392,4 +399,3 @@ class InvalidValues:
392399
]
393400

394401
invalid_dose_quantity = {"value": 2, "unit": "ml", "code": "258773002"}
395-

e2e_batch/Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
-include .env
22

3-
run-immunization-batch:
4-
ENVIRONMENT=$(environment) poetry run python -m unittest -v -c
3+
test:
4+
ENVIRONMENT=$(ENVIRONMENT) poetry run python -m unittest -v -c

e2e_batch/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# End-to-End Batch Test Suite (test_e2e_batch.py)
2+
3+
This test suite provides automated end-to-end (E2E) testing for the Immunisation FHIR API batch processing pipeline. It verifies that batch file submissions are correctly processed, acknowledged, and validated across the system.
4+
5+
## Overview
6+
- Framework: Python unittest
7+
- Purpose: Simulate real-world batch file submissions, poll for acknowledgements, and validate processing results.
8+
- Test Scenarios: Defined in the scenarios module and enabled in setUp().
9+
- Key Features:
10+
- - Uploads test batch files to S3.
11+
- - Waits for and validates ACK (acknowledgement) files.
12+
- - Cleans up SQS queues and test artifacts after each run.
13+
14+
## Test Flow
15+
1. Setup (setUp)
16+
- Loads and enables a set of test scenarios.
17+
- Prepares test data for batch submission.
18+
2. Test Execution (test_batch_submission)
19+
- Uploads ALL enabled test files to S3.
20+
- Polls for ALL ACK responses and forwarded files.
21+
- Validates the content and structure of ACK files.
22+
3. Teardown (tearDown)
23+
- Cleans up SQS queues and any generated test files.
24+
25+
## Key Functions
26+
- send_files(tests): Uploads enabled test files to the S3 input bucket.
27+
- poll_for_responses(tests, max_timeout): Polls for ACKs and processed files, with a timeout.
28+
- validate_responses(tests): Validates the content of ACK files and checks for expected outcomes.
29+
30+
## How to Run
31+
1. Ensure all dependencies and environment variables are set (see project root README).
32+
2. Update `.env` file with contents indicated in `PR-NNN.env`, modified for PR
33+
3. Update `.env` with referrence to the appropriate AWS config profile `AWS_PROFILE={your-aws-profile}`
34+
4. Update the apigee app to match the required PR-NNN
35+
5. Run tests from vscode debugger or from makefile using
36+
```
37+
make test
38+
```

e2e_batch/clients.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
"""
44

55
import logging
6-
from constants import (environment, REGION)
6+
from constants import (
7+
environment, REGION,
8+
batch_fifo_queue_name, ack_metadata_queue_name, audit_table_name
9+
)
710
from boto3 import client as boto3_client, resource as boto3_resource
811

912

@@ -13,8 +16,12 @@
1316
s3_client = boto3_client("s3", region_name=REGION)
1417

1518
dynamodb = boto3_resource("dynamodb", region_name=REGION)
16-
table_name = f"imms-{environment}-imms-events"
17-
table = dynamodb.Table(table_name)
19+
sqs_client = boto3_client('sqs', region_name=REGION)
20+
events_table_name = f"imms-{environment}-imms-events"
21+
events_table = dynamodb.Table(events_table_name)
22+
audit_table = dynamodb.Table(audit_table_name)
23+
batch_fifo_queue_url = sqs_client.get_queue_url(QueueName=batch_fifo_queue_name)['QueueUrl']
24+
ack_metadata_queue_url = sqs_client.get_queue_url(QueueName=ack_metadata_queue_name)['QueueUrl']
1825
# Logger
1926
logging.basicConfig(level="INFO")
2027
logger = logging.getLogger()

e2e_batch/constants.py

Lines changed: 78 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import os
2-
from datetime import datetime, timezone
32

43
environment = os.environ.get("ENVIRONMENT", "internal-dev")
54
REGION = "eu-west-2"
@@ -12,77 +11,87 @@
1211
POST_VALIDATION_ERROR = "Validation errors: contained[?(@.resourceType=='Patient')].name[0].given is a mandatory field"
1312
DUPLICATE = "The provided identifier:"
1413
ACK_PREFIX = "ack/"
14+
TEMP_ACK_PREFIX = "TempAck/"
1515
HEADER_RESPONSE_CODE_COLUMN = "HEADER_RESPONSE_CODE"
1616
FILE_NAME_VAL_ERROR = "Infrastructure Level Response Value - Processing Error"
1717
CONFIG_BUCKET = "imms-internal-dev-supplier-config"
1818
PERMISSIONS_CONFIG_FILE_KEY = "permissions_config.json"
19+
RAVS_URI = "https://www.ravs.england.nhs.uk/"
20+
batch_fifo_queue_name = f"imms-{environment}-batch-file-created-queue.fifo"
21+
ack_metadata_queue_name = f"imms-{environment}-ack-metadata-queue.fifo"
22+
audit_table_name = f"immunisation-batch-{environment}-audit-table"
1923

2024

21-
def create_row(unique_id, fore_name, dose_amount, action_flag, header):
22-
"""Helper function to create a single row with the specified UNIQUE_ID and ACTION_FLAG."""
23-
24-
return {
25-
header: "9732928395",
26-
"PERSON_FORENAME": fore_name,
27-
"PERSON_SURNAME": "James",
28-
"PERSON_DOB": "20080217",
29-
"PERSON_GENDER_CODE": "0",
30-
"PERSON_POSTCODE": "WD25 0DZ",
31-
"DATE_AND_TIME": datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S"),
32-
"SITE_CODE": "RVVKC",
33-
"SITE_CODE_TYPE_URI": "https://fhir.nhs.uk/Id/ods-organization-code",
34-
"UNIQUE_ID": unique_id,
35-
"UNIQUE_ID_URI": "https://www.ravs.england.nhs.uk/",
36-
"ACTION_FLAG": action_flag,
37-
"PERFORMING_PROFESSIONAL_FORENAME": "PHYLIS",
38-
"PERFORMING_PROFESSIONAL_SURNAME": "James",
39-
"RECORDED_DATE": datetime.now(timezone.utc).strftime("%Y%m%d"),
40-
"PRIMARY_SOURCE": "TRUE",
41-
"VACCINATION_PROCEDURE_CODE": "956951000000104",
42-
"VACCINATION_PROCEDURE_TERM": "RSV vaccination in pregnancy (procedure)",
43-
"DOSE_SEQUENCE": "1",
44-
"VACCINE_PRODUCT_CODE": "42223111000001107",
45-
"VACCINE_PRODUCT_TERM": "Quadrivalent influenza vaccine (Sanofi Pasteur)",
46-
"VACCINE_MANUFACTURER": "Sanofi Pasteur",
47-
"BATCH_NUMBER": "BN92478105653",
48-
"EXPIRY_DATE": "20240915",
49-
"SITE_OF_VACCINATION_CODE": "368209003",
50-
"SITE_OF_VACCINATION_TERM": "Right arm",
51-
"ROUTE_OF_VACCINATION_CODE": "1210999013",
52-
"ROUTE_OF_VACCINATION_TERM": "Intradermal use",
53-
"DOSE_AMOUNT": dose_amount,
54-
"DOSE_UNIT_CODE": "2622896019",
55-
"DOSE_UNIT_TERM": "Inhalation - unit of product usage",
56-
"INDICATION_CODE": "1037351000000105",
57-
"LOCATION_CODE": "RJC02",
58-
"LOCATION_CODE_TYPE_URI": "https://fhir.nhs.uk/Id/ods-organization-code",
59-
}
60-
61-
62-
def create_permissions_json(value):
63-
return {
64-
"all_permissions": {
65-
"DPSFULL": ["RSV_FULL", "COVID19_FULL", "FLU_FULL", "MMR_FULL"],
66-
"DPSREDUCED": ["COVID19_FULL", "FLU_FULL", "MMR_FULL"],
67-
"EMIS": [value, "RSV_FULL"],
68-
"PINNACLE": ["COVID19_UPDATE", "RSV_FULL"],
69-
"SONAR": "",
70-
"TPP": [""],
71-
"AGEM-NIVS": [""],
72-
"NIMS": [""],
73-
"EVA": [""],
74-
"RAVS": [""],
75-
"MEDICAL_DIRECTOR": [""],
76-
"WELSH_DA_1": [""],
77-
"WELSH_DA_2": [""],
78-
"NORTHERN_IRELAND_DA": [""],
79-
"SCOTLAND_DA": [""],
80-
"COVID19_VACCINE_RESOLUTION_SERVICEDESK": [""],
81-
},
82-
"definitions:": {
83-
"FULL": "Full permissions to create, update and delete a batch record",
84-
"CREATE": "Permission to create a batch record",
85-
"UPDATE": "Permission to update a batch record",
86-
"DELETE": "Permission to delete a batch record",
87-
},
88-
}
25+
class EventName:
26+
CREATE = "INSERT"
27+
UPDATE = "MODIFY"
28+
DELETE_LOGICAL = "MODIFY"
29+
DELETE_PHYSICAL = "REMOVE"
30+
31+
32+
class Operation:
33+
CREATE = "CREATE"
34+
UPDATE = "UPDATE"
35+
DELETE_LOGICAL = "DELETE"
36+
DELETE_PHYSICAL = "REMOVE"
37+
38+
39+
class ActionFlag:
40+
CREATE = "NEW"
41+
UPDATE = "UPDATE"
42+
DELETE_LOGICAL = "DELETE"
43+
NONE = "NONE"
44+
45+
46+
class InfResult:
47+
SUCCESS = "Success"
48+
PARTIAL_SUCCESS = "Partial Success"
49+
FATAL_ERROR = "Fatal Error"
50+
51+
52+
class BusRowResult:
53+
SUCCESS = "OK"
54+
FATAL_ERROR = "Fatal Error"
55+
IMMS_NOT_FOUND = "Immunization resource does not exist"
56+
NONE = "NONE"
57+
58+
59+
class OperationOutcome:
60+
IMMS_NOT_FOUND = "Immunization resource does not exist"
61+
TEST = "TEST"
62+
63+
64+
class OpMsgs:
65+
VALIDATION_ERROR = "Validation errors"
66+
MISSING_MANDATORY_FIELD = "is a mandatory field"
67+
DOSE_QUANTITY_NOT_NUMBER = "doseQuantity.value must be a number"
68+
IMM_NOT_EXIST = "Immunization resource does not exist"
69+
IDENTIFIER_PROVIDED = "The provided identifier:"
70+
INVALID_DATE_FORMAT = "is not in the correct format"
71+
72+
73+
class DestinationType:
74+
INF = ACK_PREFIX
75+
BUS = FORWARDEDFILE_PREFIX
76+
77+
78+
class ActionSequence:
79+
def __init__(self, desc: str, actions: list[ActionFlag], outcome: ActionFlag = None):
80+
self.actions = actions
81+
self.description = desc
82+
self.outcome = outcome if outcome else actions[-1]
83+
84+
85+
class PermPair:
86+
def __init__(self, ods_code: str, permissions: str):
87+
self.ods_code = ods_code
88+
self.permissions = permissions
89+
90+
91+
class TestSet:
92+
CREATE_OK = ActionSequence("Create. OK", [ActionFlag.CREATE])
93+
UPDATE_OK = ActionSequence("Update. OK", [ActionFlag.CREATE, ActionFlag.UPDATE])
94+
DELETE_OK = ActionSequence("Delete. OK", [ActionFlag.CREATE, ActionFlag.UPDATE, ActionFlag.DELETE_LOGICAL])
95+
REINSTATE_OK = ActionSequence("Reinstate. OK", [ActionFlag.CREATE, ActionFlag.DELETE_LOGICAL, ActionFlag.UPDATE])
96+
DELETE_FAIL = ActionSequence("Delete without Create. Fail", [ActionFlag.DELETE_LOGICAL])
97+
UPDATE_FAIL = ActionSequence("Update without Create. Fail", [ActionFlag.UPDATE], outcome=ActionFlag.NONE)

0 commit comments

Comments
 (0)