PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
test_dcmtk_move.cpp File Reference

C-MOVE (Retrieve) interoperability tests with DCMTK. More...

#include <catch2/catch_test_macros.hpp>
#include "dcmtk_tool.h"
#include "test_fixtures.h"
#include "kcenon/pacs/core/dicom_dataset.h"
#include "kcenon/pacs/core/dicom_file.h"
#include "kcenon/pacs/core/dicom_tag_constants.h"
#include "kcenon/pacs/encoding/transfer_syntax.h"
#include "kcenon/pacs/network/dimse/dimse_message.h"
#include "kcenon/pacs/services/query_scp.h"
#include "kcenon/pacs/services/retrieve_scp.h"
#include "kcenon/pacs/services/storage_scp.h"
#include "kcenon/pacs/services/storage_scu.h"
#include "kcenon/pacs/services/verification_scp.h"
#include <future>
#include <mutex>
#include <set>
#include <thread>
#include <vector>
Include dependency graph for test_dcmtk_move.cpp:

Go to the source code of this file.

Functions

 TEST_CASE ("C-MOVE: pacs_system SCP with DCMTK movescu", "[dcmtk][interop][move]")
 
 TEST_CASE ("C-MOVE: Unknown destination AE rejection", "[dcmtk][interop][move][error]")
 
 TEST_CASE ("C-MOVE: Connection error handling", "[dcmtk][interop][move][error]")
 
 TEST_CASE ("C-MOVE: Concurrent operations", "[dcmtk][interop][move][stress]")
 
 TEST_CASE ("C-MOVE: pacs_system SCU basic operation", "[dcmtk][interop][move]")
 

Detailed Description

C-MOVE (Retrieve) interoperability tests with DCMTK.

Tests bidirectional C-MOVE compatibility between pacs_system and DCMTK:

  • Scenario A: pacs_system SCP <- DCMTK movescu (with DCMTK storescp destination)
  • Scenario B: pacs_system as both SCP and destination
See also
Issue #454 - C-MOVE Interoperability Test with DCMTK
Issue #449 - DCMTK Interoperability Test Automation Epic

Definition in file test_dcmtk_move.cpp.

Function Documentation

◆ TEST_CASE() [1/5]

TEST_CASE ( "C-MOVE: Concurrent operations" ,
"" [dcmtk][interop][move][stress] )

Definition at line 479 of file test_dcmtk_move.cpp.

479 : Concurrent operations", "[dcmtk][interop][move][stress]") {
480 if (!dcmtk_tool::is_available()) {
481 SKIP("DCMTK not installed");
482 }
483
484 // Skip if real TCP DICOM connections are not supported yet
485 if (!supports_real_tcp_dicom()) {
486 SKIP("pacs_system does not support real TCP DICOM connections yet");
487 }
488
489 auto move_port = find_available_port();
490 auto dest_port = find_available_port(move_port + 1);
491 const std::string move_ae = "STRESS_MOVE_SCP";
492 const std::string dest_ae = "STRESS_DEST";
493
494 // Create multiple studies
495 test_file_repository repository;
496 std::vector<std::string> study_uids;
497 for (int i = 0; i < 3; ++i) {
498 auto study_uid = generate_uid();
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),
503 study_uid));
504 }
505
506 test_directory dest_dir;
507
508 // Start DCMTK storescp
509 auto dcmtk_dest = dcmtk_tool::storescp(dest_port, dest_ae, dest_dir.path());
510 REQUIRE(dcmtk_dest.is_running());
511
512 REQUIRE(wait_for([&]() {
513 return process_launcher::is_port_listening(dest_port);
514 }, dcmtk_server_ready_timeout()));
515
516 // Setup pacs_system Move SCP
517 test_server server(move_port, move_ae);
518
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);
523 });
524
525 retrieve_scp_ptr->set_destination_resolver([dest_port, dest_ae](
526 const std::string& ae) -> std::optional<std::pair<std::string, uint16_t>> {
527 if (ae == dest_ae) {
528 return std::make_pair("localhost", dest_port);
529 }
530 return std::nullopt;
531 });
532
533 server.register_service(retrieve_scp_ptr);
534 REQUIRE(server.start());
535
536 REQUIRE(wait_for([&]() {
537 return process_launcher::is_port_listening(move_port);
538 }, server_ready_timeout()));
539
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);
544
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]}
549 };
550 return dcmtk_tool::movescu(
551 "localhost", move_port, move_ae, dest_ae,
552 "STUDY", keys,
553 "MOVESCU_" + std::to_string(i));
554 }));
555 }
556
557 // All should succeed
558 for (size_t i = 0; i < futures.size(); ++i) {
559 auto result = futures[i].get();
560
561 INFO("Request " << i << " stdout: " << result.stdout_output);
562 INFO("Request " << i << " stderr: " << result.stderr_output);
563
564 REQUIRE(result.success());
565 }
566 }
567}
@ move
C-MOVE move request/response.

