PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
test_error_recovery.cpp
Go to the documentation of this file.
1
15#include "test_fixtures.h"
16
17#include <catch2/catch_test_macros.hpp>
18
25
26#include <atomic>
27#include <thread>
28
29using namespace kcenon::pacs::integration_test;
30using namespace kcenon::pacs::network;
31using namespace kcenon::pacs::services;
32using namespace kcenon::pacs::storage;
33using namespace kcenon::pacs::core;
34using namespace kcenon::pacs::encoding;
35using namespace kcenon::pacs::network::dimse;
36
37// =============================================================================
38// Helper: Configurable Error Server
39// =============================================================================
40
41namespace {
42
46class error_test_server {
47public:
48 explicit error_test_server(uint16_t port, const std::string& ae_title = "ERROR_SCP")
49 : port_(port)
50 , ae_title_(ae_title)
51 , test_dir_("error_test_")
52 , storage_dir_(test_dir_.path() / "archive")
53 , db_path_(test_dir_.path() / "index.db") {
54
55 std::filesystem::create_directories(storage_dir_);
56
57 server_config config;
58 config.ae_title = ae_title_;
59 config.port = port_;
60 config.max_associations = 20;
61 config.idle_timeout = std::chrono::seconds{60};
62 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.20";
63 config.implementation_version_name = "ERROR_SCP";
64
65 server_ = std::make_unique<dicom_server>(config);
66
67 file_storage_config fs_conf;
68 fs_conf.root_path = storage_dir_;
69 file_storage_ = std::make_unique<file_storage>(fs_conf);
70
71 auto db_result = index_database::open(db_path_.string());
72 if (db_result.is_ok()) {
73 database_ = std::move(db_result.value());
74 } else {
75 throw std::runtime_error("Failed to open database");
76 }
77}
78
79 bool initialize() {
80 server_->register_service(std::make_shared<verification_scp>());
81
82 auto storage_scp_ptr = std::make_shared<storage_scp>();
83 storage_scp_ptr->set_handler([this](
84 const dicom_dataset& dataset,
85 const std::string& calling_ae,
86 const std::string& sop_class_uid,
87 const std::string& sop_instance_uid) {
88
89 return handle_store(dataset, calling_ae, sop_class_uid, sop_instance_uid);
90 });
91 server_->register_service(storage_scp_ptr);
92
93 return true;
94 }
95
96 bool start() {
97 auto result = server_->start();
98 if (result.is_ok()) {
99 std::this_thread::sleep_for(std::chrono::milliseconds{100});
100 return true;
101 }
102 return false;
103 }
104
105 void stop() {
106 server_->stop();
107 }
108
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(); }
113
114 // Error injection controls
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; }
118
119 void add_accepted_sop_class(const std::string& sop_class) {
120 accepted_sop_classes_.push_back(sop_class);
121 }
122
123private:
124 storage_status handle_store(
125 const dicom_dataset& dataset,
126 const std::string& /* calling_ae */,
127 const std::string& sop_class_uid,
128 const std::string& sop_instance_uid) {
129
130 // Simulate processing delay
131 if (simulate_delay_.count() > 0) {
132 std::this_thread::sleep_for(simulate_delay_);
133 }
134
135 // Reject all requests
136 if (reject_all_) {
137 ++rejected_count_;
138 return storage_status::out_of_resources;
139 }
140
141 // Reject specific SOP class
142 if (!reject_sop_class_.empty() && sop_class_uid == reject_sop_class_) {
143 ++rejected_count_;
144 return storage_status::data_set_does_not_match_sop_class;
145 }
146
147 // Check accepted SOP classes
148 if (!accepted_sop_classes_.empty()) {
149 bool found = false;
150 for (const auto& accepted : accepted_sop_classes_) {
151 if (sop_class_uid == accepted) {
152 found = true;
153 break;
154 }
155 }
156 if (!found) {
157 ++rejected_count_;
158 return storage_status::data_set_does_not_match_sop_class;
159 }
160 }
161
162 // Normal processing
163 auto store_result = file_storage_->store(dataset);
164 if (store_result.is_err()) {
165 ++rejected_count_;
166 return storage_status::storage_error;
167 }
168
169 // 1. Upsert Patient
170 auto patient_res = database_->upsert_patient(
171 dataset.get_string(tags::patient_id),
172 dataset.get_string(tags::patient_name),
173 dataset.get_string(tags::patient_birth_date),
174 dataset.get_string(tags::patient_sex));
175 if (patient_res.is_err()) {
176 ++rejected_count_;
177 return storage_status::storage_error;
178 }
179 int64_t patient_pk = patient_res.value();
180
181 // 2. Upsert Study
182 auto study_res = database_->upsert_study(
183 patient_pk,
184 dataset.get_string(tags::study_instance_uid),
185 dataset.get_string(tags::study_id),
186 dataset.get_string(tags::study_date),
187 dataset.get_string(tags::study_time),
188 dataset.get_string(tags::accession_number));
189 if (study_res.is_err()) {
190 ++rejected_count_;
191 return storage_status::storage_error;
192 }
193 int64_t study_pk = study_res.value();
194
195 // 3. Upsert Series
196 std::optional<int> series_number;
197 try {
198 std::string sn = dataset.get_string(tags::series_number);
199 if (!sn.empty()) series_number = std::stoi(sn);
200 } catch (...) {}
201
202 auto series_res = database_->upsert_series(
203 study_pk,
204 dataset.get_string(tags::series_instance_uid),
205 dataset.get_string(tags::modality),
206 series_number);
207 if (series_res.is_err()) {
208 ++rejected_count_;
209 return storage_status::storage_error;
210 }
211 int64_t series_pk = series_res.value();
212
213 // 4. Upsert Instance
214 auto file_path = file_storage_->get_file_path(sop_instance_uid);
215 std::optional<int> instance_number;
216 try {
217 std::string in = dataset.get_string(tags::instance_number);
218 if (!in.empty()) instance_number = std::stoi(in);
219 } catch (...) {}
220
221 auto instance_res = database_->upsert_instance(
222 series_pk,
223 sop_instance_uid,
224 sop_class_uid,
225 file_path.string(),
226 static_cast<int64_t>(std::filesystem::file_size(file_path)),
227 "",
228 instance_number);
229
230 if (instance_res.is_err()) {
231 ++rejected_count_;
232 return storage_status::storage_error;
233 }
234
235 ++stored_count_;
236 return storage_status::success;
237}
238
239 uint16_t port_;
240 std::string ae_title_;
241 test_directory test_dir_;
242 std::filesystem::path storage_dir_;
243 std::filesystem::path db_path_;
244
245 std::unique_ptr<dicom_server> server_;
246 std::unique_ptr<file_storage> file_storage_;
247 std::unique_ptr<index_database> database_;
248
249 std::atomic<size_t> stored_count_{0};
250 std::atomic<size_t> rejected_count_{0};
251
252 // Error injection
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_;
257};
258
259} // namespace
260
261// =============================================================================
262// Scenario 5: Error Recovery Tests
263// =============================================================================
264
265TEST_CASE("Invalid SOP Class rejection", "[error][sop_class]") {
266 auto port = find_available_port();
267 error_test_server server(port, "ERROR_SCP");
268 server.add_accepted_sop_class("1.2.840.10008.5.1.4.1.1.2"); // Only CT
269
270 REQUIRE(server.initialize());
271 REQUIRE(server.start());
272
273 // Try to store MR image (not in accepted list)
274 association_config config;
275 config.calling_ae_title = "ERROR_SCU";
276 config.called_ae_title = server.ae_title();
277 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.21";
278 config.proposed_contexts.push_back({
279 1,
280 "1.2.840.10008.5.1.4.1.1.4", // MR Image Storage
281 {"1.2.840.10008.1.2.1"}
282 });
283
284 auto connect_result = association::connect(
285 "localhost", port, config, default_timeout());
286 REQUIRE(connect_result.is_ok());
287
288 auto& assoc = connect_result.value();
289
290 // MR context should be rejected at association level or store level
291 auto mr_context = assoc.accepted_context_id("1.2.840.10008.5.1.4.1.1.4");
292
293 if (mr_context) {
294 // If context was accepted, store should fail
295 storage_scu scu;
296 auto mr_dataset = generate_mr_dataset();
297 auto result = scu.store(assoc, mr_dataset);
298
299 if (result.is_ok()) {
300 // Server should reject with SOP class not supported
301 REQUIRE_FALSE(result.value().is_success());
302 }
303 }
304 // If context was not accepted, that's also valid behavior
305
306 (void)assoc.release(default_timeout());
307
308 // Verify server rejected the request
309 REQUIRE(server.stored_count() == 0);
310
311 server.stop();
312}
313
314TEST_CASE("Server rejection of all stores", "[error][rejection]") {
315 auto port = find_available_port();
316 error_test_server server(port, "ERROR_SCP");
317 server.set_reject_all(true);
318
319 REQUIRE(server.initialize());
320 REQUIRE(server.start());
321
322 association_config config;
323 config.calling_ae_title = "ERROR_SCU";
324 config.called_ae_title = server.ae_title();
325 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.22";
326 config.proposed_contexts.push_back({
327 1,
328 "1.2.840.10008.5.1.4.1.1.2",
329 {"1.2.840.10008.1.2.1"}
330 });
331
332 auto connect_result = association::connect(
333 "localhost", port, config, default_timeout());
334 REQUIRE(connect_result.is_ok());
335
336 auto& assoc = connect_result.value();
337 storage_scu scu;
338
339 auto dataset = generate_ct_dataset();
340 auto result = scu.store(assoc, dataset);
341
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));
345
346 (void)assoc.release(default_timeout());
347
348 REQUIRE(server.stored_count() == 0);
349 REQUIRE(server.rejected_count() == 1);
350
351 server.stop();
352}
353
354TEST_CASE("Connection to offline server and retry", "[error][retry]") {
355 auto port = find_available_port();
356
357 // First, try to connect when server is not running
358 auto connect_result = test_association::connect(
359 "localhost",
360 port,
361 "OFFLINE_SCP",
362 "RETRY_SCU",
363 {std::string(verification_sop_class_uid)}
364 );
365
366 REQUIRE(connect_result.is_err());
367
368 // Now start the server
369 error_test_server server(port, "OFFLINE_SCP");
370 REQUIRE(server.initialize());
371 REQUIRE(server.start());
372
373 // Retry connection - should succeed now
374 auto retry_result = test_association::connect(
375 "localhost",
376 port,
377 server.ae_title(),
378 "RETRY_SCU",
379 {std::string(verification_sop_class_uid)}
380 );
381
382 REQUIRE(retry_result.is_ok());
383 (void)retry_result.value().release(default_timeout());
384
385 server.stop();
386}
387
388TEST_CASE("Server restart during operations", "[error][restart]") {
389 auto port = find_available_port();
390 error_test_server server(port, "RESTART_SCP");
391
392 REQUIRE(server.initialize());
393 REQUIRE(server.start());
394
395 // Store some files first
396 {
397 association_config config;
398 config.calling_ae_title = "PRE_RESTART";
399 config.called_ae_title = server.ae_title();
400 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.23";
401 config.proposed_contexts.push_back({
402 1,
403 "1.2.840.10008.5.1.4.1.1.2",
404 {"1.2.840.10008.1.2.1"}
405 });
406
407 auto connect = association::connect("localhost", port, config, default_timeout());
408 REQUIRE(connect.is_ok());
409
410 storage_scu scu;
411 auto ds = generate_ct_dataset();
412 auto result = scu.store(connect.value(), ds);
413 REQUIRE(result.is_ok());
414 REQUIRE(result.value().is_success());
415
416 (void)connect.value().release(default_timeout());
417 }
418
419 REQUIRE(server.stored_count() == 1);
420
421 // Stop the server
422 server.stop();
423
424 // Try to connect - should fail
425 auto offline_connect = test_association::connect(
426 "localhost", port, "RESTART_SCP", "POST_STOP",
427 {"1.2.840.10008.5.1.4.1.1.2"});
428 REQUIRE(offline_connect.is_err());
429
430 // Note: For a true restart test, we'd need to create a new server
431 // since the database/storage is tied to the test_directory lifetime
432 // Here we demonstrate the connection failure when server is stopped
433
434 // Create new server on same port
435 error_test_server new_server(port, "RESTART_SCP");
436 REQUIRE(new_server.initialize());
437 REQUIRE(new_server.start());
438
439 // Retry connection - should succeed
440 auto retry_connect = test_association::connect(
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());
444 (void)retry_connect.value().release(default_timeout());
445
446 new_server.stop();
447}
448
449TEST_CASE("Timeout during slow processing", "[error][timeout]") {
450 auto port = find_available_port();
451 error_test_server server(port, "SLOW_SCP");
452 server.set_simulate_delay(std::chrono::milliseconds{2000}); // 2 second delay
453
454 REQUIRE(server.initialize());
455 REQUIRE(server.start());
456
457 association_config config;
458 config.calling_ae_title = "TIMEOUT_SCU";
459 config.called_ae_title = server.ae_title();
460 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.24";
461 config.proposed_contexts.push_back({
462 1,
463 "1.2.840.10008.5.1.4.1.1.2",
464 {"1.2.840.10008.1.2.1"}
465 });
466
467 auto connect_result = association::connect(
468 "localhost", port, config, default_timeout());
469 REQUIRE(connect_result.is_ok());
470
471 auto& assoc = connect_result.value();
472
473 // Use very short timeout
474 storage_scu_config scu_config;
475 scu_config.response_timeout = std::chrono::milliseconds{500}; // Very short
476 storage_scu scu{scu_config};
477
478 auto dataset = generate_ct_dataset();
479
480 // This may timeout or succeed depending on timing
481 auto result = scu.store(assoc, dataset);
482
483 // Either timeout error or slow success is acceptable
484 // The key is that the system handles it gracefully without crashing
485 if (result.is_err()) {
486 // Timeout is expected behavior
487 INFO("Store timed out as expected");
488 } else {
489 // If it succeeded, verify the result
490 INFO("Store completed despite slow processing");
491 }
492
493 // Abort the association since we might have a timeout situation
494 assoc.abort();
495
496 server.stop();
497}
498
499TEST_CASE("Association abort handling", "[error][abort]") {
500 auto port = find_available_port();
501 error_test_server server(port, "ABORT_SCP");
502
503 REQUIRE(server.initialize());
504 REQUIRE(server.start());
505
506 association_config config;
507 config.calling_ae_title = "ABORT_SCU";
508 config.called_ae_title = server.ae_title();
509 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.25";
510 config.proposed_contexts.push_back({
511 1,
512 std::string(verification_sop_class_uid),
513 {"1.2.840.10008.1.2.1"}
514 });
515
516 auto connect_result = association::connect(
517 "localhost", port, config, default_timeout());
518 REQUIRE(connect_result.is_ok());
519
520 auto& assoc = connect_result.value();
521
522 // Abort instead of graceful release
523 assoc.abort();
524
525 // Server should handle the abort gracefully
526 // New connections should still work
527 auto new_connect = test_association::connect(
528 "localhost", port, server.ae_title(), "AFTER_ABORT",
529 {std::string(verification_sop_class_uid)});
530 REQUIRE(new_connect.is_ok());
531 (void)new_connect.value().release(default_timeout());
532
533 server.stop();
534}
535
536TEST_CASE("Multiple rapid aborts", "[error][rapid_abort]") {
537 auto port = find_available_port();
538 error_test_server server(port, "RAPID_ABORT_SCP");
539
540 REQUIRE(server.initialize());
541 REQUIRE(server.start());
542
543 constexpr int num_aborts = 10;
544
545 for (int i = 0; i < num_aborts; ++i) {
546 association_config config;
547 config.calling_ae_title = "ABORT_" + std::to_string(i);
548 config.called_ae_title = server.ae_title();
549 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.26";
550 config.proposed_contexts.push_back({
551 1,
552 std::string(verification_sop_class_uid),
553 {"1.2.840.10008.1.2.1"}
554 });
555
556 auto connect = association::connect(
557 "localhost", port, config, default_timeout());
558
559 if (connect.is_ok()) {
560 connect.value().abort();
561 }
562 }
563
564 // Server should still be operational
565 auto final_connect = test_association::connect(
566 "localhost", port, server.ae_title(), "FINAL_CHECK",
567 {std::string(verification_sop_class_uid)});
568 REQUIRE(final_connect.is_ok());
569
570 // Send an echo to verify server is responsive
571 auto& assoc = final_connect.value();
572 auto ctx = assoc.accepted_context_id(verification_sop_class_uid);
573 REQUIRE(ctx.has_value());
574
576 REQUIRE(assoc.send_dimse(*ctx, echo_rq).is_ok());
577
578 auto recv = assoc.receive_dimse(default_timeout());
579 REQUIRE(recv.is_ok());
580 REQUIRE(recv.value().second.status() == status_success);
581
582 (void)assoc.release(default_timeout());
583 server.stop();
584}
585
586TEST_CASE("Duplicate SOP Instance handling", "[error][duplicate]") {
587 auto port = find_available_port();
588 error_test_server server(port, "DUP_SCP");
589
590 REQUIRE(server.initialize());
591 REQUIRE(server.start());
592
593 association_config config;
594 config.calling_ae_title = "DUP_SCU";
595 config.called_ae_title = server.ae_title();
596 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.27";
597 config.proposed_contexts.push_back({
598 1,
599 "1.2.840.10008.5.1.4.1.1.2",
600 {"1.2.840.10008.1.2.1"}
601 });
602
603 auto connect_result = association::connect(
604 "localhost", port, config, default_timeout());
605 REQUIRE(connect_result.is_ok());
606
607 auto& assoc = connect_result.value();
608 storage_scu scu;
609
610 // Create dataset with fixed SOP Instance UID
611 auto sop_instance_uid = generate_uid();
612 auto dataset = generate_ct_dataset();
613 dataset.set_string(tags::sop_instance_uid, vr_type::UI, sop_instance_uid);
614
615 // First store should succeed
616 auto result1 = scu.store(assoc, dataset);
617 REQUIRE(result1.is_ok());
618 REQUIRE(result1.value().is_success());
619
620 // Second store with same SOP Instance UID
621 // Behavior depends on server implementation:
622 // - Could overwrite (success)
623 // - Could reject as duplicate (error)
624 // - Could return warning
625 auto result2 = scu.store(assoc, dataset);
626 REQUIRE(result2.is_ok());
627 // Either success (overwrite) or warning (duplicate) is acceptable
628
629 (void)assoc.release(default_timeout());
630 server.stop();
631}
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.
constexpr dicom_tag series_number
Series Number.
constexpr dicom_tag instance_number
Instance Number.
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::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.
Definition storage_scu.h:80
std::chrono::milliseconds response_timeout
Timeout for receiving C-STORE response (milliseconds)
Definition storage_scu.h:85
Result of a C-STORE operation.
Definition storage_scu.h:43
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)