PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
test_dcmtk_move.cpp
Go to the documentation of this file.
1
13#include <catch2/catch_test_macros.hpp>
14
15#include "dcmtk_tool.h"
16#include "test_fixtures.h"
17
28
29#include <future>
30#include <mutex>
31#include <set>
32#include <thread>
33#include <vector>
34
35using namespace kcenon::pacs::integration_test;
36using namespace kcenon::pacs::network;
37using namespace kcenon::pacs::network::dimse;
38using namespace kcenon::pacs::services;
39using namespace kcenon::pacs::core;
40using namespace kcenon::pacs::encoding;
41
42// =============================================================================
43// Test Fixture: DICOM File Repository
44// =============================================================================
45
46namespace {
47
51class test_file_repository {
52public:
53 void add_file(const dicom_file& file) {
54 std::lock_guard<std::mutex> lock(mutex_);
55 files_.push_back(file);
56 }
57
58 void add_file(dicom_file&& file) {
59 std::lock_guard<std::mutex> lock(mutex_);
60 files_.push_back(std::move(file));
61 }
62
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;
66
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);
71 }
72 }
73
74 return results;
75 }
76
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;
80
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);
85 }
86 }
87
88 return results;
89 }
90
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;
94
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);
97
98 for (const auto& file : files_) {
99 bool match = true;
100
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) {
104 match = false;
105 }
106 }
107
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) {
111 match = false;
112 }
113 }
114
115 if (match) {
116 results.push_back(file);
117 }
118 }
119
120 return results;
121 }
122
123 void clear() {
124 std::lock_guard<std::mutex> lock(mutex_);
125 files_.clear();
126 }
127
128 size_t size() const {
129 std::lock_guard<std::mutex> lock(mutex_);
130 return files_.size();
131 }
132
133private:
134 mutable std::mutex mutex_;
135 std::vector<dicom_file> files_;
136};
137
141dicom_file create_test_dicom_file(
142 const std::string& patient_id,
143 const std::string& patient_name,
144 const std::string& study_uid) {
145
146 dicom_dataset ds;
147
148 // SOP Common
150 ds.set_string(tags::sop_class_uid, vr_type::UI, "1.2.840.10008.5.1.4.1.1.2"); // CT
151 ds.set_string(tags::sop_instance_uid, vr_type::UI, sop_instance_uid);
152
153 // Patient
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");
158
159 // Study
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");
165
166 // Series
167 ds.set_string(tags::series_instance_uid, vr_type::UI, generate_uid());
168 ds.set_string(tags::series_number, vr_type::IS, "1");
169 ds.set_string(tags::modality, vr_type::CS, "CT");
170
171 // Instance
172 ds.set_string(tags::instance_number, vr_type::IS, "1");
173
174 return dicom_file::create(std::move(ds), transfer_syntax::explicit_vr_little_endian);
175}
176
180class received_file_tracker {
181public:
182 void on_file_received(
183 const dicom_dataset& dataset,
184 const std::string& /* calling_ae */,
185 const std::string& /* sop_class_uid */,
186 const std::string& /* sop_instance_uid */) {
187
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);
191 }
192
193 size_t count() const {
194 std::lock_guard<std::mutex> lock(mutex_);
195 return received_sop_uids_.size();
196 }
197
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;
201 }
202
203 void clear() {
204 std::lock_guard<std::mutex> lock(mutex_);
205 received_sop_uids_.clear();
206 }
207
208private:
209 mutable std::mutex mutex_;
210 std::set<std::string> received_sop_uids_;
211};
212
216size_t count_dicom_files(const std::filesystem::path& dir) {
217 size_t count = 0;
218 std::error_code ec;
219 for (const auto& entry : std::filesystem::directory_iterator(dir, ec)) {
220 if (entry.is_regular_file()) {
221 auto ext = entry.path().extension().string();
222 // DCMTK storescp may save with .dcm or without extension
223 if (ext == ".dcm" || ext.empty()) {
224 ++count;
225 }
226 }
227 }
228 return count;
229}
230
234dimse_message make_c_move_rq(
235 uint16_t message_id,
236 std::string_view sop_class_uid,
237 const std::string& move_destination) {
238
239 dimse_message msg{command_field::c_move_rq, message_id};
240 msg.set_affected_sop_class_uid(sop_class_uid);
241 msg.set_priority(priority_medium);
242
243 // Set Move Destination AE
244 msg.command_set().set_string(
246 vr_type::AE,
247 move_destination);
248
249 return msg;
250}
251
252} // namespace
253
254// =============================================================================
255// Test: pacs_system Move SCP with DCMTK movescu
256// =============================================================================
257
258TEST_CASE("C-MOVE: pacs_system SCP with DCMTK movescu", "[dcmtk][interop][move]") {
260 SKIP("DCMTK not installed - skipping interoperability test");
261 }
262
263 // Skip if real TCP DICOM connections are not supported yet
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);
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);
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}
377
378// =============================================================================
379// Test: Unknown destination AE handling
380// =============================================================================
381
382TEST_CASE("C-MOVE: Unknown destination AE rejection", "[dcmtk][interop][move][error]") {
384 SKIP("DCMTK not installed");
385 }
386
387 // Skip if real TCP DICOM connections are not supported yet
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([&]() {
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}
440
441// =============================================================================
442// Test: Connection error handling
443// =============================================================================
444
445TEST_CASE("C-MOVE: Connection error handling", "[dcmtk][interop][move][error]") {
447 SKIP("DCMTK not installed");
448 }
449
450 // Skip if real TCP DICOM connections are not supported yet
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}
474
475// =============================================================================
476// Test: Concurrent move operations
477// =============================================================================
478
479TEST_CASE("C-MOVE: Concurrent operations", "[dcmtk][interop][move][stress]") {
481 SKIP("DCMTK not installed");
482 }
483
484 // Skip if real TCP DICOM connections are not supported yet
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);
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);
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}
568
569// =============================================================================
570// Test: pacs_system as Move SCU (with DCMTK SCP - limited test)
571// =============================================================================
572
573TEST_CASE("C-MOVE: pacs_system SCU basic operation", "[dcmtk][interop][move]") {
575 SKIP("DCMTK not installed");
576 }
577
578 // Skip if real TCP DICOM connections are not supported yet
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);
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);
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}
689
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_available()
Check if DCMTK is available on the system.
Definition dcmtk_tool.h:70
static dcmtk_result movescu(const std::string &host, uint16_t port, const std::string &called_ae, const std::string &dest_ae, const std::string &query_level, const std::vector< std::pair< std::string, std::string > > &keys, const std::string &calling_ae="MOVESCU", std::chrono::seconds timeout=std::chrono::seconds{120})
Run C-MOVE (movescu) client.
Definition dcmtk_tool.h:217
static background_process_guard storescp(uint16_t port, const std::string &ae_title, const std::filesystem::path &output_dir, std::chrono::seconds startup_timeout=default_scp_startup_timeout())
Start C-STORE SCP (storescp) server.
Definition dcmtk_tool.h:274
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.
C++ wrapper for DCMTK command-line tools.
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.
constexpr dicom_tag patient_id
Patient ID.
constexpr dicom_tag message_id
Message ID.
constexpr dicom_tag sop_instance_uid
SOP Instance UID.
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)