References kcenon::pacs::integration_test::dcmtk_server_ready_timeout(), kcenon::pacs::integration_test::find_available_port(), kcenon::pacs::integration_test::generate_uid(), kcenon::pacs::integration_test::dcmtk_tool::is_available(), kcenon::pacs::integration_test::process_launcher::is_port_listening(), kcenon::pacs::integration_test::dcmtk_tool::movescu(), kcenon::pacs::integration_test::test_directory::path(), kcenon::pacs::integration_test::server_ready_timeout(), kcenon::pacs::integration_test::dcmtk_tool::storescp(), kcenon::pacs::integration_test::supports_real_tcp_dicom(), and kcenon::pacs::integration_test::wait_for().

Here is the call graph for this function:

◆ TEST_CASE() [2/5]

TEST_CASE ( "C-MOVE: Connection error handling" ,
"" [dcmtk][interop][move][error] )

Definition at line 445 of file test_dcmtk_move.cpp.

445 : Connection error handling", "[dcmtk][interop][move][error]") {
446 if (!dcmtk_tool::is_available()) {
447 SKIP("DCMTK not installed");
448 }
449
450 // Skip if real TCP DICOM connections are not supported yet
451 if (!supports_real_tcp_dicom()) {
452 SKIP("pacs_system does not support real TCP DICOM connections yet");
453 }
454
455 SECTION("movescu to non-existent server fails gracefully") {
456 auto port = find_available_port();
457
458 // Ensure nothing is listening
459 REQUIRE_FALSE(process_launcher::is_port_listening(port));
460
461 std::vector<std::pair<std::string, std::string>> keys = {
462 {"StudyInstanceUID", "1.2.3.4.5"}
463 };
464
465 auto result = dcmtk_tool::movescu(
466 "localhost", port, "NONEXISTENT", "DEST",
467 "STUDY", keys,
468 "MOVESCU", std::chrono::seconds{10});
469
470 // Should fail - no server listening
471 REQUIRE_FALSE(result.success());
472 }
473}
@ error
Node returned an error.

References kcenon::pacs::integration_test::find_available_port(), kcenon::pacs::integration_test::dcmtk_tool::is_available(), kcenon::pacs::integration_test::process_launcher::is_port_listening(), kcenon::pacs::integration_test::dcmtk_tool::movescu(), and kcenon::pacs::integration_test::supports_real_tcp_dicom().

Here is the call graph for this function:

◆ TEST_CASE() [3/5]

TEST_CASE ( "C-MOVE: pacs_system SCP with DCMTK movescu" ,
"" [dcmtk][interop][move] )

Definition at line 258 of file test_dcmtk_move.cpp.

