PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
test_dicom_server_v2_integration.cpp
Go to the documentation of this file.
1
19#include "test_fixtures.h"
20
21#include <catch2/catch_test_macros.hpp>
22#include <catch2/matchers/catch_matchers_string.hpp>
23
32
33#include <atomic>
34#include <chrono>
35#include <future>
36#include <latch>
37#include <mutex>
38#include <thread>
39#include <vector>
40
41using namespace kcenon::pacs::integration_test;
42using namespace kcenon::pacs::network;
43using namespace kcenon::pacs::network::v2;
44using namespace kcenon::pacs::network::dimse;
45using namespace kcenon::pacs::services;
46using namespace kcenon::pacs::storage;
47using namespace kcenon::pacs::core;
48using namespace kcenon::pacs::encoding;
49
50// =============================================================================
51// Helper: V2 Test Server
52// =============================================================================
53
54namespace {
55
62class test_server_v2 {
63public:
64 explicit test_server_v2(
65 uint16_t port = 0,
66 const std::string& ae_title = "TEST_SCP_V2")
67 : port_(port == 0 ? find_available_port() : port)
68 , ae_title_(ae_title) {
69
70 server_config config;
71 config.ae_title = ae_title_;
72 config.port = port_;
73 config.max_associations = 50;
74 config.idle_timeout = std::chrono::seconds{60};
75 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.100";
76 config.implementation_version_name = "TEST_SCP_V2";
77
78 server_ = std::make_unique<dicom_server_v2>(config);
79 }
80
81 ~test_server_v2() {
82 stop();
83 }
84
85 test_server_v2(const test_server_v2&) = delete;
86 test_server_v2& operator=(const test_server_v2&) = delete;
87 test_server_v2(test_server_v2&&) = delete;
88 test_server_v2& operator=(test_server_v2&&) = delete;
89
90 template <typename Service>
91 void register_service(std::shared_ptr<Service> service) {
92 server_->register_service(std::move(service));
93 }
94
95 [[nodiscard]] bool start() {
96 auto result = server_->start();
97 if (result.is_ok()) {
98 running_ = true;
99 std::this_thread::sleep_for(std::chrono::milliseconds{100});
100 }
101 return result.is_ok();
102 }
103
104 void stop() {
105 if (running_) {
106 server_->stop();
107 running_ = false;
108 }
109 }
110
111 [[nodiscard]] uint16_t port() const noexcept { return port_; }
112 [[nodiscard]] const std::string& ae_title() const noexcept { return ae_title_; }
113 [[nodiscard]] bool is_running() const noexcept { return running_; }
114 [[nodiscard]] dicom_server_v2& server() { return *server_; }
115
116 [[nodiscard]] server_statistics get_statistics() const {
117 return server_->get_statistics();
118 }
119
120private:
121 uint16_t port_;
122 std::string ae_title_;
123 std::unique_ptr<dicom_server_v2> server_;
124 bool running_{false};
125};
126
132class stress_test_server_v2 {
133public:
134 explicit stress_test_server_v2(
135 uint16_t port,
136 const std::string& ae_title = "STRESS_V2")
137 : port_(port)
138 , ae_title_(ae_title)
139 , test_dir_("stress_test_v2_")
140 , storage_dir_(test_dir_.path() / "archive")
141 , db_path_(test_dir_.path() / "index.db") {
142
143 std::filesystem::create_directories(storage_dir_);
144
145 server_config config;
146 config.ae_title = ae_title_;
147 config.port = port_;
148 config.max_associations = 100;
149 config.idle_timeout = std::chrono::seconds{120};
150 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.101";
151 config.implementation_version_name = "STRESS_V2";
152
153 server_ = std::make_unique<dicom_server_v2>(config);
154
155 file_storage_config fs_conf;
156 fs_conf.root_path = storage_dir_;
157 file_storage_ = std::make_unique<file_storage>(fs_conf);
158
159 auto db_result = index_database::open(db_path_.string());
160 if (db_result.is_ok()) {
161 database_ = std::move(db_result.value());
162 } else {
163 throw std::runtime_error("Failed to open database: " + db_result.error().message);
164 }
165 }
166
167 bool initialize() {
168 server_->register_service(std::make_shared<verification_scp>());
169
170 auto storage_scp_ptr = std::make_shared<storage_scp>();
171 storage_scp_ptr->set_handler([this](
172 const dicom_dataset& dataset,
173 const std::string& calling_ae,
174 const std::string& sop_class_uid,
175 const std::string& sop_instance_uid) {
176 return handle_store(dataset, calling_ae, sop_class_uid, sop_instance_uid);
177 });
178 server_->register_service(storage_scp_ptr);
179 return true;
180 }
181
182 bool start() {
183 auto result = server_->start();
184 if (result.is_ok()) {
185 std::this_thread::sleep_for(std::chrono::milliseconds{100});
186 return true;
187 }
188 return false;
189 }
190
191 void stop() {
192 server_->stop();
193 }
194
195 uint16_t port() const { return port_; }
196 const std::string& ae_title() const { return ae_title_; }
197 size_t stored_count() const { return stored_count_.load(); }
198 size_t failed_count() const { return failed_count_.load(); }
199
200 server_statistics get_statistics() const {
201 return server_->get_statistics();
202 }
203
204private:
205 storage_status handle_store(
206 const dicom_dataset& dataset,
207 const std::string& /* calling_ae */,
208 const std::string& /* sop_class_uid */,
209 const std::string& sop_instance_uid) {
210
211 auto store_result = file_storage_->store(dataset);
212 if (store_result.is_err()) {
213 ++failed_count_;
214 return storage_status::storage_error;
215 }
216
217 // Index in database
218 auto patient_res = database_->upsert_patient(
219 dataset.get_string(tags::patient_id),
220 dataset.get_string(tags::patient_name),
221 dataset.get_string(tags::patient_birth_date),
222 dataset.get_string(tags::patient_sex));
223
224 if (patient_res.is_err()) {
225 ++failed_count_;
226 return storage_status::storage_error;
227 }
228 int64_t patient_pk = patient_res.value();
229
230 auto study_res = database_->upsert_study(
231 patient_pk,
232 dataset.get_string(tags::study_instance_uid),
233 dataset.get_string(tags::study_id),
234 dataset.get_string(tags::study_date),
235 dataset.get_string(tags::study_time),
236 dataset.get_string(tags::accession_number));
237
238 if (study_res.is_err()) {
239 ++failed_count_;
240 return storage_status::storage_error;
241 }
242 int64_t study_pk = study_res.value();
243
244 std::optional<int> series_number;
245 try {
246 std::string sn = dataset.get_string(tags::series_number);
247 if (!sn.empty()) series_number = std::stoi(sn);
248 } catch (...) {}
249
250 auto series_res = database_->upsert_series(
251 study_pk,
252 dataset.get_string(tags::series_instance_uid),
253 dataset.get_string(tags::modality),
254 series_number);
255
256 if (series_res.is_err()) {
257 ++failed_count_;
258 return storage_status::storage_error;
259 }
260 int64_t series_pk = series_res.value();
261
262 auto file_path = file_storage_->get_file_path(sop_instance_uid);
263 std::optional<int> instance_number;
264 try {
265 std::string in = dataset.get_string(tags::instance_number);
266 if (!in.empty()) instance_number = std::stoi(in);
267 } catch (...) {}
268
269 auto instance_res = database_->upsert_instance(
270 series_pk,
271 sop_instance_uid,
272 dataset.get_string(tags::sop_class_uid),
273 file_path.string(),
274 static_cast<int64_t>(std::filesystem::file_size(file_path)),
275 "",
276 instance_number);
277
278 if (instance_res.is_err()) {
279 ++failed_count_;
280 return storage_status::storage_error;
281 }
282
283 ++stored_count_;
284 return storage_status::success;
285 }
286
287 uint16_t port_;
288 std::string ae_title_;
289 test_directory test_dir_;
290 std::filesystem::path storage_dir_;
291 std::filesystem::path db_path_;
292
293 std::unique_ptr<dicom_server_v2> server_;
294 std::unique_ptr<file_storage> file_storage_;
295 std::unique_ptr<index_database> database_;
296
297 std::atomic<size_t> stored_count_{0};
298 std::atomic<size_t> failed_count_{0};
299};
300
304struct v2_worker_result {
305 size_t success_count{0};
306 size_t failure_count{0};
307 std::chrono::milliseconds duration{0};
308 std::string error_message;
309};
310
311} // namespace
312
313// =============================================================================
314// Scenario 1: Basic DICOM Operations with V2 Server
315// =============================================================================
316
317#ifdef PACS_WITH_NETWORK_SYSTEM
318
319TEST_CASE("dicom_server_v2 C-ECHO integration", "[v2][integration][echo]") {
320 auto port = find_available_port();
321 test_server_v2 server(port, "V2_ECHO_SCP");
322 server.register_service(std::make_shared<verification_scp>());
323
324 REQUIRE(server.start());
325 REQUIRE(server.is_running());
326
327 SECTION("Single C-ECHO succeeds") {
328 auto connect = test_association::connect(
329 "localhost", port, server.ae_title(), "V2_ECHO_SCU",
330 {std::string(verification_sop_class_uid)});
331
332 REQUIRE(connect.is_ok());
333 auto& assoc = connect.value();
334
335 REQUIRE(assoc.has_accepted_context(verification_sop_class_uid));
336
337 auto ctx_opt = assoc.accepted_context_id(verification_sop_class_uid);
338 REQUIRE(ctx_opt.has_value());
339
341 auto send_result = assoc.send_dimse(*ctx_opt, echo_rq);
342 REQUIRE(send_result.is_ok());
343
344 auto recv_result = assoc.receive_dimse(default_timeout());
345 REQUIRE(recv_result.is_ok());
346
347 auto& [recv_ctx, echo_rsp] = recv_result.value();
348 REQUIRE(echo_rsp.command() == command_field::c_echo_rsp);
349 REQUIRE(echo_rsp.status() == status_success);
350
351 (void)assoc.release(default_timeout());
352 }
353
354 SECTION("Multiple sequential C-ECHO operations") {
355 constexpr int num_echos = 10;
356 int success_count = 0;
357
358 auto connect = test_association::connect(
359 "localhost", port, server.ae_title(), "V2_ECHO_SCU",
360 {std::string(verification_sop_class_uid)});
361 REQUIRE(connect.is_ok());
362 auto& assoc = connect.value();
363
364 auto ctx_opt = assoc.accepted_context_id(verification_sop_class_uid);
365 REQUIRE(ctx_opt.has_value());
366
367 for (int i = 0; i < num_echos; ++i) {
368 auto echo_rq = make_c_echo_rq(static_cast<uint16_t>(i + 1),
370 if (assoc.send_dimse(*ctx_opt, echo_rq).is_ok()) {
371 auto recv = assoc.receive_dimse(default_timeout());
372 if (recv.is_ok() && recv.value().second.status() == status_success) {
373 ++success_count;
374 }
375 }
376 }
377
378 REQUIRE(success_count == num_echos);
379 (void)assoc.release(default_timeout());
380 }
381
382 server.stop();
383
384 auto stats = server.get_statistics();
385 CHECK(stats.total_associations > 0);
386}
387
388TEST_CASE("dicom_server_v2 C-STORE integration", "[v2][integration][store]") {
389 auto port = find_available_port();
390 test_directory test_dir("v2_store_test_");
391
392 server_config config;
393 config.ae_title = "V2_STORE_SCP";
394 config.port = port;
395 config.max_associations = 20;
396
397 dicom_server_v2 server(config);
398
399 file_storage_config fs_conf;
400 fs_conf.root_path = test_dir.path() / "archive";
401 std::filesystem::create_directories(fs_conf.root_path);
402 auto file_storage_ptr = std::make_unique<file_storage>(fs_conf);
403 auto* fs_raw = file_storage_ptr.get();
404
405 std::atomic<int> store_count{0};
406 auto storage_scp_ptr = std::make_shared<storage_scp>();
407 storage_scp_ptr->set_handler([&](
408 const dicom_dataset& dataset,
409 const std::string&,
410 const std::string&,
411 const std::string&) {
412 auto result = fs_raw->store(dataset);
413 if (result.is_ok()) {
414 ++store_count;
415 return storage_status::success;
416 }
417 return storage_status::storage_error;
418 });
419
420 server.register_service(storage_scp_ptr);
421 server.register_service(std::make_shared<verification_scp>());
422
423 auto start_result = server.start();
424 REQUIRE(start_result.is_ok());
425 std::this_thread::sleep_for(std::chrono::milliseconds{100});
426
427 SECTION("Store single CT image") {
428 association_config assoc_config;
429 assoc_config.calling_ae_title = "V2_STORE_SCU";
430 assoc_config.called_ae_title = "V2_STORE_SCP";
431 assoc_config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.102";
432 assoc_config.proposed_contexts.push_back({
433 1,
434 "1.2.840.10008.5.1.4.1.1.2", // CT Image Storage
435 {"1.2.840.10008.1.2.1", "1.2.840.10008.1.2"}
436 });
437
438 auto connect = association::connect(
439 "localhost", port, assoc_config, default_timeout());
440 REQUIRE(connect.is_ok());
441
442 auto& assoc = connect.value();
443 storage_scu_config scu_config;
444 storage_scu scu{scu_config};
445
446 auto dataset = generate_ct_dataset();
447 auto result = scu.store(assoc, dataset);
448 REQUIRE(result.is_ok());
449 REQUIRE(result.value().is_success());
450
451 (void)assoc.release(default_timeout());
452 REQUIRE(store_count == 1);
453 }
454
455 SECTION("Store multiple images in single association") {
456 association_config assoc_config;
457 assoc_config.calling_ae_title = "V2_STORE_SCU";
458 assoc_config.called_ae_title = "V2_STORE_SCP";
459 assoc_config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.103";
460 assoc_config.proposed_contexts.push_back({
461 1,
462 "1.2.840.10008.5.1.4.1.1.2",
463 {"1.2.840.10008.1.2.1"}
464 });
465
466 auto connect = association::connect(
467 "localhost", port, assoc_config, default_timeout());
468 REQUIRE(connect.is_ok());
469
470 auto& assoc = connect.value();
471 storage_scu scu;
472
473 constexpr int num_images = 5;
474 auto study_uid = generate_uid();
475 int success_count = 0;
476
477 for (int i = 0; i < num_images; ++i) {
478 auto dataset = generate_ct_dataset(study_uid);
479 auto result = scu.store(assoc, dataset);
480 if (result.is_ok() && result.value().is_success()) {
481 ++success_count;
482 }
483 }
484
485 REQUIRE(success_count == num_images);
486 (void)assoc.release(default_timeout());
487 }
488
489 server.stop();
490}
491
492// =============================================================================
493// Scenario 2: Stress Testing with V2 Server
494// =============================================================================
495
496TEST_CASE("dicom_server_v2 concurrent storage stress test", "[v2][stress][concurrent]") {
497 auto port = find_available_port();
498 stress_test_server_v2 server(port, "V2_STRESS");
499
500 REQUIRE(server.initialize());
501 REQUIRE(server.start());
502
503 constexpr int num_workers = 10;
504 constexpr int files_per_worker = 5;
505 constexpr int total_expected = num_workers * files_per_worker;
506
507 std::latch start_latch(num_workers + 1);
508 std::vector<std::future<v2_worker_result>> futures;
509
510 for (int i = 0; i < num_workers; ++i) {
511 futures.push_back(std::async(std::launch::async, [&, i]() {
512 v2_worker_result result;
513 auto start_time = std::chrono::steady_clock::now();
514
515 start_latch.arrive_and_wait();
516
517 try {
518 association_config config;
519 config.calling_ae_title = "SCU_" + std::to_string(i);
520 config.called_ae_title = server.ae_title();
521 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.104";
522 config.proposed_contexts.push_back({
523 1,
524 "1.2.840.10008.5.1.4.1.1.2",
525 {"1.2.840.10008.1.2.1", "1.2.840.10008.1.2"}
526 });
527
528 auto connect = association::connect(
529 "localhost", port, config, default_timeout() * 2);
530
531 if (connect.is_err()) {
532 result.error_message = "Connection failed";
533 result.failure_count = files_per_worker;
534 return result;
535 }
536
537 auto& assoc = connect.value();
538 storage_scu scu;
539
540 auto study_uid = generate_uid();
541 for (int j = 0; j < files_per_worker; ++j) {
542 auto dataset = generate_ct_dataset(study_uid);
543 auto store_result = scu.store(assoc, dataset);
544 if (store_result.is_ok() && store_result.value().is_success()) {
545 ++result.success_count;
546 } else {
547 ++result.failure_count;
548 }
549 }
550
551 (void)assoc.release(default_timeout());
552 } catch (const std::exception& e) {
553 result.error_message = e.what();
554 }
555
556 result.duration = std::chrono::duration_cast<std::chrono::milliseconds>(
557 std::chrono::steady_clock::now() - start_time);
558 return result;
559 }));
560 }
561
562 start_latch.arrive_and_wait();
563
564 size_t total_success = 0;
565 size_t total_failure = 0;
566 std::chrono::milliseconds max_duration{0};
567
568 for (auto& future : futures) {
569 auto result = future.get();
570 total_success += result.success_count;
571 total_failure += result.failure_count;
572 max_duration = (std::max)(max_duration, result.duration);
573
574 if (!result.error_message.empty()) {
575 INFO("Worker error: " << result.error_message);
576 }
577 }
578
579 INFO("Total success: " << total_success);
580 INFO("Total failure: " << total_failure);
581 INFO("Max duration: " << max_duration.count() << " ms");
582 INFO("Server stored: " << server.stored_count());
583
584 REQUIRE(total_success == total_expected);
585 REQUIRE(total_failure == 0);
586 REQUIRE(server.stored_count() == total_expected);
587
588 auto stats = server.get_statistics();
589 CHECK(stats.total_associations >= num_workers);
590
591 server.stop();
592}
593
594TEST_CASE("dicom_server_v2 rapid sequential connections", "[v2][stress][sequential]") {
595 auto port = find_available_port();
596 test_server_v2 server(port, "V2_RAPID");
597 server.register_service(std::make_shared<verification_scp>());
598
599 REQUIRE(server.start());
600
601 constexpr int num_connections = 30;
602 size_t success_count = 0;
603
604 for (int i = 0; i < num_connections; ++i) {
605 auto connect = test_association::connect(
606 "localhost", port, server.ae_title(),
607 "RAPID_" + std::to_string(i),
608 {std::string(verification_sop_class_uid)});
609
610 if (connect.is_ok()) {
611 auto& assoc = connect.value();
612 (void)assoc.release(std::chrono::milliseconds{500});
613 ++success_count;
614 }
615 }
616
617 REQUIRE(success_count == num_connections);
618
619 auto stats = server.get_statistics();
620 CHECK(stats.total_associations == num_connections);
621
622 server.stop();
623}
624
625TEST_CASE("dicom_server_v2 max associations handling", "[v2][stress][limits]") {
626 auto port = find_available_port();
627
628 server_config config;
629 config.ae_title = "V2_LIMIT";
630 config.port = port;
631 config.max_associations = 5;
632
633 dicom_server_v2 server(config);
634 server.register_service(std::make_shared<verification_scp>());
635
636 REQUIRE(server.start().is_ok());
637 std::this_thread::sleep_for(std::chrono::milliseconds{100});
638
639 std::vector<std::optional<association>> held_connections;
640
641 // Fill up to max
642 for (int i = 0; i < 5; ++i) {
643 auto connect = test_association::connect(
644 "localhost", port, "V2_LIMIT",
645 "HOLD_" + std::to_string(i),
646 {std::string(verification_sop_class_uid)});
647 if (connect.is_ok()) {
648 held_connections.push_back(std::move(connect.value()));
649 }
650 }
651
652 REQUIRE(held_connections.size() == 5);
653 REQUIRE(server.active_associations() == 5);
654
655 // Release one connection
656 if (held_connections[0]) {
657 (void)held_connections[0]->release(std::chrono::milliseconds{500});
658 held_connections[0].reset();
659 }
660
661 std::this_thread::sleep_for(std::chrono::milliseconds{200});
662
663 // New connection should succeed
664 auto new_connect = test_association::connect(
665 "localhost", port, "V2_LIMIT", "NEW_CLIENT",
666 {std::string(verification_sop_class_uid)});
667
668 REQUIRE(new_connect.is_ok());
669 (void)new_connect.value().release(default_timeout());
670
671 // Clean up remaining connections
672 for (auto& opt_assoc : held_connections) {
673 if (opt_assoc) {
674 (void)opt_assoc->release(std::chrono::milliseconds{500});
675 }
676 }
677
678 server.stop();
679}
680
681// =============================================================================
682// Scenario 3: V1 to V2 Migration Validation
683// =============================================================================
684
685TEST_CASE("dicom_server_v2 API compatibility with v1", "[v2][migration][api]") {
686 auto port_v1 = find_available_port();
687 auto port_v2 = find_available_port();
688
689 // V1 Server
690 server_config config_v1;
691 config_v1.ae_title = "MIGRATION_V1";
692 config_v1.port = port_v1;
693 config_v1.max_associations = 20;
694 config_v1.idle_timeout = std::chrono::seconds{60};
695
696 dicom_server server_v1(config_v1);
697 server_v1.register_service(std::make_shared<verification_scp>());
698
699 // V2 Server (same config)
700 server_config config_v2;
701 config_v2.ae_title = "MIGRATION_V2";
702 config_v2.port = port_v2;
703 config_v2.max_associations = 20;
704 config_v2.idle_timeout = std::chrono::seconds{60};
705
706 dicom_server_v2 server_v2(config_v2);
707 server_v2.register_service(std::make_shared<verification_scp>());
708
709 REQUIRE(server_v1.start().is_ok());
710 REQUIRE(server_v2.start().is_ok());
711 std::this_thread::sleep_for(std::chrono::milliseconds{100});
712
713 SECTION("Same configuration produces same behavior") {
714 // Both servers should accept C-ECHO
715 auto connect_v1 = test_association::connect(
716 "localhost", port_v1, "MIGRATION_V1", "V1_CLIENT",
717 {std::string(verification_sop_class_uid)});
718
719 auto connect_v2 = test_association::connect(
720 "localhost", port_v2, "MIGRATION_V2", "V2_CLIENT",
721 {std::string(verification_sop_class_uid)});
722
723 REQUIRE(connect_v1.is_ok());
724 REQUIRE(connect_v2.is_ok());
725
726 auto& assoc_v1 = connect_v1.value();
727 auto& assoc_v2 = connect_v2.value();
728
729 // Both should accept verification context
730 REQUIRE(assoc_v1.has_accepted_context(verification_sop_class_uid));
731 REQUIRE(assoc_v2.has_accepted_context(verification_sop_class_uid));
732
733 // Both should respond to C-ECHO with success
734 auto ctx_v1 = *assoc_v1.accepted_context_id(verification_sop_class_uid);
735 auto ctx_v2 = *assoc_v2.accepted_context_id(verification_sop_class_uid);
736
737 auto echo_rq_1 = make_c_echo_rq(1, verification_sop_class_uid);
738 auto echo_rq_2 = make_c_echo_rq(1, verification_sop_class_uid);
739
740 REQUIRE(assoc_v1.send_dimse(ctx_v1, echo_rq_1).is_ok());
741 REQUIRE(assoc_v2.send_dimse(ctx_v2, echo_rq_2).is_ok());
742
743 auto recv_v1 = assoc_v1.receive_dimse(default_timeout());
744 auto recv_v2 = assoc_v2.receive_dimse(default_timeout());
745
746 REQUIRE(recv_v1.is_ok());
747 REQUIRE(recv_v2.is_ok());
748
749 CHECK(recv_v1.value().second.status() == status_success);
750 CHECK(recv_v2.value().second.status() == status_success);
751
752 (void)assoc_v1.release(default_timeout());
753 (void)assoc_v2.release(default_timeout());
754 }
755
756 SECTION("Statistics consistency") {
757 auto stats_v1 = server_v1.get_statistics();
758 auto stats_v2 = server_v2.get_statistics();
759
760 // Both should have processed at least one association from previous section
761 CHECK(stats_v1.total_associations >= 0);
762 CHECK(stats_v2.total_associations >= 0);
763 }
764
765 server_v1.stop();
766 server_v2.stop();
767}
768
769TEST_CASE("dicom_server_v2 graceful shutdown comparison", "[v2][migration][shutdown]") {
770 SECTION("V2 shutdown with active connections") {
771 auto port = find_available_port();
772 test_server_v2 server(port, "V2_SHUTDOWN");
773 server.register_service(std::make_shared<verification_scp>());
774
775 REQUIRE(server.start());
776
777 // Establish connections
778 std::vector<std::optional<association>> connections;
779 for (int i = 0; i < 3; ++i) {
780 auto connect = test_association::connect(
781 "localhost", port, server.ae_title(),
782 "SHUTDOWN_" + std::to_string(i),
783 {std::string(verification_sop_class_uid)});
784 if (connect.is_ok()) {
785 connections.push_back(std::move(connect.value()));
786 }
787 }
788
789 REQUIRE(connections.size() == 3);
790
791 // Start shutdown timer
792 auto start = std::chrono::steady_clock::now();
793 server.stop();
794 auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(
795 std::chrono::steady_clock::now() - start);
796
797 // Shutdown should complete within reasonable time
798 INFO("Shutdown duration: " << duration.count() << " ms");
799 CHECK(duration < std::chrono::seconds{5});
800
801 // Connections should be closed
802 for (auto& opt_assoc : connections) {
803 if (opt_assoc) {
804 opt_assoc.reset(); // Clean up
805 }
806 }
807 }
808}
809
810// =============================================================================
811// Scenario 4: Callback and Error Handling Tests
812// =============================================================================
813
814TEST_CASE("dicom_server_v2 callback invocation", "[v2][callbacks]") {
815 auto port = find_available_port();
816
817 server_config config;
818 config.ae_title = "V2_CALLBACK";
819 config.port = port;
820 config.max_associations = 10;
821
822 dicom_server_v2 server(config);
823 server.register_service(std::make_shared<verification_scp>());
824
825 std::atomic<int> established_count{0};
826 std::atomic<int> closed_count{0};
827 std::vector<std::string> errors;
828 std::mutex errors_mutex;
829
830 server.on_association_established(
831 [&](const std::string& session_id,
832 const std::string& calling_ae,
833 const std::string& called_ae) {
834 INFO("Association established: " << calling_ae << " -> " << called_ae);
835 ++established_count;
836 (void)session_id;
837 });
838
839 server.on_association_closed(
840 [&](const std::string& session_id, bool graceful) {
841 INFO("Association closed: " << session_id << " graceful=" << graceful);
842 ++closed_count;
843 });
844
845 server.on_error(
846 [&](const std::string& error) {
847 std::lock_guard<std::mutex> lock(errors_mutex);
848 errors.push_back(error);
849 });
850
851 REQUIRE(server.start().is_ok());
852 std::this_thread::sleep_for(std::chrono::milliseconds{100});
853
854 // Establish and release connection
855 auto connect = test_association::connect(
856 "localhost", port, "V2_CALLBACK", "CALLBACK_SCU",
857 {std::string(verification_sop_class_uid)});
858 REQUIRE(connect.is_ok());
859
860 std::this_thread::sleep_for(std::chrono::milliseconds{100});
861 CHECK(established_count == 1);
862
863 (void)connect.value().release(default_timeout());
864 std::this_thread::sleep_for(std::chrono::milliseconds{200});
865
866 CHECK(closed_count == 1);
867
868 server.stop();
869}
870
871// =============================================================================
872// Scenario 5: Mixed Operations Stress Test
873// =============================================================================
874
875TEST_CASE("dicom_server_v2 mixed operations stress", "[v2][stress][mixed]") {
876 auto port = find_available_port();
877 stress_test_server_v2 server(port, "V2_MIXED");
878
879 REQUIRE(server.initialize());
880 REQUIRE(server.start());
881
882 constexpr int num_iterations = 10;
883 std::atomic<int> echo_success{0};
884 std::atomic<int> store_success{0};
885
886 std::vector<std::thread> threads;
887
888 // Echo workers
889 for (int i = 0; i < 3; ++i) {
890 threads.emplace_back([&, i]() {
891 for (int j = 0; j < num_iterations; ++j) {
892 auto connect = test_association::connect(
893 "localhost", port, server.ae_title(),
894 "ECHO_" + std::to_string(i),
895 {std::string(verification_sop_class_uid)});
896
897 if (connect.is_ok()) {
898 auto& assoc = connect.value();
899 auto ctx = assoc.accepted_context_id(verification_sop_class_uid);
900 if (ctx) {
902 if (assoc.send_dimse(*ctx, echo_rq).is_ok()) {
903 auto recv = assoc.receive_dimse(default_timeout());
904 if (recv.is_ok() &&
905 recv.value().second.status() == status_success) {
906 ++echo_success;
907 }
908 }
909 }
910 (void)assoc.release(std::chrono::milliseconds{500});
911 }
912 }
913 });
914 }
915
916 // Store workers
917 for (int i = 0; i < 2; ++i) {
918 threads.emplace_back([&, i]() {
919 for (int j = 0; j < num_iterations; ++j) {
920 association_config config;
921 config.calling_ae_title = "STORE_" + std::to_string(i);
922 config.called_ae_title = server.ae_title();
923 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.105";
924 config.proposed_contexts.push_back({
925 1,
926 "1.2.840.10008.5.1.4.1.1.2",
927 {"1.2.840.10008.1.2.1"}
928 });
929
930 auto connect = association::connect(
931 "localhost", port, config, default_timeout());
932
933 if (connect.is_ok()) {
934 auto& assoc = connect.value();
935 storage_scu scu;
936 auto ds = generate_ct_dataset();
937 auto result = scu.store(assoc, ds);
938 if (result.is_ok() && result.value().is_success()) {
939 ++store_success;
940 }
941 (void)assoc.release(std::chrono::milliseconds{500});
942 }
943 }
944 });
945 }
946
947 for (auto& t : threads) {
948 t.join();
949 }
950
951 INFO("Echo success: " << echo_success.load());
952 INFO("Store success: " << store_success.load());
953
954 REQUIRE(echo_success == 3 * num_iterations);
955 REQUIRE(store_success == 2 * num_iterations);
956
957 server.stop();
958}
959
960#else // !PACS_WITH_NETWORK_SYSTEM
961
962TEST_CASE("dicom_server_v2 requires network_system", "[v2][skip]") {
963 WARN("dicom_server_v2 tests skipped: PACS_WITH_NETWORK_SYSTEM not defined");
964 SUCCEED("Tests skipped as expected");
965}
966
967#endif // PACS_WITH_NETWORK_SYSTEM
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.
DICOM server using network_system's messaging_server for connection management.
network::Result< store_result > store(network::association &assoc, const core::dicom_dataset &dataset)
Store a single DICOM dataset.
Multi-threaded DICOM server for handling multiple associations.
DICOM server implementation using network_system's messaging_server.
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_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.
constexpr std::string_view verification_sop_class_uid
Verification SOP Class UID (1.2.840.10008.1.1)
DICOM Query SCP service (C-FIND handler)
DICOM Storage SCP service (C-STORE handler)
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.
Statistics for server monitoring.
Configuration for Storage SCU service.
Definition storage_scu.h:80
Result of a C-STORE operation.
Definition storage_scu.h:43
bool is_success() const noexcept
Check if the store operation was successful.
Definition storage_scu.h:54
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)