17#include <catch2/catch_test_macros.hpp>
46class error_test_server {
48 explicit error_test_server(uint16_t port,
const std::string& ae_title =
"ERROR_SCP")
51 , test_dir_(
"error_test_")
52 , storage_dir_(test_dir_.path() /
"archive")
53 , db_path_(test_dir_.path() /
"index.db") {
55 std::filesystem::create_directories(storage_dir_);
65 server_ = std::make_unique<dicom_server>(config);
69 file_storage_ = std::make_unique<file_storage>(fs_conf);
71 auto db_result = index_database::open(db_path_.string());
72 if (db_result.is_ok()) {
73 database_ = std::move(db_result.value());
75 throw std::runtime_error(
"Failed to open database");
80 server_->register_service(std::make_shared<verification_scp>());
82 auto storage_scp_ptr = std::make_shared<storage_scp>();
83 storage_scp_ptr->set_handler([
this](
85 const std::string& calling_ae,
86 const std::string& sop_class_uid,
87 const std::string& sop_instance_uid) {
89 return handle_store(dataset, calling_ae, sop_class_uid, sop_instance_uid);
91 server_->register_service(storage_scp_ptr);
97 auto result = server_->start();
99 std::this_thread::sleep_for(std::chrono::milliseconds{100});
109 uint16_t port()
const {
return port_; }
110 const std::string& ae_title()
const {
return ae_title_; }
111 size_t stored_count()
const {
return stored_count_.load(); }
112 size_t rejected_count()
const {
return rejected_count_.load(); }
115 void set_reject_all(
bool reject) { reject_all_ =
reject; }
116 void set_reject_sop_class(
const std::string& sop_class) { reject_sop_class_ = sop_class; }
117 void set_simulate_delay(std::chrono::milliseconds delay) { simulate_delay_ = delay; }
119 void add_accepted_sop_class(
const std::string& sop_class) {
120 accepted_sop_classes_.push_back(sop_class);
127 const std::string& sop_class_uid,
128 const std::string& sop_instance_uid) {
131 if (simulate_delay_.count() > 0) {
132 std::this_thread::sleep_for(simulate_delay_);
138 return storage_status::out_of_resources;
142 if (!reject_sop_class_.empty() && sop_class_uid == reject_sop_class_) {
144 return storage_status::data_set_does_not_match_sop_class;
148 if (!accepted_sop_classes_.empty()) {
150 for (
const auto& accepted : accepted_sop_classes_) {
151 if (sop_class_uid == accepted) {
158 return storage_status::data_set_does_not_match_sop_class;
166 return storage_status::storage_error;
170 auto patient_res = database_->upsert_patient(
175 if (patient_res.is_err()) {
177 return storage_status::storage_error;
179 int64_t patient_pk = patient_res.value();
182 auto study_res = database_->upsert_study(
189 if (study_res.is_err()) {
191 return storage_status::storage_error;
193 int64_t study_pk = study_res.value();
198 std::string sn = dataset.
get_string(tags::series_number);
202 auto series_res = database_->upsert_series(
204 dataset.
get_string(tags::series_instance_uid),
207 if (series_res.is_err()) {
209 return storage_status::storage_error;
211 int64_t series_pk = series_res.value();
214 auto file_path = file_storage_->get_file_path(sop_instance_uid);
217 std::string in = dataset.
get_string(tags::instance_number);
221 auto instance_res = database_->upsert_instance(
226 static_cast<int64_t
>(std::filesystem::file_size(file_path)),
230 if (instance_res.is_err()) {
232 return storage_status::storage_error;
236 return storage_status::success;
240 std::string ae_title_;
242 std::filesystem::path storage_dir_;
243 std::filesystem::path db_path_;
245 std::unique_ptr<dicom_server> server_;
246 std::unique_ptr<file_storage> file_storage_;
247 std::unique_ptr<index_database> database_;
249 std::atomic<size_t> stored_count_{0};
250 std::atomic<size_t> rejected_count_{0};
253 bool reject_all_{
false};
254 std::string reject_sop_class_;
255 std::chrono::milliseconds simulate_delay_{0};
256 std::vector<std::string> accepted_sop_classes_;
265TEST_CASE(
"Invalid SOP Class rejection",
"[error][sop_class]") {
267 error_test_server server(port,
"ERROR_SCP");
268 server.add_accepted_sop_class(
"1.2.840.10008.5.1.4.1.1.2");
270 REQUIRE(server.initialize());
271 REQUIRE(server.start());
280 "1.2.840.10008.5.1.4.1.1.4",
281 {
"1.2.840.10008.1.2.1"}
284 auto connect_result = association::connect(
286 REQUIRE(connect_result.is_ok());
288 auto& assoc = connect_result.value();
291 auto mr_context = assoc.accepted_context_id(
"1.2.840.10008.5.1.4.1.1.4");
297 auto result = scu.
store(assoc, mr_dataset);
299 if (result.is_ok()) {
301 REQUIRE_FALSE(result.value().is_success());
309 REQUIRE(server.stored_count() == 0);
314TEST_CASE(
"Server rejection of all stores",
"[error][rejection]") {
316 error_test_server server(port,
"ERROR_SCP");
317 server.set_reject_all(
true);
319 REQUIRE(server.initialize());
320 REQUIRE(server.start());
328 "1.2.840.10008.5.1.4.1.1.2",
329 {
"1.2.840.10008.1.2.1"}
332 auto connect_result = association::connect(
334 REQUIRE(connect_result.is_ok());
336 auto& assoc = connect_result.value();
340 auto result = scu.
store(assoc, dataset);
342 REQUIRE(result.is_ok());
343 REQUIRE_FALSE(result.value().is_success());
344 REQUIRE(result.value().status ==
static_cast<uint16_t
>(storage_status::out_of_resources));
348 REQUIRE(server.stored_count() == 0);
349 REQUIRE(server.rejected_count() == 1);
354TEST_CASE(
"Connection to offline server and retry",
"[error][retry]") {
366 REQUIRE(connect_result.is_err());
369 error_test_server server(port,
"OFFLINE_SCP");
370 REQUIRE(server.initialize());
371 REQUIRE(server.start());
379 {std::string(verification_sop_class_uid)}
382 REQUIRE(retry_result.is_ok());
388TEST_CASE(
"Server restart during operations",
"[error][restart]") {
390 error_test_server server(port,
"RESTART_SCP");
392 REQUIRE(server.initialize());
393 REQUIRE(server.start());
403 "1.2.840.10008.5.1.4.1.1.2",
404 {
"1.2.840.10008.1.2.1"}
407 auto connect = association::connect(
"localhost", port, config,
default_timeout());
408 REQUIRE(connect.is_ok());
412 auto result = scu.
store(connect.value(), ds);
413 REQUIRE(result.is_ok());
414 REQUIRE(result.value().is_success());
419 REQUIRE(server.stored_count() == 1);
426 "localhost", port,
"RESTART_SCP",
"POST_STOP",
427 {
"1.2.840.10008.5.1.4.1.1.2"});
428 REQUIRE(offline_connect.is_err());
435 error_test_server new_server(port,
"RESTART_SCP");
436 REQUIRE(new_server.initialize());
437 REQUIRE(new_server.start());
441 "localhost", port, new_server.ae_title(),
"POST_RESTART",
442 {
"1.2.840.10008.5.1.4.1.1.2"});
443 REQUIRE(retry_connect.is_ok());
449TEST_CASE(
"Timeout during slow processing",
"[error][timeout]") {
451 error_test_server server(port,
"SLOW_SCP");
452 server.set_simulate_delay(std::chrono::milliseconds{2000});
454 REQUIRE(server.initialize());
455 REQUIRE(server.start());
463 "1.2.840.10008.5.1.4.1.1.2",
464 {
"1.2.840.10008.1.2.1"}
467 auto connect_result = association::connect(
469 REQUIRE(connect_result.is_ok());
471 auto& assoc = connect_result.value();
481 auto result = scu.store(assoc, dataset);
485 if (result.is_err()) {
487 INFO(
"Store timed out as expected");
490 INFO(
"Store completed despite slow processing");
499TEST_CASE(
"Association abort handling",
"[error][abort]") {
501 error_test_server server(port,
"ABORT_SCP");
503 REQUIRE(server.initialize());
504 REQUIRE(server.start());
513 {
"1.2.840.10008.1.2.1"}
516 auto connect_result = association::connect(
518 REQUIRE(connect_result.is_ok());
520 auto& assoc = connect_result.value();
528 "localhost", port, server.ae_title(),
"AFTER_ABORT",
529 {std::string(verification_sop_class_uid)});
530 REQUIRE(new_connect.is_ok());
536TEST_CASE(
"Multiple rapid aborts",
"[error][rapid_abort]") {
538 error_test_server server(port,
"RAPID_ABORT_SCP");
540 REQUIRE(server.initialize());
541 REQUIRE(server.start());
543 constexpr int num_aborts = 10;
545 for (
int i = 0; i < num_aborts; ++i) {
553 {
"1.2.840.10008.1.2.1"}
556 auto connect = association::connect(
559 if (connect.is_ok()) {
560 connect.value().abort();
566 "localhost", port, server.ae_title(),
"FINAL_CHECK",
567 {std::string(verification_sop_class_uid)});
568 REQUIRE(final_connect.is_ok());
571 auto& assoc = final_connect.value();
573 REQUIRE(ctx.has_value());
576 REQUIRE(assoc.send_dimse(*ctx, echo_rq).is_ok());
579 REQUIRE(recv.is_ok());
580 REQUIRE(recv.value().second.status() == status_success);
586TEST_CASE(
"Duplicate SOP Instance handling",
"[error][duplicate]") {
588 error_test_server server(port,
"DUP_SCP");
590 REQUIRE(server.initialize());
591 REQUIRE(server.start());
599 "1.2.840.10008.5.1.4.1.1.2",
600 {
"1.2.840.10008.1.2.1"}
603 auto connect_result = association::connect(
605 REQUIRE(connect_result.is_ok());
607 auto& assoc = connect_result.value();
613 dataset.
set_string(tags::sop_instance_uid, vr_type::UI, sop_instance_uid);
616 auto result1 = scu.
store(assoc, dataset);
617 REQUIRE(result1.is_ok());
618 REQUIRE(result1.value().is_success());
625 auto result2 = scu.
store(assoc, dataset);
626 REQUIRE(result2.is_ok());
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.
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.
network::Result< store_result > store(network::association &assoc, const core::dicom_dataset &dataset)
Store a single DICOM dataset.
DIMSE message encoding and decoding.
Filesystem-based DICOM storage with hierarchical organization.
PACS index database for metadata storage and retrieval.
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.
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 default_timeout()
Default timeout for test operations (5s normal, 30s CI)
TEST_CASE("test_data_generator::ct generates valid CT dataset", "[data_generator][ct]")
std::string generate_uid(const std::string &root="1.2.826.0.1.3680043.9.9999")
Generate a unique UID for testing.
auto make_c_echo_rq(uint16_t message_id, std::string_view sop_class_uid="1.2.840.10008.1.1") -> dimse_message
Create a C-ECHO request message.
storage_status
Storage operation status codes.
@ reject
Reject duplicates with error status.
constexpr std::string_view verification_sop_class_uid
Verification SOP Class UID (1.2.840.10008.1.1)
DICOM Storage SCP service (C-STORE handler)
DICOM Storage SCU service (C-STORE sender)
Configuration for SCU association request.
std::string called_ae_title
Remote AE Title (16 chars max)
std::string calling_ae_title
Our AE Title (16 chars max)
std::string implementation_class_uid
std::vector< proposed_presentation_context > proposed_contexts
size_t max_associations
Maximum concurrent associations (0 = unlimited)
std::chrono::seconds idle_timeout
Idle timeout for associations (0 = no timeout)
std::string implementation_version_name
Implementation Version Name.
uint16_t port
Port to listen on (default: 11112, standard alternate DICOM port)
std::string ae_title
Application Entity Title for this server (16 chars max)
std::string implementation_class_uid
Implementation Class UID.
Configuration for Storage SCU service.
std::chrono::milliseconds response_timeout
Timeout for receiving C-STORE response (milliseconds)
Result of a C-STORE operation.
Configuration for file_storage.
std::filesystem::path root_path
Root directory for storage.
Common test fixtures and utilities for integration tests.
DICOM Verification SCP service (C-ECHO handler)