258 : pacs_system SCP with DCMTK movescu", "[dcmtk][interop][move]") {
259 if (!dcmtk_tool::is_available()) {
260 SKIP("DCMTK not installed - skipping interoperability test");
261 }
262
263 // Skip if real TCP DICOM connections are not supported yet
264 if (!supports_real_tcp_dicom()) {
265 SKIP("pacs_system does not support real TCP DICOM connections yet");
266 }
267
268 // Setup: Ports and AE titles
269 auto move_port = find_available_port();
270 auto dest_port = find_available_port(move_port + 1);
271 const std::string move_ae = "MOVE_SCP";
272 const std::string dest_ae = "DEST_SCP";
273
274 // Setup: File repository with test data
275 test_file_repository repository;
276 auto study_uid = generate_uid();
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));
281
282 // Setup: Create destination directory for DCMTK storescp
283 test_directory dest_dir;
284
285 // Start DCMTK storescp as destination
286 auto dcmtk_dest = dcmtk_tool::storescp(dest_port, dest_ae, dest_dir.path());
287 REQUIRE(dcmtk_dest.is_running());
288
289 // Wait for destination to be ready
290 REQUIRE(wait_for([&]() {
291 return process_launcher::is_port_listening(dest_port);
292 }, dcmtk_server_ready_timeout()));
293
294 // Setup: pacs_system Move SCP
295 test_server server(move_port, move_ae);
296
297 auto retrieve_scp_ptr = std::make_shared<retrieve_scp>();
298
299 // Set retrieve handler
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);
303 });
304
305 // Set destination resolver
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);
310 }
311 return std::nullopt;
312 });
313
314 server.register_service(retrieve_scp_ptr);
315 server.register_service(std::make_shared<verification_scp>());
316
317 REQUIRE(server.start());
318
319 // Wait for server to be ready
320 REQUIRE(wait_for([&]() {
321 return process_launcher::is_port_listening(move_port);
322 }, server_ready_timeout()));
323
324 SECTION("C-MOVE by StudyInstanceUID succeeds") {
325 std::vector<std::pair<std::string, std::string>> keys = {
326 {"StudyInstanceUID", study_uid}
327 };
328
329 auto result = dcmtk_tool::movescu(
330 "localhost", move_port, move_ae, dest_ae,
331 "STUDY", keys);
332
333 INFO("stdout: " << result.stdout_output);
334 INFO("stderr: " << result.stderr_output);
335
336 REQUIRE(result.success());
337
338 // Wait for files to be received
339 std::this_thread::sleep_for(std::chrono::seconds{2});
340
341 // Verify files were received
342 auto received_count = count_dicom_files(dest_dir.path());
343 REQUIRE(received_count >= 2);
344 }
345
346 SECTION("C-MOVE by PatientID succeeds") {
347 std::vector<std::pair<std::string, std::string>> keys = {
348 {"PatientID", "PAT001"}
349 };
350
351 auto result = dcmtk_tool::movescu(
352 "localhost", move_port, move_ae, dest_ae,
353 "PATIENT", keys);
354
355 INFO("stdout: " << result.stdout_output);
356 INFO("stderr: " << result.stderr_output);
357
358 REQUIRE(result.success());
359 }
360
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"}
364 };
365
366 auto result = dcmtk_tool::movescu(
367 "localhost", move_port, move_ae, dest_ae,
368 "STUDY", keys);
369
370 INFO("stdout: " << result.stdout_output);
371 INFO("stderr: " << result.stderr_output);
372
373 // Should succeed even with no matching results
374 REQUIRE(result.success());
375 }
376}
@ empty
Z - Replace with zero-length value.

References kcenon::pacs::integration_test::dcmtk_server_ready_timeout(), kcenon::pacs::integration_test::find_available_port(), kcenon::pacs::integration_test::generate_uid(), kcenon::pacs::integration_test::dcmtk_tool::is_available(), kcenon::pacs::integration_test::process_launcher::is_port_listening(), kcenon::pacs::integration_test::dcmtk_tool::movescu(), kcenon::pacs::integration_test::test_directory::path(), kcenon::pacs::integration_test::server_ready_timeout(), kcenon::pacs::integration_test::dcmtk_tool::storescp(), kcenon::pacs::integration_test::supports_real_tcp_dicom(), and kcenon::pacs::integration_test::wait_for().

