Skip to content

Commit 00e7dd1

Browse files
End-to-end integration test covering the complete Gateway flow (#17)
* End-to-end integration test covering the complete Gateway flow - Relay message received with worklist item - Worklist item stored in MWL database - C-FIND returns the scheduled worklist item - C-STORE receives and validates DICOM image - Upload processor sends image to Manage API with correct action_id * Update to reflect Manage endpoint moving from POST to PUT
1 parent 291b33d commit 00e7dd1

2 files changed

Lines changed: 272 additions & 0 deletions

File tree

src/services/storage.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,38 @@ def close(self):
181181
"""Close storage (cleanup if needed)."""
182182
logger.info("PACS storage closed")
183183

184+
def get_instance(self, sop_instance_uid: str) -> Optional[Dict]:
185+
"""Get a stored instance by SOP Instance UID."""
186+
with self._get_connection() as conn:
187+
cursor = conn.execute(
188+
"""
189+
SELECT sop_instance_uid, storage_path, accession_number, patient_id,
190+
patient_name, file_size, status, upload_status, upload_error,
191+
upload_attempt_count, created_at
192+
FROM stored_instances
193+
WHERE sop_instance_uid = ?
194+
""",
195+
(sop_instance_uid,),
196+
)
197+
row = cursor.fetchone()
198+
return dict(row) if row else None
199+
200+
def get_instance_by_accession(self, accession_number: str) -> Optional[Dict]:
201+
"""Get a stored instance by accession number."""
202+
with self._get_connection() as conn:
203+
cursor = conn.execute(
204+
"""
205+
SELECT sop_instance_uid, storage_path, accession_number, patient_id,
206+
patient_name, file_size, status, upload_status, upload_error,
207+
upload_attempt_count, created_at
208+
FROM stored_instances
209+
WHERE accession_number = ?
210+
""",
211+
(accession_number,),
212+
)
213+
row = cursor.fetchone()
214+
return dict(row) if row else None
215+
184216
def get_pending_uploads(self, limit: int = 10, max_retries: int = 3) -> List[Dict]:
185217
"""Get stored instances pending upload"""
186218
with self._get_connection() as conn:
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
"""
2+
End-to-end integration test
3+
4+
This test verifies the complete flow from receiving a worklist item via Azure Relay
5+
through to uploading the DICOM image to the Manage service.
6+
"""
7+
8+
import json
9+
from unittest.mock import Mock, patch
10+
11+
import pytest
12+
from dicom_helpers import generate_random_dicom_file, send_dicom_file_to_server
13+
from pydicom import Dataset
14+
from pynetdicom import AE
15+
from pynetdicom.sop_class import ModalityWorklistInformationFind
16+
17+
from relay_listener import RelayListener
18+
from server import MWLServer, PACSServer
19+
from services.dicom import PENDING, SUCCESS
20+
from services.dicom.dicom_uploader import DICOMUploader
21+
from services.dicom.upload_processor import UploadProcessor
22+
from services.storage import MWLStorage, PACSStorage
23+
24+
TEST_ACCESSION_NUMBER = "ACC-E2E-12345" # gitleaks:allow
25+
TEST_PATIENT_ID = "9991234567" # gitleaks:allow
26+
TEST_ACTION_ID = "action-e2e-test-001" # gitleaks:allow
27+
28+
29+
@pytest.mark.integration
30+
class TestEndToEndRelayToUpload:
31+
"""
32+
End-to-end test covering the complete flow:
33+
34+
1. Relay message received with worklist item
35+
2. Worklist item stored in MWL database
36+
3. C-FIND returns the scheduled worklist item
37+
4. C-STORE receives and validates DICOM image
38+
5. Upload processor sends image to Manage API with correct action_id
39+
"""
40+
41+
@pytest.fixture
42+
def mwl_storage(self, tmp_dir):
43+
return MWLStorage(f"{tmp_dir}/worklist.db")
44+
45+
@pytest.fixture
46+
def pacs_storage(self, tmp_dir):
47+
return PACSStorage(f"{tmp_dir}/pacs.db", tmp_dir)
48+
49+
@pytest.fixture
50+
def relay_payload(self):
51+
"""Relay message payload that creates a worklist item."""
52+
return {
53+
"action_id": TEST_ACTION_ID,
54+
"action_type": "worklist.create_item",
55+
"parameters": {
56+
"worklist_item": {
57+
"participant": {
58+
"nhs_number": TEST_PATIENT_ID,
59+
"name": "TEST^PATIENT",
60+
"birth_date": "19850315",
61+
"sex": "F",
62+
},
63+
"scheduled": {
64+
"date": "20240620",
65+
"time": "103000",
66+
},
67+
"procedure": {
68+
"modality": "MG",
69+
"study_description": "SCREENING MAMMOGRAPHY",
70+
},
71+
"accession_number": TEST_ACCESSION_NUMBER,
72+
}
73+
},
74+
}
75+
76+
@pytest.fixture
77+
def mwl_server(self, mwl_storage):
78+
"""MWL server using the shared storage."""
79+
server = MWLServer.__new__(MWLServer)
80+
server.ae_title = "MWL_SCP"
81+
server.port = 4243
82+
server.storage = mwl_storage
83+
server.ae = None
84+
server.block = False
85+
return server
86+
87+
@pytest.fixture
88+
def pacs_server(self, pacs_storage):
89+
"""PACS server using the shared storage."""
90+
server = PACSServer.__new__(PACSServer)
91+
server.ae_title = "SCREENING_PACS"
92+
server.port = 4244
93+
server.storage = pacs_storage
94+
server.ae = None
95+
server.block = False
96+
return server
97+
98+
@pytest.mark.asyncio
99+
async def test_full_flow_relay_to_upload(
100+
self,
101+
mwl_storage,
102+
pacs_storage,
103+
mwl_server,
104+
pacs_server,
105+
relay_payload,
106+
fake_relay,
107+
):
108+
"""
109+
Complete end-to-end test of the screening gateway flow.
110+
111+
This test verifies that:
112+
1. A relay message creates a worklist item
113+
2. The worklist item can be queried via C-FIND
114+
3. A DICOM image sent via C-STORE is validated and stored
115+
4. The upload processor sends the image with the correct action_id
116+
"""
117+
118+
# ===== STEP 1: Receive relay message and create worklist item =====
119+
listener = RelayListener(mwl_storage)
120+
relay_message = json.dumps({"accept": {"address": "wss://accept-url"}})
121+
122+
with fake_relay(relay_message, json.dumps(relay_payload)) as ws_client:
123+
await listener.listen()
124+
125+
# Verify worklist item was created
126+
ws_client.send.assert_called_once()
127+
response = json.loads(ws_client.send.call_args[0][0])
128+
assert response["status"] == "created"
129+
assert response["action_id"] == TEST_ACTION_ID
130+
131+
worklist_items = mwl_storage.find_worklist_items()
132+
assert len(worklist_items) == 1
133+
assert worklist_items[0].accession_number == TEST_ACCESSION_NUMBER
134+
assert worklist_items[0].patient_id == TEST_PATIENT_ID
135+
136+
# Update status to SCHEDULED so it appears in C-FIND results
137+
mwl_storage.update_status(TEST_ACCESSION_NUMBER, "SCHEDULED")
138+
139+
# ===== STEP 2: Query worklist via C-FIND =====
140+
mwl_server.start()
141+
try:
142+
ae = AE(ae_title="TEST_MODALITY")
143+
ae.add_requested_context(ModalityWorklistInformationFind)
144+
assoc = ae.associate("127.0.0.1", 4243, ae_title="MWL_SCP")
145+
146+
assert assoc.is_established, "Failed to establish C-FIND association"
147+
148+
query = Dataset()
149+
query.PatientID = TEST_PATIENT_ID
150+
151+
responses = list(assoc.send_c_find(query, query_model=ModalityWorklistInformationFind))
152+
assoc.release()
153+
154+
# Should have 1 pending result + 1 success status
155+
assert len(responses) == 2
156+
status, ds = responses[0]
157+
assert status.Status == PENDING
158+
assert ds.PatientID == TEST_PATIENT_ID
159+
assert ds.AccessionNumber == TEST_ACCESSION_NUMBER
160+
161+
status, ds = responses[1]
162+
assert status.Status == SUCCESS
163+
finally:
164+
mwl_server.stop()
165+
166+
# ===== STEP 3: Send DICOM image via C-STORE =====
167+
pacs_server.start()
168+
try:
169+
# Generate a DICOM file with matching accession number
170+
dicom_file = generate_random_dicom_file(modality_type="MG")
171+
172+
# Modify the file to use our test accession number
173+
import pydicom
174+
175+
ds = pydicom.dcmread(dicom_file)
176+
ds.AccessionNumber = TEST_ACCESSION_NUMBER
177+
ds.PatientID = TEST_PATIENT_ID
178+
ds.save_as(dicom_file)
179+
180+
# Send to PACS server
181+
success = send_dicom_file_to_server(
182+
dicom_file,
183+
"127.0.0.1",
184+
4244,
185+
"SCREENING_PACS",
186+
ae_title="TEST_MODALITY",
187+
)
188+
assert success, "C-STORE failed"
189+
190+
# Clean up temp file
191+
import os
192+
193+
os.remove(dicom_file)
194+
finally:
195+
pacs_server.stop()
196+
197+
# Verify image was stored
198+
instance = pacs_storage.get_instance_by_accession(TEST_ACCESSION_NUMBER)
199+
assert instance is not None, "DICOM instance not found in storage"
200+
assert instance["accession_number"] == TEST_ACCESSION_NUMBER
201+
assert instance["patient_id"] == TEST_PATIENT_ID
202+
assert instance["upload_status"] == "PENDING"
203+
204+
stored_sop_instance_uid = instance["sop_instance_uid"]
205+
206+
# ===== STEP 4: Upload to Manage API =====
207+
mock_response = Mock()
208+
mock_response.status_code = 201
209+
210+
with patch("services.dicom.dicom_uploader.requests.put") as mock_put:
211+
mock_put.return_value = mock_response
212+
213+
uploader = DICOMUploader(api_endpoint="http://test-manage-api/dicom")
214+
processor = UploadProcessor(
215+
pacs_storage=pacs_storage,
216+
mwl_storage=mwl_storage,
217+
uploader=uploader,
218+
max_retries=3,
219+
)
220+
221+
processed = processor.process_batch(limit=10)
222+
assert processed == 1
223+
224+
# Verify the upload was called with correct parameters
225+
mock_put.assert_called_once()
226+
call_args = mock_put.call_args
227+
228+
# Verify URL contains the action_id from relay
229+
url = call_args[0][0]
230+
assert url == f"http://test-manage-api/dicom/{TEST_ACTION_ID}"
231+
232+
# Verify the file was included
233+
files = call_args.kwargs.get("files", {})
234+
assert "file" in files
235+
filename, _ = files["file"]
236+
assert stored_sop_instance_uid in filename
237+
238+
# Verify upload status was updated
239+
instance = pacs_storage.get_instance(stored_sop_instance_uid)
240+
assert instance["upload_status"] == "COMPLETE"

0 commit comments

Comments
 (0)