13#include <catch2/catch_test_macros.hpp>
31namespace fs = std::filesystem;
49class storage_test_server {
51 explicit storage_test_server(uint16_t port,
const std::string& ae_title =
"STORE_SCP")
54 , storage_dir_(
"dcmtk_store_test_") {
56 fs::create_directories(storage_dir_.path());
58 server_ = std::make_unique<test_server>(port_, ae_title_);
60 auto storage_scp_ptr = std::make_shared<storage_scp>();
61 storage_scp_ptr->set_handler([
this](
67 return handle_store(dataset, sop_instance_uid);
70 server_->register_service(storage_scp_ptr);
74 if (!server_->start()) {
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(); }
91 std::vector<fs::path> stored_files()
const {
92 std::lock_guard<std::mutex> lock(mutex_);
98 const std::string& sop_instance_uid) {
101 auto file = dicom_file::create(
103 transfer_syntax::explicit_vr_little_endian);
104 auto result = file.save(file_path);
106 if (result.is_ok()) {
107 std::lock_guard<std::mutex> lock(mutex_);
108 stored_files_.push_back(file_path);
110 return storage_status::success;
112 return storage_status::storage_error;
114 return storage_status::storage_error;
119 std::string ae_title_;
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_;
130std::vector<fs::path> find_dicom_files(
const fs::path& dir) {
131 std::vector<fs::path> result;
132 if (!fs::exists(dir)) {
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());
150fs::path create_test_dicom(
const fs::path& dir,
const std::string& filename,
151 const std::string& modality =
"CT") {
152 fs::create_directories(dir);
155 if (modality ==
"CT") {
157 }
else if (modality ==
"MR") {
159 }
else if (modality ==
"XA") {
165 auto file_path = dir / filename;
166 auto file = dicom_file::create(
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");
183TEST_CASE(
"C-STORE: pacs_system SCP receives from DCMTK storescu",
184 "[dcmtk][interop][store]") {
186 SKIP(
"DCMTK not installed - skipping interoperability test");
191 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
198 storage_test_server server(port,
"PACS_STORE");
199 REQUIRE(server.start());
201 SECTION(
"Single CT image storage") {
202 auto test_file = create_test_dicom(input_dir.
path(),
"test_ct.dcm",
"CT");
205 "localhost", port,
"PACS_STORE", {test_file});
207 INFO(
"stdout: " << result.stdout_output);
208 INFO(
"stderr: " << result.stderr_output);
210 REQUIRE(result.success());
211 REQUIRE(server.stored_count() >= 1);
214 SECTION(
"MR image storage") {
215 auto test_file = create_test_dicom(input_dir.
path(),
"test_mr.dcm",
"MR");
218 "localhost", port,
"PACS_STORE", {test_file});
220 INFO(
"stdout: " << result.stdout_output);
221 INFO(
"stderr: " << result.stderr_output);
223 REQUIRE(result.success());
224 REQUIRE(server.stored_count() >= 1);
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(
232 "test_" + std::to_string(i) +
".dcm",
234 files.push_back(file);
238 "localhost", port,
"PACS_STORE", files);
240 INFO(
"stdout: " << result.stdout_output);
241 INFO(
"stderr: " << result.stderr_output);
243 REQUIRE(result.success());
244 REQUIRE(server.stored_count() >= 3);
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"));
254 "localhost", port,
"PACS_STORE", files);
256 REQUIRE(result.success());
257 REQUIRE(server.stored_count() >= 3);
265TEST_CASE(
"C-STORE: DCMTK storescp receives from pacs_system SCU",
266 "[dcmtk][interop][store]") {
268 SKIP(
"DCMTK not installed - skipping interoperability test");
273 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
282 REQUIRE(dcmtk_server.is_running());
288 SECTION(
"Single image via storage_scu") {
289 auto test_file = create_test_dicom(input_dir.
path(),
"test.dcm",
"CT");
291 auto file_result = dicom_file::open(test_file);
292 REQUIRE(file_result.is_ok());
295 auto sop_class = file_result.value().dataset().get_string(tags::sop_class_uid);
296 REQUIRE_FALSE(sop_class.empty());
300 "localhost", port,
"DCMTK_SCP",
"PACS_SCU", {sop_class});
301 REQUIRE(connect_result.is_ok());
303 auto& assoc = connect_result.value();
305 auto send_result = scu.
store(assoc, file_result.value().dataset());
306 REQUIRE(send_result.is_ok());
311 std::this_thread::sleep_for(std::chrono::milliseconds{500});
312 auto received = find_dicom_files(storage_dir.
path());
313 REQUIRE(received.size() >= 1);
316 SECTION(
"Multiple images via storage_scu") {
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(
322 "test_" + std::to_string(i) +
".dcm",
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()));
332 "localhost", port,
"DCMTK_SCP",
"PACS_SCU",
333 {
"1.2.840.10008.5.1.4.1.1.2"});
334 REQUIRE(connect_result.is_ok());
336 auto& assoc = connect_result.value();
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());
347 std::this_thread::sleep_for(std::chrono::milliseconds{500});
348 auto received = find_dicom_files(storage_dir.
path());
349 REQUIRE(received.size() >= 3);
357TEST_CASE(
"C-STORE: Bidirectional round-trip verification",
358 "[dcmtk][interop][store]") {
360 SKIP(
"DCMTK not installed");
365 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
375 storage_test_server pacs_server(pacs_port,
"PACS_SCP");
376 REQUIRE(pacs_server.start());
380 dcmtk_port,
"DCMTK_SCP", dcmtk_storage_dir.
path());
381 REQUIRE(dcmtk_server.is_running());
387 SECTION(
"DCMTK -> pacs_system -> DCMTK round-trip") {
389 auto original_file = create_test_dicom(
390 original_dir.
path(),
"original.dcm",
"CT");
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());
401 "localhost", pacs_port,
"PACS_SCP", {original_file});
402 REQUIRE(store1.success());
403 REQUIRE(pacs_server.stored_count() >= 1);
406 auto pacs_files = pacs_server.stored_files();
407 REQUIRE(pacs_files.size() >= 1);
410 auto read_result = dicom_file::open(pacs_files[0]);
411 REQUIRE(read_result.is_ok());
415 "localhost", dcmtk_port,
"DCMTK_SCP",
"PACS_SCU",
416 {
"1.2.840.10008.5.1.4.1.1.2"});
417 REQUIRE(connect_result.is_ok());
419 auto& assoc = connect_result.value();
421 auto send_result = scu.
store(assoc, read_result.value().dataset());
422 REQUIRE(send_result.is_ok());
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);
431 auto final_result = dicom_file::open(dcmtk_files[0]);
432 REQUIRE(final_result.is_ok());
434 auto final_uid = final_result.value().dataset().get_string(
435 tags::sop_instance_uid);
436 REQUIRE_FALSE(final_uid.empty());
439 REQUIRE(final_uid == orig_uid);
447TEST_CASE(
"C-STORE: Concurrent store operations",
"[dcmtk][interop][store][stress]") {
449 SKIP(
"DCMTK not installed");
454 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
461 storage_test_server server(port,
"STRESS_SCP");
462 REQUIRE(server.start());
464 SECTION(
"3 concurrent DCMTK storescu clients") {
465 constexpr int num_clients = 3;
468 std::vector<fs::path> files;
469 for (
int i = 0; i < num_clients; ++i) {
470 auto file = create_test_dicom(
472 "client_" + std::to_string(i) +
".dcm",
474 files.push_back(file);
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));
489 for (
size_t i = 0; i < futures.size(); ++i) {
490 auto result = futures[i].get();
492 INFO(
"Client " << i <<
" stdout: " << result.stdout_output);
493 INFO(
"Client " << i <<
" stderr: " << result.stderr_output);
495 REQUIRE(result.success());
499 REQUIRE(server.stored_count() >=
static_cast<size_t>(num_clients));
507TEST_CASE(
"C-STORE: Error handling",
"[dcmtk][interop][store][error]") {
509 SKIP(
"DCMTK not installed");
514 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
517 SECTION(
"storescu to non-existent server fails gracefully") {
523 auto test_file = create_test_dicom(input_dir.
path(),
"test.dcm",
"CT");
526 "localhost", port,
"NONEXISTENT", {test_file},
527 "STORESCU", std::chrono::seconds{5});
529 REQUIRE_FALSE(result.success());
532 SECTION(
"pacs_system SCU to non-existent server fails gracefully") {
537 std::this_thread::sleep_for(std::chrono::milliseconds{100});
541 SKIP(
"Port " + std::to_string(port) +
" is unexpectedly in use");
546 "localhost", port,
"NONEXISTENT",
"PACS_SCU",
547 {
"1.2.840.10008.5.1.4.1.1.2"});
549 REQUIRE_FALSE(connect_result.is_ok());
557TEST_CASE(
"C-STORE: Data integrity verification",
"[dcmtk][interop][store][integrity]") {
559 SKIP(
"DCMTK not installed");
564 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
571 storage_test_server server(port,
"INTEGRITY_SCP");
572 REQUIRE(server.start());
574 SECTION(
"Patient demographics preserved") {
577 ds.
set_string(tags::patient_name, vr_type::PN,
"INTEGRITY^TEST^PATIENT");
578 ds.
set_string(tags::patient_id, vr_type::LO,
"INTEG001");
580 auto test_file = input_dir.
path() /
"integrity_test.dcm";
581 auto file = dicom_file::create(
583 transfer_syntax::explicit_vr_little_endian);
584 auto write_result = file.save(test_file);
585 REQUIRE(write_result.is_ok());
589 "localhost", port,
"INTEGRITY_SCP", {test_file});
590 REQUIRE(result.success());
593 auto stored_files = server.stored_files();
594 REQUIRE(stored_files.size() >= 1);
596 auto stored_result = dicom_file::open(stored_files[0]);
597 REQUIRE(stored_result.is_ok());
599 auto& stored_ds = stored_result.value().dataset();
601 auto stored_name = stored_ds.get_string(tags::patient_name);
602 auto stored_id = stored_ds.get_string(tags::patient_id);
604 REQUIRE_FALSE(stored_name.empty());
605 REQUIRE_FALSE(stored_id.empty());
606 REQUIRE(stored_name ==
"INTEGRITY^TEST^PATIENT");
607 REQUIRE(stored_id ==
"INTEG001");
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_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.
DICOM Part 10 file handling for reading/writing DICOM files.
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.
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.