Here is the call graph for this function:

◆ TEST_CASE() [4/5]

TEST_CASE ( "C-MOVE: pacs_system SCU basic operation" ,
"" [dcmtk][interop][move] )

Definition at line 573 of file test_dcmtk_move.cpp.

573 : pacs_system SCU basic operation", "[dcmtk][interop][move]") {
574 if (!dcmtk_tool::is_available()) {
575 SKIP("DCMTK not installed");
576 }
577
578 // Skip if real TCP DICOM connections are not supported yet
579 if (!supports_real_tcp_dicom()) {
580 SKIP("pacs_system does not support real TCP DICOM connections yet");
581 }
582
583 // Note: DCMTK doesn't provide a simple move SCP for testing.
584 // This section tests pacs_system's move SCU capability by connecting
585 // to our own pacs_system move SCP (which has been validated above).
586
587 auto move_port = find_available_port();
588 auto dest_port = find_available_port(move_port + 1);
589 const std::string move_ae = "MOVE_SCP";
590 const std::string dest_ae = "DEST_SCP";
591
592 test_file_repository repository;
593 auto study_uid = generate_uid();
594 repository.add_file(create_test_dicom_file("PAT001", "DOE^JOHN", study_uid));
595
596 // Received file tracker for destination
597 received_file_tracker tracker;
598 test_directory dest_dir;
599
600 // Start pacs_system as destination storage SCP
601 test_server dest_server(dest_port, dest_ae);
602 auto storage_scp_ptr = std::make_shared<storage_scp>();
603 storage_scp_ptr->set_handler([&tracker](
604 const dicom_dataset& dataset,
605 const std::string& calling_ae,
606 const std::string& sop_class_uid,
607 const std::string& sop_instance_uid) -> storage_status {
608 tracker.on_file_received(dataset, calling_ae, sop_class_uid, sop_instance_uid);
609 return storage_status::success;
610 });
611 dest_server.register_service(storage_scp_ptr);
612 REQUIRE(dest_server.start());
613
614 REQUIRE(wait_for([&]() {
615 return process_launcher::is_port_listening(dest_port);
616 }, server_ready_timeout()));
617
618 // Start Move SCP
619 test_server move_server(move_port, move_ae);
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);
624 });
625
626 retrieve_scp_ptr->set_destination_resolver([dest_port, dest_ae](
627 const std::string& ae) -> std::optional<std::pair<std::string, uint16_t>> {
628 if (ae == dest_ae) {
629 return std::make_pair("localhost", dest_port);
630 }
631 return std::nullopt;
632 });
633
634 move_server.register_service(retrieve_scp_ptr);
635 REQUIRE(move_server.start());
636
637 REQUIRE(wait_for([&]() {
638 return process_launcher::is_port_listening(move_port);
639 }, server_ready_timeout()));
640
641 SECTION("pacs_system SCU sends C-MOVE request") {
642 // Connect to Move SCP
643 auto connect_result = test_association::connect(
644 "localhost", move_port, move_ae, "PACS_SCU",
645 {std::string(study_root_move_sop_class_uid)});
646
647 REQUIRE(connect_result.is_ok());
648 auto& assoc = connect_result.value();
649
650 REQUIRE(assoc.has_accepted_context(study_root_move_sop_class_uid));
651 auto context_id = assoc.accepted_context_id(study_root_move_sop_class_uid);
652 REQUIRE(context_id.has_value());
653
654 // Create move request
655 dicom_dataset move_keys;
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);
658
659 auto move_rq = make_c_move_rq(1, study_root_move_sop_class_uid, dest_ae);
660 move_rq.set_dataset(std::move(move_keys));
661
662 auto send_result = assoc.send_dimse(*context_id, move_rq);
663 REQUIRE(send_result.is_ok());
664
665 // Receive move responses
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());
670
671 auto& [recv_ctx, rsp] = recv_result.value();
672 REQUIRE(rsp.command() == command_field::c_move_rsp);
673
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");
679 }
680 }
681
682 // Wait for destination to receive files
683 std::this_thread::sleep_for(std::chrono::seconds{1});
684
685 // Verify file was received
686 REQUIRE(tracker.count() >= 1);
687 }
688}
@ basic
HTTP basic authentication.
constexpr dicom_tag status
Status.

