Mercurial > hg > orthanc-tests
changeset 980:07a74087e638
added new StorageCommitment tests
| author | Alain Mazy <am@orthanc.team> |
|---|---|
| date | Thu, 24 Sep 2026 16:47:04 +0200 |
| parents | a84b97f4350e |
| children | 71c6a69c02e3 |
| files | NewTests/.dockerignore NewTests/README NewTests/StorageCommitment/Dockerfile NewTests/StorageCommitment/__init__.py NewTests/StorageCommitment/docker-compose-storage-commitment.yml NewTests/StorageCommitment/test_storage_commitment.py NewTests/helpers.py NewTests/main.py NewTests/requirements.txt |
| diffstat | 8 files changed, 598 insertions(+), 3 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/NewTests/.dockerignore Thu Sep 24 16:47:04 2026 +0200 @@ -0,0 +1,2 @@ +**/__pycache__/* +**/docker-compose*.yml \ No newline at end of file
--- a/NewTests/README Wed Sep 23 11:40:41 2026 +0200 +++ b/NewTests/README Thu Sep 24 16:47:04 2026 +0200 @@ -290,6 +290,20 @@ with Docker: python3 NewTests/main.py --pattern=NonRegressionPerfs.test_non_regression_perfs.TestNonRegressionPerfs.* \ - --orthanc_under_tests_docker_image=orthancteam/orthanc-pre-release:master-normal-stable-before-tests-amd64 \ + --orthanc_under_tests_docker_image=orthancteam/orthanc-pre-release:master-normal-unstable-before-tests-amd64 \ --orthanc_under_tests_http_port=8043 + +StorageCommitment: +----------------- + +Run the StorageCommitment tests with your locally build version and break before execution to allow you to start your debugger. + +python3 NewTests/main.py --pattern=StorageCommitment.test_storage_commitment.TestStorageCommitment.* \ + --orthanc_under_tests_exe=/home/alain/o/build/orthanc/Orthanc \ + --break_after_preparation + +with Docker: + +ORTHANC_IMAGE_UNDER_TESTS=orthancteam/orthanc-pre-release:master-normal-unstable-before-tests-amd64 docker compose -f docker-compose-storage-commitment.yml up --build --exit-code-from=tests +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/NewTests/StorageCommitment/Dockerfile Thu Sep 24 16:47:04 2026 +0200 @@ -0,0 +1,9 @@ +FROM python:3.14 + +COPY requirements.txt . + +RUN pip install --break-system-packages -r requirements.txt + +COPY . /NewTests + +WORKDIR /NewTests \ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/NewTests/StorageCommitment/docker-compose-storage-commitment.yml Thu Sep 24 16:47:04 2026 +0200 @@ -0,0 +1,46 @@ +services: + + orthanc-under-tests: + image: ${ORTHANC_IMAGE_UNDER_TESTS:-orthancteam/orthanc:latest} + container_name: orthanc-under-tests + restart: unless-stopped + ports: ["8052:8052"] + volumes: ["storage-orthanc:/var/lib/orthanc/db"] + environment: + VERBOSE_STARTUP: "true" + VERBOSE_ENABLED: "true" + ORTHANC_JSON: | + { + "AuthenticationEnabled": false, + "DicomAet": "ORTHANC", + "Name": "Orthanc for storage commitment", + "HttpPort": 8052, + "DicomPort": 4252, + + "DicomModalities": { + "fake-mod": { + "AET": "FAKE_MOD", + "Port": 4999, + "Host": "tests" + }, + "alternate-mod": { + "AET": "ALTERNATE", + "Port": 4999, + "Host": "tests" + } + } + } + + + tests: + build: + dockerfile: StorageCommitment/Dockerfile + context: .. + container_name: tests + depends_on: + - orthanc-under-tests + ports: ["4999:4999"] + entrypoint: python /NewTests/main.py --pattern=StorageCommitment.test_storage_commitment.TestStorageCommitment.* --orthanc_under_tests_hostname=orthanc-under-tests --tests_run_in_docker + +volumes: + storage-orthanc:
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/NewTests/StorageCommitment/test_storage_commitment.py Thu Sep 24 16:47:04 2026 +0200 @@ -0,0 +1,519 @@ +import unittest +import time +import os +import sys +import threading +import pprint +from helpers import OrthancTestCase, Helpers + +from orthanc_api_client import OrthancApiClient, ChangeType, HttpError +from orthanc_api_client import helpers as OrthancHelpers + +FAKE_SERVER_AET = 'FAKE_MOD' +FAKE_ALTERNATE_SERVER_AET = 'ALTERNATE' +FAKE_SERVER_PORT = 4999 + +import logging + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("fake DICOM SCP") + +try: + from pydicom import Dataset + from pydicom.uid import ( + ExplicitVRLittleEndian, + ImplicitVRLittleEndian, + generate_uid, + ) +except ImportError as exc: # pragma: no cover + sys.stderr.write(f"[!] pydicom is required: {exc}\n pip install pydicom\n") + sys.exit(2) + +try: + from pynetdicom import AE, build_role, debug_logger + + # SOP class import path moved between pynetdicom 1.x and 2.x. + try: + from pynetdicom.sop_class import StorageCommitmentPushModel + except ImportError: # pynetdicom < 2.0 + from pynetdicom.sop_class import ( + StorageCommitmentPushModelSOPClass as StorageCommitmentPushModel, + ) +except ImportError as exc: # pragma: no cover + sys.stderr.write(f"[!] pynetdicom is required: {exc}\n pip install pynetdicom\n") + sys.exit(2) + +from pydicom.dataset import Dataset +from pydicom.uid import ImplicitVRLittleEndian +from pynetdicom import AE, evt, build_role +from pynetdicom.sop_class import ( + StorageCommitmentPushModel, +) +from enum import Enum + +import pathlib +import subprocess +import glob +here = pathlib.Path(__file__).parent.resolve() + +STORAGE_COMMITMENT_SOP_CLASS = "1.2.840.10008.1.20.1" +STORAGE_COMMITMENT_SOP_INSTANCE = "1.2.840.10008.1.20.1.1" + + +class PendingTransaction: + def __init__(self, transaction_uid, sop_instances): + self.transaction_uid = transaction_uid + self.sop_instances = sop_instances + +class SimulatedFailure(Enum): + NONE = 0 + CHANGE_TXUID = 1 + CHANGE_ONE_SOP_INSTANCE_UID = 2 + CHANGE_ONE_SOP_CLASS_UID = 3 + ADD_ONE_SOP_INSTANCE = 4 + REMOVE_ONE_SOP_INSTANCE = 5 + CHANGE_CALLED_AET = 6 + + +class FakeStorageCommitmentServer: + _should_stop = False + _pending_transaction = None + _simulated_failure = SimulatedFailure.CHANGE_TXUID + _is_running = False + _thread = None + event_report_response_status = None + + def __init__(self, simulated_failure= SimulatedFailure): + self._simulated_failure = simulated_failure + + + def handle_n_action(self, event): + """Receive Orthanc's Storage Commitment N-ACTION.""" + + transaction_uid = event.action_information.TransactionUID + + sop_instances = [] + + if hasattr(event.action_information, "ReferencedSOPSequence"): + for item in event.action_information.ReferencedSOPSequence: + sop_instances.append( + ( + item.ReferencedSOPClassUID, + item.ReferencedSOPInstanceUID, + ) + ) + + print() + print("Received Storage Commitment N-ACTION") + print("------------------------------------") + print(f"Transaction UID: {transaction_uid}") + + for sop_class, sop_instance in sop_instances: + print(f" {sop_class} / {sop_instance}") + + if transaction_uid is not None: + self._pending_transaction = PendingTransaction( + transaction_uid, + sop_instances, + ) + + # N-ACTION success + return 0x0000, None + + + def send_event_report( + self, + host, + port, + calling_ae, + called_ae, + transaction_uid, + sop_instances + ): + """ + Open a new association to Orthanc and send an N-EVENT-REPORT. + """ + + ae = AE(ae_title=calling_ae) + + ae.add_requested_context( + StorageCommitmentPushModel, + [ + ImplicitVRLittleEndian, + ], + ) + + # We initiate the association, but act as SCP for + # Storage Commitment. + role = build_role( + StorageCommitmentPushModel, + scp_role=True, + scu_role=False, + ) + + assoc = ae.associate( + host, + port, + ae_title=called_ae, + ext_neg=[role], + ) + + if not assoc.is_established: + print("Could not establish association") + return + + ds = Dataset() + + ds.TransactionUID = transaction_uid + + ds.ReferencedSOPSequence = [] + + for sop_class, sop_instance in sop_instances: + item = Dataset() + item.ReferencedSOPClassUID = sop_class + item.ReferencedSOPInstanceUID = sop_instance + ds.ReferencedSOPSequence.append(item) + + print() + print("Sending N-EVENT-REPORT") + print("----------------------") + print(f"Transaction UID: {transaction_uid}") + + for sop_class, sop_instance in sop_instances: + print(f" {sop_class} / {sop_instance}") + + status, response = assoc.send_n_event_report( + ds, + 1, # Event Type 1 = Request Successful + STORAGE_COMMITMENT_SOP_CLASS, + STORAGE_COMMITMENT_SOP_INSTANCE, + ) + + if status is None: + print("No N-EVENT-REPORT response") + else: + print( + f"N-EVENT-REPORT response: " + f"0x{status.Status:04X}" + ) + self.event_report_response_status = status.Status + + assoc.release() + + + def start(self): + self._thread = threading.Thread(target=self.execute) + self._thread.start() + while not self._is_running: + time.sleep(0.1) + + + def execute(self): + ae = AE(ae_title=FAKE_SERVER_AET) + + # Receive N-ACTION from Orthanc. + ae.add_supported_context( + StorageCommitmentPushModel, + [ + ImplicitVRLittleEndian, + ], + scp_role=True, + scu_role=False, + ) + + handlers = [ + ( + evt.EVT_N_ACTION, + self.handle_n_action, + ), + ] + + print( + f"Listening on 0.0.0.0:{FAKE_SERVER_PORT} " + f"as {FAKE_SERVER_AET}" + ) + + # Start SCP in the background so that we can interact + # with it from the command line. + server = ae.start_server( + ("0.0.0.0", FAKE_SERVER_PORT), + block=False, + evt_handlers=handlers, + ) + self._is_running = True + + try: + while not self._should_stop: + if self._pending_transaction is None: + time.sleep(0.2) + continue + + transaction = self._pending_transaction + + if self._simulated_failure == SimulatedFailure.NONE: + self.send_event_report( + host=Helpers.get_orthanc_ip(), + port=Helpers.get_orthanc_dicom_port(), + calling_ae=FAKE_SERVER_AET, + called_ae='ORTHANC', + transaction_uid=transaction.transaction_uid, + sop_instances=transaction.sop_instances + ) + return + + elif self._simulated_failure == SimulatedFailure.CHANGE_TXUID: + self.send_event_report( + host=Helpers.get_orthanc_ip(), + port=Helpers.get_orthanc_dicom_port(), + calling_ae=FAKE_SERVER_AET, + called_ae='ORTHANC', + transaction_uid="1.2.3.4", + sop_instances=transaction.sop_instances + ) + return + + elif self._simulated_failure == SimulatedFailure.CHANGE_ONE_SOP_INSTANCE_UID: + modified_sop_instances = transaction.sop_instances.copy() + modified_sop_instances[0] = (modified_sop_instances[0][0], "3.3.3") + self.send_event_report( + host=Helpers.get_orthanc_ip(), + port=Helpers.get_orthanc_dicom_port(), + calling_ae=FAKE_SERVER_AET, + called_ae='ORTHANC', + transaction_uid=transaction.transaction_uid, + sop_instances=modified_sop_instances + ) + return + + elif self._simulated_failure == SimulatedFailure.CHANGE_ONE_SOP_CLASS_UID: + modified_sop_instances = transaction.sop_instances.copy() + modified_sop_instances[0] = ("3.3.3", modified_sop_instances[0][1]) + self.send_event_report( + host=Helpers.get_orthanc_ip(), + port=Helpers.get_orthanc_dicom_port(), + calling_ae=FAKE_SERVER_AET, + called_ae='ORTHANC', + transaction_uid=transaction.transaction_uid, + sop_instances=modified_sop_instances + ) + return + + elif self._simulated_failure == SimulatedFailure.ADD_ONE_SOP_INSTANCE: + modified_sop_instances = transaction.sop_instances.copy() + modified_sop_instances.append(("4.4.0", "4.4.1")) + self.send_event_report( + host=Helpers.get_orthanc_ip(), + port=Helpers.get_orthanc_dicom_port(), + calling_ae=FAKE_SERVER_AET, + called_ae='ORTHANC', + transaction_uid=transaction.transaction_uid, + sop_instances=modified_sop_instances + ) + return + + elif self._simulated_failure == SimulatedFailure.REMOVE_ONE_SOP_INSTANCE: + modified_sop_instances = transaction.sop_instances.copy()[1:] + self.send_event_report( + host=Helpers.get_orthanc_ip(), + port=Helpers.get_orthanc_dicom_port(), + calling_ae=FAKE_SERVER_AET, + called_ae='ORTHANC', + transaction_uid=transaction.transaction_uid, + sop_instances=modified_sop_instances + ) + return + + elif self._simulated_failure == SimulatedFailure.CHANGE_CALLED_AET: + modified_sop_instances = transaction.sop_instances.copy()[1:] + self.send_event_report( + host=Helpers.get_orthanc_ip(), + port=Helpers.get_orthanc_dicom_port(), + calling_ae=FAKE_ALTERNATE_SERVER_AET, # the AET must be known from Orthanc in order to accept the incoming connection + called_ae='ORTHANC', + transaction_uid=transaction.transaction_uid, + sop_instances=modified_sop_instances + ) + return + finally: + server.shutdown() + + def stop(self): + self._should_stop = True + self._thread.join() + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc, tb): + self.stop() + + +class TestStorageCommitment(OrthancTestCase): + + @classmethod + def prepare(cls): + print('-------------- preparing TestStorageCommitment tests') + + if not Helpers.tests_run_in_docker: + + cls.clear_storage(storage_name="TestStorageCommitment") + + config = { # this config is used only on desktop -> check the docker-compose-storage-commitment.yml for docker config + "DicomModalities": { + "fake-mod": { + "AET": FAKE_SERVER_AET, + "Port": FAKE_SERVER_PORT, + "Host": "localhost" + }, + "alternate-mod": { + "AET": FAKE_ALTERNATE_SERVER_AET, + "Port": FAKE_SERVER_PORT, + "Host": "localhost" + } + }, + } + + config_path = cls.generate_configuration( + config_name="storage_commitment", + storage_name="StorageCommitment", + config=config, + plugins=Helpers.plugins + ) + + if Helpers.break_after_preparation: + print(f"++++ It is now time to start your Orthanc under tests with configuration file '{config_path}' +++++") + input("Press Enter to continue") + else: + print('-------------- launching StorageCommitment tests') + cls.launch_orthanc_under_tests( + config_path=config_path, + config_name="storage_commitment", + storage_name="StorageCommitment", + plugins=Helpers.plugins + ) + + print('-------------- waiting for orthanc-under-tests to be available') + + # else: + # cls.o = OrthancApiClient(f"{Helpers.orthanc_under_tests_hostname}:{Helpers.orthanc_under_tests_http_port}") + + cls.o.wait_started() + + + def wait_transaction_complete(self, transaction_uid, timeout=10): + retry = 0 + while retry < timeout: + r = self.o.get_json(f"/storage-commitment/{transaction_uid}") + if r['Status'] != "Pending": + return r['Status'] + retry += 1 + time.sleep(1) + + return r['Status'] + + def test_valid_transaction(self): + + with FakeStorageCommitmentServer(SimulatedFailure.NONE) as server: + + r = self.o.post("/modalities/fake-mod/storage-commitment", json={ + "DicomInstances": [{ + "SOPClassUID": "1.1.0", + "SOPInstanceUID": "1.1.1" + }]}).json() + + self.assertEqual('Success', self.wait_transaction_complete(r['ID'])) + self.assertEqual(0x0000, server.event_report_response_status) + + + def test_transaction_uuid_validation(self): + if self.o.is_orthanc_version_at_least(1, 13, 1): + with FakeStorageCommitmentServer(SimulatedFailure.CHANGE_TXUID) as server: + + r = self.o.post("/modalities/fake-mod/storage-commitment", json={ + "DicomInstances": [{ + "SOPClassUID": "1.1.0", + "SOPInstanceUID": "1.1.1" + }]}).json() + + self.assertEqual('Pending', self.wait_transaction_complete(r['ID'], timeout=2)) + self.assertEqual(0x0211, server.event_report_response_status) + + + def test_changed_sop_instance(self): + if self.o.is_orthanc_version_at_least(1, 13, 1): + with FakeStorageCommitmentServer(SimulatedFailure.CHANGE_ONE_SOP_INSTANCE_UID) as server: + + r = self.o.post("/modalities/fake-mod/storage-commitment", json={ + "DicomInstances": [{ + "SOPClassUID": "1.1.0", + "SOPInstanceUID": "1.1.1" + }, { + "SOPClassUID": "1.2.0", + "SOPInstanceUID": "1.2.1" + }]}).json() + + self.assertEqual('Pending', self.wait_transaction_complete(r['ID'], timeout=2)) + self.assertEqual(0x0117, server.event_report_response_status) + + def test_add_sop_instance(self): + if self.o.is_orthanc_version_at_least(1, 13, 1): + with FakeStorageCommitmentServer(SimulatedFailure.ADD_ONE_SOP_INSTANCE) as server: + + r = self.o.post("/modalities/fake-mod/storage-commitment", json={ + "DicomInstances": [{ + "SOPClassUID": "1.1.0", + "SOPInstanceUID": "1.1.1" + }]}).json() + + self.assertEqual('Pending', self.wait_transaction_complete(r['ID'], timeout=2)) + self.assertEqual(0x0117, server.event_report_response_status) + + def test_remove_sop_instance(self): + if self.o.is_orthanc_version_at_least(1, 13, 1): + with FakeStorageCommitmentServer(SimulatedFailure.REMOVE_ONE_SOP_INSTANCE) as server: + + r = self.o.post("/modalities/fake-mod/storage-commitment", json={ + "DicomInstances": [{ + "SOPClassUID": "1.1.0", + "SOPInstanceUID": "1.1.1" + }, { + "SOPClassUID": "1.2.0", + "SOPInstanceUID": "1.2.1" + }]}).json() + + self.assertEqual('Pending', self.wait_transaction_complete(r['ID'], timeout=2)) + self.assertEqual(0x0110, server.event_report_response_status) + + def test_change_sop_class(self): + if self.o.is_orthanc_version_at_least(1, 13, 1): + with FakeStorageCommitmentServer(SimulatedFailure.CHANGE_ONE_SOP_CLASS_UID) as server: + + r = self.o.post("/modalities/fake-mod/storage-commitment", json={ + "DicomInstances": [{ + "SOPClassUID": "1.1.0", + "SOPInstanceUID": "1.1.1" + }, { + "SOPClassUID": "1.2.0", + "SOPInstanceUID": "1.2.1" + }]}).json() + + self.assertEqual('Pending', self.wait_transaction_complete(r['ID'], timeout=2)) + self.assertEqual(0x0119, server.event_report_response_status) + + def test_change_call_aet(self): + if self.o.is_orthanc_version_at_least(1, 13, 1): + with FakeStorageCommitmentServer(SimulatedFailure.CHANGE_CALLED_AET) as server: + + r = self.o.post("/modalities/fake-mod/storage-commitment", json={ + "DicomInstances": [{ + "SOPClassUID": "1.1.0", + "SOPInstanceUID": "1.1.1" + }, { + "SOPClassUID": "1.2.0", + "SOPInstanceUID": "1.2.1" + }]}).json() + + self.assertEqual('Pending', self.wait_transaction_complete(r['ID'], timeout=2)) + self.assertEqual(0x0110, server.event_report_response_status)
--- a/NewTests/helpers.py Wed Sep 23 11:40:41 2026 +0200 +++ b/NewTests/helpers.py Thu Sep 24 16:47:04 2026 +0200 @@ -117,7 +117,7 @@ @classmethod def tearDownClass(cls): - if not Helpers.break_after_preparation: + if not Helpers.break_after_preparation and not Helpers.tests_run_in_docker: cls.kill_orthanc() cls.terminate()
--- a/NewTests/main.py Wed Sep 23 11:40:41 2026 +0200 +++ b/NewTests/main.py Thu Sep 24 16:47:04 2026 +0200 @@ -19,6 +19,7 @@ parser = argparse.ArgumentParser(description='Executes Orthanc integration tests.') parser.add_argument('-k', '--pattern', dest='test_name_patterns', action='append', type=str, help='a test pattern (ex: Housekeeper.toto') + parser.add_argument('--tests_run_in_docker', action='store_true', help="set to true if the tests are running in a Docker container inside the docker compose") parser.add_argument('--orthanc_under_tests_hostname', type=str, default="localhost", help="orthanc under tests hostname") parser.add_argument('--orthanc_under_tests_http_port', type=int, default=8052, help="orthanc under tests HTTP port") parser.add_argument('--orthanc_under_tests_dicom_port', type=int, default=4252, help="orthanc under tests DICOM port") @@ -32,6 +33,7 @@ parser.add_argument('--db', type=str, default='unspecified', help="the DB engine to use") parser.add_argument('-p', '--plugin', dest='plugins', action='append', type=str, help='path to a plugin to add to configuration') + args = parser.parse_args() loader = unittest.TestLoader() @@ -40,6 +42,7 @@ Helpers.orthanc_under_tests_hostname = args.orthanc_under_tests_hostname Helpers.orthanc_under_tests_http_port = args.orthanc_under_tests_http_port Helpers.orthanc_under_tests_dicom_port = args.orthanc_under_tests_dicom_port + Helpers.tests_run_in_docker = args.tests_run_in_docker Helpers.plugins = args.plugins Helpers.db = args.db
