PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
test_dcmtk_store.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
23
24#include <atomic>
25#include <filesystem>
26#include <future>
27#include <mutex>
28#include <thread>
29#include <vector>
30
31namespace fs = std::filesystem;
32
33using namespace kcenon::pacs::integration_test;
34using namespace kcenon::pacs::network;
35using namespace kcenon::pacs::network::dimse;
36using namespace kcenon::pacs::services;
37using namespace kcenon::pacs::core;
38using namespace kcenon::pacs::encoding;
39
40// =============================================================================
41// Helper: Storage Test Server
42// =============================================================================
43
44namespace {
45
49class storage_test_server {
50public:
51 explicit storage_test_server(uint16_t port, const std::string& ae_title = "STORE_SCP")
52 : port_(port)
53 , ae_title_(ae_title)
54 , storage_dir_("dcmtk_store_test_") {
55
56 fs::create_directories(storage_dir_.path());
57
58 server_ = std::make_unique<test_server>(port_, ae_title_);
59
60 auto storage_scp_ptr = std::make_shared<storage_scp>();
61 storage_scp_ptr->set_handler([this](
62 const dicom_dataset& dataset,
63 const std::string& /* calling_ae */,
64 const std::string& /* sop_class_uid */,
65 const std::string& sop_instance_uid) -> storage_status {
66
67 return handle_store(dataset, sop_instance_uid);
68 });
69
70 server_->register_service(storage_scp_ptr);
71 }
72
73 bool start() {
74 if (!server_->start()) {
75 return false;
76 }
77 return wait_for([this]() {
80 }
81
82 void stop() {
83 server_->stop();
84 }
85
86 uint16_t port() const { return port_; }
87 const std::string& ae_title() const { return ae_title_; }
88 size_t stored_count() const { return stored_count_.load(); }
89 const fs::path& storage_path() const { return storage_dir_.path(); }
90
91 std::vector<fs::path> stored_files() const {
92 std::lock_guard<std::mutex> lock(mutex_);
93 return stored_files_;
94 }
95
96private:
97 storage_status handle_store(const dicom_dataset& dataset,
98 const std::string& sop_instance_uid) {
99 try {
100 auto file_path = storage_dir_.path() / (sop_instance_uid + ".dcm");
101 auto file = dicom_file::create(
102 dicom_dataset{dataset},
103 transfer_syntax::explicit_vr_little_endian);
104 auto result = file.save(file_path);
105
106 if (result.is_ok()) {
107 std::lock_guard<std::mutex> lock(mutex_);
108 stored_files_.push_back(file_path);
109 ++stored_count_;
110 return storage_status::success;
111 }
112 return storage_status::storage_error;
113 } catch (...) {
114 return storage_status::storage_error;
115 }
116 }
117
118 uint16_t port_;
119 std::string ae_title_;
120 test_directory storage_dir_;
121 std::unique_ptr<test_server> server_;
122 std::atomic<size_t> stored_count_{0};
123 mutable std::mutex mutex_;
124 std::vector<fs::path> stored_files_;
125};
126
130std::vector<fs::path> find_dicom_files(const fs::path& dir) {
131 std::vector<fs::path> result;
132 if (!fs::exists(dir)) {
133 return result;
134 }
135
136 for (const auto& entry : fs::recursive_directory_iterator(dir)) {
137 if (entry.is_regular_file()) {
138 auto ext = entry.path().extension().string();
139 if (ext == ".dcm" || ext.empty()) {
140 result.push_back(entry.path());
141 }
142 }
143 }
144 return result;
145}
146
150fs::path create_test_dicom(const fs::path& dir, const std::string& filename,
151 const std::string& modality = "CT") {
152 fs::create_directories(dir);
153
154 dicom_dataset ds;
155 if (modality == "CT") {
156 ds = generate_ct_dataset();
157 } else if (modality == "MR") {
158 ds = generate_mr_dataset();
159 } else if (modality == "XA") {
160 ds = generate_xa_dataset();
161 } else {
162 ds = generate_ct_dataset();
163 }
164
165 auto file_path = dir / filename;
166 auto file = dicom_file::create(
167 std::move(ds),
168 transfer_syntax::explicit_vr_little_endian);
169 auto result = file.save(file_path);
170 if (!result.is_ok()) {
171 throw std::runtime_error("Failed to write test DICOM file");
172 }
173
174 return file_path;
175}
176
177} // namespace
178
179// =============================================================================
180// Test: pacs_system SCP receives from DCMTK storescu
181// =============================================================================
182
183TEST_CASE("C-STORE: pacs_system SCP receives from DCMTK storescu",
184 "[dcmtk][interop][store]") {
186 SKIP("DCMTK not installed - skipping interoperability test");
187 }
188
189 // Skip if real TCP DICOM connections are not supported yet
191 SKIP("pacs_system does not support real TCP DICOM connections yet");
192 }
193
194 auto port = find_available_port();
195 test_directory input_dir;
196
197 // Setup storage server
198 storage_test_server server(port, "PACS_STORE");
199 REQUIRE(server.start());
200
201 SECTION("Single CT image storage") {
202 auto test_file = create_test_dicom(input_dir.path(), "test_ct.dcm", "CT");
203
204 auto result = dcmtk_tool::storescu(
205 "localhost", port, "PACS_STORE", {test_file});
206
207 INFO("stdout: " << result.stdout_output);
208 INFO("stderr: " << result.stderr_output);
209
210 REQUIRE(result.success());
211 REQUIRE(server.stored_count() >= 1);
212 }
213
214 SECTION("MR image storage") {
215 auto test_file = create_test_dicom(input_dir.path(), "test_mr.dcm", "MR");
216
217 auto result = dcmtk_tool::storescu(
218 "localhost", port, "PACS_STORE", {test_file});
219
220 INFO("stdout: " << result.stdout_output);
221 INFO("stderr: " << result.stderr_output);
222
223 REQUIRE(result.success());
224 REQUIRE(server.stored_count() >= 1);
225 }
226
227 SECTION("Multiple images in single association") {
228 std::vector<fs::path> files;
229 for (int i = 0; i < 3; ++i) {
230 auto file = create_test_dicom(
231 input_dir.path(),
232 "test_" + std::to_string(i) + ".dcm",
233 "CT");
234 files.push_back(file);
235 }
236
237 auto result = dcmtk_tool::storescu(
238 "localhost", port, "PACS_STORE", files);
239
240 INFO("stdout: " << result.stdout_output);
241 INFO("stderr: " << result.stderr_output);
242
243 REQUIRE(result.success());
244 REQUIRE(server.stored_count() >= 3);
245 }
246
247 SECTION("Multiple modality images") {
248 std::vector<fs::path> files;
249 files.push_back(create_test_dicom(input_dir.path(), "ct.dcm", "CT"));
250 files.push_back(create_test_dicom(input_dir.path(), "mr.dcm", "MR"));
251 files.push_back(create_test_dicom(input_dir.path(), "xa.dcm", "XA"));
252
253 auto result = dcmtk_tool::storescu(
254 "localhost", port, "PACS_STORE", files);
255
256 REQUIRE(result.success());
257 REQUIRE(server.stored_count() >= 3);
258 }
259}
260
261// =============================================================================
262// Test: DCMTK storescp receives from pacs_system SCU
263// =============================================================================
264
265TEST_CASE("C-STORE: DCMTK storescp receives from pacs_system SCU",
266 "[dcmtk][interop][store]") {
268 SKIP("DCMTK not installed - skipping interoperability test");
269 }
270
271 // Skip if real TCP DICOM connections are not supported yet
273 SKIP("pacs_system does not support real TCP DICOM connections yet");
274 }
275
276 auto port = find_available_port();
277 test_directory storage_dir;
278 test_directory input_dir;
279
280 // Start DCMTK storescp
281 auto dcmtk_server = dcmtk_tool::storescp(port, "DCMTK_SCP", storage_dir.path());
282 REQUIRE(dcmtk_server.is_running());
283
284 REQUIRE(wait_for([&]() {
287
288 SECTION("Single image via storage_scu") {
289 auto test_file = create_test_dicom(input_dir.path(), "test.dcm", "CT");
290
291 auto file_result = dicom_file::open(test_file);
292 REQUIRE(file_result.is_ok());
293
294 // Get SOP Class UID from the dataset
295 auto sop_class = file_result.value().dataset().get_string(tags::sop_class_uid);
296 REQUIRE_FALSE(sop_class.empty());
297
298 // Establish association with DCMTK storescp
299 auto connect_result = test_association::connect(
300 "localhost", port, "DCMTK_SCP", "PACS_SCU", {sop_class});
301 REQUIRE(connect_result.is_ok());
302
303 auto& assoc = connect_result.value();
304 storage_scu scu;
305 auto send_result = scu.store(assoc, file_result.value().dataset());
306 REQUIRE(send_result.is_ok());
307
308 (void)assoc.release(default_timeout());
309
310 // Wait for DCMTK to write the file
311 std::this_thread::sleep_for(std::chrono::milliseconds{500});
312 auto received = find_dicom_files(storage_dir.path());
313 REQUIRE(received.size() >= 1);
314 }
315
316 SECTION("Multiple images via storage_scu") {
317 // Load all datasets first to get SOP Class UIDs
318 std::vector<std::pair<fs::path, dicom_file>> files;
319 for (int i = 0; i < 3; ++i) {
320 auto test_file = create_test_dicom(
321 input_dir.path(),
322 "test_" + std::to_string(i) + ".dcm",
323 "CT");
324
325 auto file_result = dicom_file::open(test_file);
326 REQUIRE(file_result.is_ok());
327 files.emplace_back(test_file, std::move(file_result.value()));
328 }
329
330 // Establish association
331 auto connect_result = test_association::connect(
332 "localhost", port, "DCMTK_SCP", "PACS_SCU",
333 {"1.2.840.10008.5.1.4.1.1.2"}); // CT Image Storage
334 REQUIRE(connect_result.is_ok());
335
336 auto& assoc = connect_result.value();
337 storage_scu scu;
338
339 for (size_t i = 0; i < files.size(); ++i) {
340 auto send_result = scu.store(assoc, files[i].second.dataset());
341 INFO("Sending file " << i);
342 REQUIRE(send_result.is_ok());
343 }
344
345 (void)assoc.release(default_timeout());
346
347 std::this_thread::sleep_for(std::chrono::milliseconds{500});
348 auto received = find_dicom_files(storage_dir.path());
349 REQUIRE(received.size() >= 3);
350 }
351}
352
353// =============================================================================
354// Test: Bidirectional store (round-trip)
355// =============================================================================
356
357TEST_CASE("C-STORE: Bidirectional round-trip verification",
358 "[dcmtk][interop][store]") {
360 SKIP("DCMTK not installed");
361 }
362
363 // Skip if real TCP DICOM connections are not supported yet
365 SKIP("pacs_system does not support real TCP DICOM connections yet");
366 }
367
368 test_directory original_dir;
369 test_directory dcmtk_storage_dir;
370
371 auto pacs_port = find_available_port();
372 auto dcmtk_port = find_available_port();
373
374 // Setup pacs_system storage server
375 storage_test_server pacs_server(pacs_port, "PACS_SCP");
376 REQUIRE(pacs_server.start());
377
378 // Start DCMTK storescp
379 auto dcmtk_server = dcmtk_tool::storescp(
380 dcmtk_port, "DCMTK_SCP", dcmtk_storage_dir.path());
381 REQUIRE(dcmtk_server.is_running());
382
383 REQUIRE(wait_for([&]() {
384 return process_launcher::is_port_listening(dcmtk_port);
386
387 SECTION("DCMTK -> pacs_system -> DCMTK round-trip") {
388 // Create original test file
389 auto original_file = create_test_dicom(
390 original_dir.path(), "original.dcm", "CT");
391
392 // Read original for comparison
393 auto original_result = dicom_file::open(original_file);
394 REQUIRE(original_result.is_ok());
395 auto orig_uid = original_result.value().dataset().get_string(
396 tags::sop_instance_uid);
397 REQUIRE_FALSE(orig_uid.empty());
398
399 // Step 1: DCMTK storescu -> pacs_system SCP
400 auto store1 = dcmtk_tool::storescu(
401 "localhost", pacs_port, "PACS_SCP", {original_file});
402 REQUIRE(store1.success());
403 REQUIRE(pacs_server.stored_count() >= 1);
404
405 // Get the stored files
406 auto pacs_files = pacs_server.stored_files();
407 REQUIRE(pacs_files.size() >= 1);
408
409 // Step 2: pacs_system SCU -> DCMTK storescp
410 auto read_result = dicom_file::open(pacs_files[0]);
411 REQUIRE(read_result.is_ok());
412
413 // Establish association with DCMTK storescp
414 auto connect_result = test_association::connect(
415 "localhost", dcmtk_port, "DCMTK_SCP", "PACS_SCU",
416 {"1.2.840.10008.5.1.4.1.1.2"}); // CT Image Storage
417 REQUIRE(connect_result.is_ok());
418
419 auto& assoc = connect_result.value();
420 storage_scu scu;
421 auto send_result = scu.store(assoc, read_result.value().dataset());
422 REQUIRE(send_result.is_ok());
423 (void)assoc.release(default_timeout());
424
425 // Verify DCMTK received the file
426 std::this_thread::sleep_for(std::chrono::milliseconds{500});
427 auto dcmtk_files = find_dicom_files(dcmtk_storage_dir.path());
428 REQUIRE(dcmtk_files.size() >= 1);
429
430 // Verify data integrity through round-trip
431 auto final_result = dicom_file::open(dcmtk_files[0]);
432 REQUIRE(final_result.is_ok());
433
434 auto final_uid = final_result.value().dataset().get_string(
435 tags::sop_instance_uid);
436 REQUIRE_FALSE(final_uid.empty());
437
438 // UIDs should match
439 REQUIRE(final_uid == orig_uid);
440 }
441}
442
443// =============================================================================
444// Test: Concurrent store operations
445// =============================================================================
446
447TEST_CASE("C-STORE: Concurrent store operations", "[dcmtk][interop][store][stress]") {
449 SKIP("DCMTK not installed");
450 }
451
452 // Skip if real TCP DICOM connections are not supported yet
454 SKIP("pacs_system does not support real TCP DICOM connections yet");
455 }
456
457 auto port = find_available_port();
458 test_directory input_dir;
459
460 // Setup storage server
461 storage_test_server server(port, "STRESS_SCP");
462 REQUIRE(server.start());
463
464 SECTION("3 concurrent DCMTK storescu clients") {
465 constexpr int num_clients = 3;
466
467 // Create test files for each client
468 std::vector<fs::path> files;
469 for (int i = 0; i < num_clients; ++i) {
470 auto file = create_test_dicom(
471 input_dir.path(),
472 "client_" + std::to_string(i) + ".dcm",
473 "CT");
474 files.push_back(file);
475 }
476
477 // Launch concurrent stores
478 std::vector<std::future<dcmtk_result>> futures;
479 for (int i = 0; i < num_clients; ++i) {
480 futures.push_back(std::async(std::launch::async, [&, i]() {
482 "localhost", port, "STRESS_SCP",
483 {files[static_cast<size_t>(i)]},
484 "CLIENT_" + std::to_string(i));
485 }));
486 }
487
488 // All should succeed
489 for (size_t i = 0; i < futures.size(); ++i) {
490 auto result = futures[i].get();
491
492 INFO("Client " << i << " stdout: " << result.stdout_output);
493 INFO("Client " << i << " stderr: " << result.stderr_output);
494
495 REQUIRE(result.success());
496 }
497
498 // Verify all files were stored
499 REQUIRE(server.stored_count() >= static_cast<size_t>(num_clients));
500 }
501}
502
503// =============================================================================
504// Test: Error handling
505// =============================================================================
506
507TEST_CASE("C-STORE: Error handling", "[dcmtk][interop][store][error]") {
509 SKIP("DCMTK not installed");
510 }
511
512 // Skip if real TCP DICOM connections are not supported yet
514 SKIP("pacs_system does not support real TCP DICOM connections yet");
515 }
516
517 SECTION("storescu to non-existent server fails gracefully") {
518 auto port = find_available_port();
519 test_directory input_dir;
520
521 REQUIRE_FALSE(process_launcher::is_port_listening(port));
522
523 auto test_file = create_test_dicom(input_dir.path(), "test.dcm", "CT");
524
525 auto result = dcmtk_tool::storescu(
526 "localhost", port, "NONEXISTENT", {test_file},
527 "STORESCU", std::chrono::seconds{5});
528
529 REQUIRE_FALSE(result.success());
530 }
531
532 SECTION("pacs_system SCU to non-existent server fails gracefully") {
533 // Use a high port range that's less likely to have conflicts
534 auto port = find_available_port(59000);
535
536 // Wait briefly and re-verify the port is truly free
537 std::this_thread::sleep_for(std::chrono::milliseconds{100});
538
539 // Ensure nothing is listening on this port
541 SKIP("Port " + std::to_string(port) + " is unexpectedly in use");
542 }
543
544 // Connection should fail - no server listening
545 auto connect_result = test_association::connect(
546 "localhost", port, "NONEXISTENT", "PACS_SCU",
547 {"1.2.840.10008.5.1.4.1.1.2"}); // CT Image Storage
548
549 REQUIRE_FALSE(connect_result.is_ok());
550 }
551}
552
553// =============================================================================
554// Test: Data integrity verification
555// =============================================================================
556
557TEST_CASE("C-STORE: Data integrity verification", "[dcmtk][interop][store][integrity]") {
559 SKIP("DCMTK not installed");
560 }
561
562 // Skip if real TCP DICOM connections are not supported yet
564 SKIP("pacs_system does not support real TCP DICOM connections yet");
565 }
566
567 auto port = find_available_port();
568 test_directory input_dir;
569
570 // Setup storage server
571 storage_test_server server(port, "INTEGRITY_SCP");
572 REQUIRE(server.start());
573
574 SECTION("Patient demographics preserved") {
575 // Create test file with specific patient data
577 ds.set_string(tags::patient_name, vr_type::PN, "INTEGRITY^TEST^PATIENT");
578 ds.set_string(tags::patient_id, vr_type::LO, "INTEG001");
579
580 auto test_file = input_dir.path() / "integrity_test.dcm";
581 auto file = dicom_file::create(
582 std::move(ds),
583 transfer_syntax::explicit_vr_little_endian);
584 auto write_result = file.save(test_file);
585 REQUIRE(write_result.is_ok());
586
587 // Store via DCMTK
588 auto result = dcmtk_tool::storescu(
589 "localhost", port, "INTEGRITY_SCP", {test_file});
590 REQUIRE(result.success());
591
592 // Verify stored data
593 auto stored_files = server.stored_files();
594 REQUIRE(stored_files.size() >= 1);
595
596 auto stored_result = dicom_file::open(stored_files[0]);
597 REQUIRE(stored_result.is_ok());
598
599 auto& stored_ds = stored_result.value().dataset();
600
601 auto stored_name = stored_ds.get_string(tags::patient_name);
602 auto stored_id = stored_ds.get_string(tags::patient_id);
603
604 REQUIRE_FALSE(stored_name.empty());
605 REQUIRE_FALSE(stored_id.empty());
606 REQUIRE(stored_name == "INTEGRITY^TEST^PATIENT");
607 REQUIRE(stored_id == "INTEG001");
608 }
609}
void set_string(dicom_tag tag, encoding::vr_type vr, std::string_view value)
Set a string value for the given tag.
static bool is_available()
Check if DCMTK is available on the system.
Definition dcmtk_tool.h:70
static dcmtk_result storescu(const std::string &host, uint16_t port, const std::string &called_ae, const std::vector< std::filesystem::path > &files, const std::string &calling_ae="STORESCU", std::chrono::seconds timeout=std::chrono::seconds{60})
Run C-STORE (storescu) client.
Definition dcmtk_tool.h:138
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
network::Result< store_result > store(network::association &assoc, const core::dicom_dataset &dataset)
Store a single DICOM dataset.
C++ wrapper for DCMTK command-line tools.
DICOM Part 10 file handling for reading/writing DICOM files.
DIMSE message encoding and decoding.
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.
core::dicom_dataset generate_mr_dataset(const std::string &study_uid="")
Generate a MR image dataset 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.
core::dicom_dataset generate_ct_dataset(const std::string &study_uid="", const std::string &series_uid="", const std::string &instance_uid="")
Generate a minimal CT image dataset for testing.
std::chrono::milliseconds server_ready_timeout()
Port listening timeout for pacs_system servers (5s normal, 30s CI)
std::chrono::milliseconds default_timeout()
Default timeout for test operations (5s normal, 30s CI)
TEST_CASE("test_data_generator::ct generates valid CT dataset", "[data_generator][ct]")
core::dicom_dataset generate_xa_dataset(const std::string &study_uid="")
Generate a XA (X-Ray Angiographic) image dataset for testing.
std::chrono::milliseconds dcmtk_server_ready_timeout()
Port listening timeout for DCMTK servers (10s normal, 60s CI)
storage_status
Storage operation status codes.
DICOM Storage SCP service (C-STORE handler)
DICOM Storage SCU service (C-STORE sender)
Common test fixtures and utilities for integration tests.