13#include <catch2/catch_test_macros.hpp>
51class test_file_repository {
54 std::lock_guard<std::mutex> lock(mutex_);
55 files_.push_back(file);
59 std::lock_guard<std::mutex> lock(mutex_);
60 files_.push_back(std::move(file));
63 std::vector<dicom_file> find_by_patient_id(
const std::string& patient_id)
const {
64 std::lock_guard<std::mutex> lock(mutex_);
65 std::vector<dicom_file> results;
67 for (
const auto& file : files_) {
68 auto file_patient_id = file.
dataset().get_string(tags::patient_id);
69 if (file_patient_id == patient_id ||
patient_id.empty()) {
70 results.push_back(file);
77 std::vector<dicom_file> find_by_study_uid(
const std::string& study_uid)
const {
78 std::lock_guard<std::mutex> lock(mutex_);
79 std::vector<dicom_file> results;
81 for (
const auto& file : files_) {
82 auto file_study_uid = file.
dataset().get_string(tags::study_instance_uid);
83 if (file_study_uid == study_uid || study_uid.empty()) {
84 results.push_back(file);
91 std::vector<dicom_file> find_all(
const dicom_dataset& query_keys)
const {
92 std::lock_guard<std::mutex> lock(mutex_);
93 std::vector<dicom_file> results;
95 auto query_patient_id = query_keys.
get_string(tags::patient_id);
96 auto query_study_uid = query_keys.
get_string(tags::study_instance_uid);
98 for (
const auto& file : files_) {
101 if (!query_patient_id.empty()) {
102 auto file_patient_id = file.
dataset().get_string(tags::patient_id);
103 if (file_patient_id != query_patient_id) {
108 if (match && !query_study_uid.empty()) {
109 auto file_study_uid = file.
dataset().get_string(tags::study_instance_uid);
110 if (file_study_uid != query_study_uid) {
116 results.push_back(file);
124 std::lock_guard<std::mutex> lock(mutex_);
128 size_t size()
const {
129 std::lock_guard<std::mutex> lock(mutex_);
130 return files_.size();
134 mutable std::mutex mutex_;
135 std::vector<dicom_file> files_;
142 const std::string& patient_id,
143 const std::string& patient_name,
144 const std::string& study_uid) {
150 ds.
set_string(tags::sop_class_uid, vr_type::UI,
"1.2.840.10008.5.1.4.1.1.2");
151 ds.
set_string(tags::sop_instance_uid, vr_type::UI, sop_instance_uid);
154 ds.
set_string(tags::patient_id, vr_type::LO, patient_id);
155 ds.
set_string(tags::patient_name, vr_type::PN, patient_name);
156 ds.
set_string(tags::patient_birth_date, vr_type::DA,
"19700101");
157 ds.
set_string(tags::patient_sex, vr_type::CS,
"M");
160 ds.
set_string(tags::study_instance_uid, vr_type::UI, study_uid);
161 ds.
set_string(tags::study_date, vr_type::DA,
"20240101");
162 ds.
set_string(tags::study_time, vr_type::TM,
"120000");
163 ds.
set_string(tags::study_id, vr_type::SH,
"STUDY001");
164 ds.
set_string(tags::accession_number, vr_type::SH,
"ACC001");
168 ds.
set_string(tags::series_number, vr_type::IS,
"1");
169 ds.
set_string(tags::modality, vr_type::CS,
"CT");
172 ds.
set_string(tags::instance_number, vr_type::IS,
"1");
174 return dicom_file::create(std::move(ds), transfer_syntax::explicit_vr_little_endian);
180class received_file_tracker {
182 void on_file_received(
186 const std::string& ) {
188 std::lock_guard<std::mutex> lock(mutex_);
189 auto sop_uid = dataset.
get_string(tags::sop_instance_uid);
190 received_sop_uids_.insert(sop_uid);
193 size_t count()
const {
194 std::lock_guard<std::mutex> lock(mutex_);
195 return received_sop_uids_.size();
198 bool received(
const std::string& sop_uid)
const {
199 std::lock_guard<std::mutex> lock(mutex_);
200 return received_sop_uids_.count(sop_uid) > 0;
204 std::lock_guard<std::mutex> lock(mutex_);
205 received_sop_uids_.clear();
209 mutable std::mutex mutex_;
210 std::set<std::string> received_sop_uids_;
216size_t count_dicom_files(
const std::filesystem::path& dir) {
219 for (
const auto& entry : std::filesystem::directory_iterator(dir, ec)) {
220 if (entry.is_regular_file()) {
221 auto ext = entry.path().extension().string();
223 if (ext ==
".dcm" || ext.empty()) {
236 std::string_view sop_class_uid,
237 const std::string& move_destination) {
244 msg.command_set().set_string(
258TEST_CASE(
"C-MOVE: pacs_system SCP with DCMTK movescu",
"[dcmtk][interop][move]") {
260 SKIP(
"DCMTK not installed - skipping interoperability test");
265 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
271 const std::string move_ae =
"MOVE_SCP";
272 const std::string dest_ae =
"DEST_SCP";
275 test_file_repository repository;
277 auto file1 = create_test_dicom_file(
"PAT001",
"DOE^JOHN", study_uid);
278 auto file2 = create_test_dicom_file(
"PAT001",
"DOE^JOHN", study_uid);
279 repository.add_file(std::move(file1));
280 repository.add_file(std::move(file2));
287 REQUIRE(dcmtk_dest.is_running());
297 auto retrieve_scp_ptr = std::make_shared<retrieve_scp>();
300 retrieve_scp_ptr->set_retrieve_handler([&repository](
301 const dicom_dataset& query_keys) -> std::vector<dicom_file> {
302 return repository.find_all(query_keys);
306 retrieve_scp_ptr->set_destination_resolver([dest_port, dest_ae](
307 const std::string& ae_title) -> std::optional<std::pair<std::string, uint16_t>> {
308 if (ae_title == dest_ae) {
309 return std::make_pair(
"localhost", dest_port);
314 server.register_service(retrieve_scp_ptr);
315 server.register_service(std::make_shared<verification_scp>());
317 REQUIRE(server.start());
324 SECTION(
"C-MOVE by StudyInstanceUID succeeds") {
325 std::vector<std::pair<std::string, std::string>> keys = {
326 {
"StudyInstanceUID", study_uid}
330 "localhost", move_port, move_ae, dest_ae,
333 INFO(
"stdout: " << result.stdout_output);
334 INFO(
"stderr: " << result.stderr_output);
336 REQUIRE(result.success());
339 std::this_thread::sleep_for(std::chrono::seconds{2});
342 auto received_count = count_dicom_files(dest_dir.
path());
343 REQUIRE(received_count >= 2);
346 SECTION(
"C-MOVE by PatientID succeeds") {
347 std::vector<std::pair<std::string, std::string>> keys = {
348 {
"PatientID",
"PAT001"}
352 "localhost", move_port, move_ae, dest_ae,
355 INFO(
"stdout: " << result.stdout_output);
356 INFO(
"stderr: " << result.stderr_output);
358 REQUIRE(result.success());
361 SECTION(
"C-MOVE with empty result set completes gracefully") {
362 std::vector<std::pair<std::string, std::string>> keys = {
363 {
"StudyInstanceUID",
"1.2.3.4.5.6.7.8.9.999999"}
367 "localhost", move_port, move_ae, dest_ae,
370 INFO(
"stdout: " << result.stdout_output);
371 INFO(
"stderr: " << result.stderr_output);
374 REQUIRE(result.success());
382TEST_CASE(
"C-MOVE: Unknown destination AE rejection",
"[dcmtk][interop][move][error]") {
384 SKIP(
"DCMTK not installed");
389 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
393 const std::string ae_title =
"MOVE_SCP";
395 test_file_repository repository;
397 repository.add_file(create_test_dicom_file(
"PAT001",
"DOE^JOHN", study_uid));
401 auto retrieve_scp_ptr = std::make_shared<retrieve_scp>();
402 retrieve_scp_ptr->set_retrieve_handler([&repository](
403 const dicom_dataset& query_keys) -> std::vector<dicom_file> {
404 return repository.find_all(query_keys);
408 retrieve_scp_ptr->set_destination_resolver([](
409 const std::string& ae) -> std::optional<std::pair<std::string, uint16_t>> {
410 if (ae ==
"KNOWN_DEST") {
411 return std::make_pair(
"localhost", 11113);
417 REQUIRE(server.
start());
423 SECTION(
"Unknown destination AE is rejected") {
424 std::vector<std::pair<std::string, std::string>> keys = {
425 {
"StudyInstanceUID", study_uid}
430 "localhost", port, ae_title,
"UNKNOWN_DEST",
433 INFO(
"stdout: " << result.stdout_output);
434 INFO(
"stderr: " << result.stderr_output);
437 REQUIRE_FALSE(result.success());
445TEST_CASE(
"C-MOVE: Connection error handling",
"[dcmtk][interop][move][error]") {
447 SKIP(
"DCMTK not installed");
452 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
455 SECTION(
"movescu to non-existent server fails gracefully") {
461 std::vector<std::pair<std::string, std::string>> keys = {
462 {
"StudyInstanceUID",
"1.2.3.4.5"}
466 "localhost", port,
"NONEXISTENT",
"DEST",
468 "MOVESCU", std::chrono::seconds{10});
471 REQUIRE_FALSE(result.success());
479TEST_CASE(
"C-MOVE: Concurrent operations",
"[dcmtk][interop][move][stress]") {
481 SKIP(
"DCMTK not installed");
486 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
491 const std::string move_ae =
"STRESS_MOVE_SCP";
492 const std::string dest_ae =
"STRESS_DEST";
495 test_file_repository repository;
496 std::vector<std::string> study_uids;
497 for (
int i = 0; i < 3; ++i) {
499 study_uids.push_back(study_uid);
500 repository.add_file(create_test_dicom_file(
501 "PAT00" + std::to_string(i),
502 "PATIENT^" + std::to_string(i),
510 REQUIRE(dcmtk_dest.is_running());
519 auto retrieve_scp_ptr = std::make_shared<retrieve_scp>();
520 retrieve_scp_ptr->set_retrieve_handler([&repository](
521 const dicom_dataset& query_keys) -> std::vector<dicom_file> {
522 return repository.find_all(query_keys);
525 retrieve_scp_ptr->set_destination_resolver([dest_port, dest_ae](
526 const std::string& ae) -> std::optional<std::pair<std::string, uint16_t>> {
528 return std::make_pair(
"localhost", dest_port);
533 server.register_service(retrieve_scp_ptr);
534 REQUIRE(server.start());
540 SECTION(
"Multiple concurrent move requests") {
541 constexpr int num_requests = 2;
542 std::vector<std::future<dcmtk_result>> futures;
543 futures.reserve(num_requests);
545 for (
int i = 0; i < num_requests; ++i) {
546 futures.push_back(std::async(std::launch::async, [&, i]() {
547 std::vector<std::pair<std::string, std::string>> keys = {
548 {
"StudyInstanceUID", study_uids[i]}
551 "localhost", move_port, move_ae, dest_ae,
553 "MOVESCU_" + std::to_string(i));
558 for (
size_t i = 0; i < futures.size(); ++i) {
559 auto result = futures[i].get();
561 INFO(
"Request " << i <<
" stdout: " << result.stdout_output);
562 INFO(
"Request " << i <<
" stderr: " << result.stderr_output);
564 REQUIRE(result.success());
573TEST_CASE(
"C-MOVE: pacs_system SCU basic operation",
"[dcmtk][interop][move]") {
575 SKIP(
"DCMTK not installed");
580 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
589 const std::string move_ae =
"MOVE_SCP";
590 const std::string dest_ae =
"DEST_SCP";
592 test_file_repository repository;
594 repository.add_file(create_test_dicom_file(
"PAT001",
"DOE^JOHN", study_uid));
597 received_file_tracker tracker;
602 auto storage_scp_ptr = std::make_shared<storage_scp>();
603 storage_scp_ptr->set_handler([&tracker](
605 const std::string& calling_ae,
606 const std::string& sop_class_uid,
608 tracker.on_file_received(dataset, calling_ae, sop_class_uid, sop_instance_uid);
609 return storage_status::success;
612 REQUIRE(dest_server.
start());
620 auto retrieve_scp_ptr = std::make_shared<retrieve_scp>();
621 retrieve_scp_ptr->set_retrieve_handler([&repository](
622 const dicom_dataset& query_keys) -> std::vector<dicom_file> {
623 return repository.find_all(query_keys);
626 retrieve_scp_ptr->set_destination_resolver([dest_port, dest_ae](
627 const std::string& ae) -> std::optional<std::pair<std::string, uint16_t>> {
629 return std::make_pair(
"localhost", dest_port);
634 move_server.register_service(retrieve_scp_ptr);
635 REQUIRE(move_server.start());
641 SECTION(
"pacs_system SCU sends C-MOVE request") {
644 "localhost", move_port, move_ae,
"PACS_SCU",
647 REQUIRE(connect_result.is_ok());
648 auto& assoc = connect_result.value();
652 REQUIRE(context_id.has_value());
656 move_keys.
set_string(tags::query_retrieve_level, vr_type::CS,
"STUDY");
657 move_keys.
set_string(tags::study_instance_uid, vr_type::UI, study_uid);
660 move_rq.set_dataset(std::move(move_keys));
662 auto send_result = assoc.send_dimse(*context_id, move_rq);
663 REQUIRE(send_result.is_ok());
666 bool final_received =
false;
667 while (!final_received) {
668 auto recv_result = assoc.receive_dimse(std::chrono::seconds{30});
669 REQUIRE(recv_result.is_ok());
671 auto& [recv_ctx, rsp] = recv_result.value();
672 REQUIRE(rsp.command() == command_field::c_move_rsp);
674 if (rsp.status() == status_success) {
675 final_received =
true;
676 }
else if (rsp.status() != status_pending) {
677 INFO(
"Unexpected status: " <<
static_cast<int>(rsp.status()));
678 FAIL(
"Unexpected C-MOVE response status");
683 std::this_thread::sleep_for(std::chrono::seconds{1});
686 REQUIRE(tracker.count() >= 1);
void set_string(dicom_tag tag, encoding::vr_type vr, std::string_view value)
Set a string value for the given tag.
auto get_string(dicom_tag tag, std::string_view default_value="") const -> std::string
Get the string value of an element.
auto dataset() const noexcept -> const dicom_dataset &
Get read-only access to the main dataset.
static bool is_port_listening(uint16_t port, const std::string &host="127.0.0.1")
Check if a port is currently listening.
static network::Result< network::association > connect(const std::string &host, uint16_t port, const std::string &called_ae, const std::string &calling_ae=test_scu_ae_title, const std::vector< std::string > &sop_classes={"1.2.840.10008.1.1"})
Connect to a test server.
RAII wrapper for temporary test directory.
const std::filesystem::path & path() const noexcept
RAII wrapper for a test DICOM server.
bool start()
Start the server and wait for it to be ready.
void register_service(std::shared_ptr< Service > service)
Register a service provider.
void set_affected_sop_class_uid(std::string_view uid)
Set the Affected SOP Class UID.
DICOM Dataset - ordered collection of Data Elements.
DICOM Part 10 file handling for reading/writing DICOM files.
Compile-time constants for commonly used DICOM tags.
DIMSE message encoding and decoding.
bool supports_real_tcp_dicom()
Check if pacs_system supports real TCP DICOM connections.
uint16_t find_available_port(uint16_t start=default_test_port, int max_attempts=200)
Find an available port for testing.
bool wait_for(Func &&condition, std::chrono::milliseconds timeout, std::chrono::milliseconds interval=std::chrono::milliseconds{50})
Wait for a condition with timeout.
std::chrono::milliseconds server_ready_timeout()
Port listening timeout for pacs_system servers (5s normal, 30s CI)
TEST_CASE("test_data_generator::ct generates valid CT dataset", "[data_generator][ct]")
std::chrono::milliseconds dcmtk_server_ready_timeout()
Port listening timeout for DCMTK servers (10s normal, 60s CI)
std::string generate_uid(const std::string &root="1.2.826.0.1.3680043.9.9999")
Generate a unique UID for testing.
constexpr core::dicom_tag tag_move_destination
Move Destination (0000,0600) - AE.
constexpr uint16_t priority_medium
Medium priority.
storage_status
Storage operation status codes.
constexpr std::string_view study_root_move_sop_class_uid
Study Root Query/Retrieve Information Model - MOVE.
DICOM Query SCP service (C-FIND handler)
DICOM Retrieve SCP service (C-MOVE/C-GET handler)
DICOM Storage SCP service (C-STORE handler)
DICOM Storage SCU service (C-STORE sender)
Common test fixtures and utilities for integration tests.
DICOM Verification SCP service (C-ECHO handler)