References kcenon::pacs::integration_test::test_association::connect(), kcenon::pacs::integration_test::find_available_port(), kcenon::pacs::integration_test::generate_uid(), kcenon::pacs::integration_test::dcmtk_tool::is_available(), kcenon::pacs::integration_test::process_launcher::is_port_listening(), kcenon::pacs::integration_test::test_server::register_service(), kcenon::pacs::integration_test::server_ready_timeout(), kcenon::pacs::core::dicom_dataset::set_string(), kcenon::pacs::integration_test::test_server::start(), kcenon::pacs::services::study_root_move_sop_class_uid, kcenon::pacs::integration_test::supports_real_tcp_dicom(), and kcenon::pacs::integration_test::wait_for().

Here is the call graph for this function:

◆ TEST_CASE() [5/5]

TEST_CASE ( "C-MOVE: Unknown destination AE rejection" ,
"" [dcmtk][interop][move][error] )

Definition at line 382 of file test_dcmtk_move.cpp.

382 : Unknown destination AE rejection", "[dcmtk][interop][move][error]") {
383 if (!dcmtk_tool::is_available()) {
384 SKIP("DCMTK not installed");
385 }
386
387 // Skip if real TCP DICOM connections are not supported yet
388 if (!supports_real_tcp_dicom()) {
389 SKIP("pacs_system does not support real TCP DICOM connections yet");
390 }
391
392 auto port = find_available_port();
393 const std::string ae_title = "MOVE_SCP";
394
395 test_file_repository repository;
396 auto study_uid = generate_uid();
397 repository.add_file(create_test_dicom_file("PAT001", "DOE^JOHN", study_uid));
398
399 test_server server(port, ae_title);
400
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);
405 });
406
407 // Only resolve known AE titles
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);
412 }
413 return std::nullopt; // Unknown AE
414 });
415
416 server.register_service(retrieve_scp_ptr);
417 REQUIRE(server.start());
418
419 REQUIRE(wait_for([&]() {
420 return process_launcher::is_port_listening(port);
421 }, server_ready_timeout()));
422
423 SECTION("Unknown destination AE is rejected") {
424 std::vector<std::pair<std::string, std::string>> keys = {
425 {"StudyInstanceUID", study_uid}
426 };
427
428 // Use unknown destination AE
429 auto result = dcmtk_tool::movescu(
430 "localhost", port, ae_title, "UNKNOWN_DEST",
431 "STUDY", keys);
432
433 INFO("stdout: " << result.stdout_output);
434 INFO("stderr: " << result.stderr_output);
435
436 // Should fail due to unknown destination
437 REQUIRE_FALSE(result.success());
438 }
439}
@ AE
Application Entity (16 chars max)
const atna_coded_value destination
Destination Role ID (110152)

References kcenon::pacs::integration_test::find_available_port(), kcenon::pacs::integration_test::generate_uid(), kcenon::pacs::integration_test::dcmtk_tool::is_available(), kcenon::pacs::integration_test::process_launcher::is_port_listening(), kcenon::pacs::integration_test::dcmtk_tool::movescu(), kcenon::pacs::integration_test::test_server::register_service(), kcenon::pacs::integration_test::server_ready_timeout(), kcenon::pacs::integration_test::test_server::start(), kcenon::pacs::integration_test::supports_real_tcp_dicom(), and kcenon::pacs::integration_test::wait_for().

Here is the call graph for this function: