21#include <catch2/catch_test_macros.hpp>
22#include <catch2/matchers/catch_matchers_string.hpp>
64 explicit test_server_v2(
66 const std::string& ae_title =
"TEST_SCP_V2")
68 , ae_title_(ae_title) {
78 server_ = std::make_unique<dicom_server_v2>(config);
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;
90 template <
typename Service>
91 void register_service(std::shared_ptr<Service> service) {
92 server_->register_service(std::move(service));
95 [[nodiscard]]
bool start() {
96 auto result = server_->start();
99 std::this_thread::sleep_for(std::chrono::milliseconds{100});
101 return result.is_ok();
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_; }
117 return server_->get_statistics();
122 std::string ae_title_;
123 std::unique_ptr<dicom_server_v2> server_;
124 bool running_{
false};
132class stress_test_server_v2 {
134 explicit stress_test_server_v2(
136 const std::string& ae_title =
"STRESS_V2")
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") {
143 std::filesystem::create_directories(storage_dir_);
153 server_ = std::make_unique<dicom_server_v2>(config);
157 file_storage_ = std::make_unique<file_storage>(fs_conf);
159 auto db_result = index_database::open(db_path_.string());
160 if (db_result.is_ok()) {
161 database_ = std::move(db_result.value());
163 throw std::runtime_error(
"Failed to open database: " + db_result.error().message);
168 server_->register_service(std::make_shared<verification_scp>());
170 auto storage_scp_ptr = std::make_shared<storage_scp>();
171 storage_scp_ptr->set_handler([
this](
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);
178 server_->register_service(storage_scp_ptr);
183 auto result = server_->start();
184 if (result.is_ok()) {
185 std::this_thread::sleep_for(std::chrono::milliseconds{100});
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(); }
201 return server_->get_statistics();
209 const std::string& sop_instance_uid) {
214 return storage_status::storage_error;
218 auto patient_res = database_->upsert_patient(
224 if (patient_res.is_err()) {
226 return storage_status::storage_error;
228 int64_t patient_pk = patient_res.value();
230 auto study_res = database_->upsert_study(
238 if (study_res.is_err()) {
240 return storage_status::storage_error;
242 int64_t study_pk = study_res.value();
246 std::string sn = dataset.
get_string(tags::series_number);
250 auto series_res = database_->upsert_series(
252 dataset.
get_string(tags::series_instance_uid),
256 if (series_res.is_err()) {
258 return storage_status::storage_error;
260 int64_t series_pk = series_res.value();
262 auto file_path = file_storage_->get_file_path(sop_instance_uid);
265 std::string in = dataset.
get_string(tags::instance_number);
269 auto instance_res = database_->upsert_instance(
274 static_cast<int64_t
>(std::filesystem::file_size(file_path)),
278 if (instance_res.is_err()) {
280 return storage_status::storage_error;
284 return storage_status::success;
288 std::string ae_title_;
290 std::filesystem::path storage_dir_;
291 std::filesystem::path db_path_;
293 std::unique_ptr<dicom_server_v2> server_;
294 std::unique_ptr<file_storage> file_storage_;
295 std::unique_ptr<index_database> database_;
297 std::atomic<size_t> stored_count_{0};
298 std::atomic<size_t> failed_count_{0};
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;
317#ifdef PACS_WITH_NETWORK_SYSTEM
319TEST_CASE(
"dicom_server_v2 C-ECHO integration",
"[v2][integration][echo]") {
321 test_server_v2 server(port,
"V2_ECHO_SCP");
322 server.register_service(std::make_shared<verification_scp>());
324 REQUIRE(server.start());
325 REQUIRE(server.is_running());
327 SECTION(
"Single C-ECHO succeeds") {
329 "localhost", port, server.ae_title(),
"V2_ECHO_SCU",
330 {std::string(verification_sop_class_uid)});
332 REQUIRE(connect.is_ok());
333 auto& assoc = connect.value();
338 REQUIRE(ctx_opt.has_value());
341 auto send_result = assoc.send_dimse(*ctx_opt, echo_rq);
342 REQUIRE(send_result.is_ok());
345 REQUIRE(recv_result.is_ok());
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);
354 SECTION(
"Multiple sequential C-ECHO operations") {
355 constexpr int num_echos = 10;
356 int success_count = 0;
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();
365 REQUIRE(ctx_opt.has_value());
367 for (
int i = 0; i < num_echos; ++i) {
370 if (assoc.send_dimse(*ctx_opt, echo_rq).is_ok()) {
372 if (recv.is_ok() && recv.value().second.status() == status_success) {
378 REQUIRE(success_count == num_echos);
384 auto stats = server.get_statistics();
385 CHECK(stats.total_associations > 0);
388TEST_CASE(
"dicom_server_v2 C-STORE integration",
"[v2][integration][store]") {
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();
405 std::atomic<int> store_count{0};
406 auto storage_scp_ptr = std::make_shared<storage_scp>();
407 storage_scp_ptr->set_handler([&](
411 const std::string&) {
412 auto result = fs_raw->store(dataset);
413 if (result.is_ok()) {
415 return storage_status::success;
417 return storage_status::storage_error;
420 server.register_service(storage_scp_ptr);
421 server.register_service(std::make_shared<verification_scp>());
423 auto start_result = server.start();
424 REQUIRE(start_result.is_ok());
425 std::this_thread::sleep_for(std::chrono::milliseconds{100});
427 SECTION(
"Store single CT image") {
434 "1.2.840.10008.5.1.4.1.1.2",
435 {
"1.2.840.10008.1.2.1",
"1.2.840.10008.1.2"}
438 auto connect = association::connect(
440 REQUIRE(connect.is_ok());
442 auto& assoc = connect.value();
447 auto result = scu.store(assoc, dataset);
448 REQUIRE(result.is_ok());
449 REQUIRE(result.value().is_success());
452 REQUIRE(store_count == 1);
455 SECTION(
"Store multiple images in single association") {
462 "1.2.840.10008.5.1.4.1.1.2",
463 {
"1.2.840.10008.1.2.1"}
466 auto connect = association::connect(
468 REQUIRE(connect.is_ok());
470 auto& assoc = connect.value();
473 constexpr int num_images = 5;
475 int success_count = 0;
477 for (
int i = 0; i < num_images; ++i) {
479 auto result = scu.
store(assoc, dataset);
480 if (result.is_ok() && result.value().is_success()) {
485 REQUIRE(success_count == num_images);
496TEST_CASE(
"dicom_server_v2 concurrent storage stress test",
"[v2][stress][concurrent]") {
498 stress_test_server_v2 server(port,
"V2_STRESS");
500 REQUIRE(server.initialize());
501 REQUIRE(server.start());
503 constexpr int num_workers = 10;
504 constexpr int files_per_worker = 5;
505 constexpr int total_expected = num_workers * files_per_worker;
507 std::latch start_latch(num_workers + 1);
508 std::vector<std::future<v2_worker_result>> futures;
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();
515 start_latch.arrive_and_wait();
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"}
528 auto connect = association::connect(
531 if (connect.is_err()) {
532 result.error_message =
"Connection failed";
533 result.failure_count = files_per_worker;
537 auto& assoc = connect.value();
541 for (
int j = 0; j < files_per_worker; ++j) {
545 ++result.success_count;
547 ++result.failure_count;
552 }
catch (
const std::exception& e) {
553 result.error_message = e.what();
556 result.duration = std::chrono::duration_cast<std::chrono::milliseconds>(
557 std::chrono::steady_clock::now() - start_time);
562 start_latch.arrive_and_wait();
564 size_t total_success = 0;
565 size_t total_failure = 0;
566 std::chrono::milliseconds max_duration{0};
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);
574 if (!result.error_message.empty()) {
575 INFO(
"Worker error: " << result.error_message);
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());
584 REQUIRE(total_success == total_expected);
585 REQUIRE(total_failure == 0);
586 REQUIRE(server.stored_count() == total_expected);
588 auto stats = server.get_statistics();
589 CHECK(stats.total_associations >= num_workers);
594TEST_CASE(
"dicom_server_v2 rapid sequential connections",
"[v2][stress][sequential]") {
596 test_server_v2 server(port,
"V2_RAPID");
597 server.register_service(std::make_shared<verification_scp>());
599 REQUIRE(server.start());
601 constexpr int num_connections = 30;
602 size_t success_count = 0;
604 for (
int i = 0; i < num_connections; ++i) {
606 "localhost", port, server.ae_title(),
607 "RAPID_" + std::to_string(i),
608 {std::string(verification_sop_class_uid)});
610 if (connect.is_ok()) {
611 auto& assoc = connect.value();
612 (void)assoc.release(std::chrono::milliseconds{500});
617 REQUIRE(success_count == num_connections);
619 auto stats = server.get_statistics();
620 CHECK(stats.total_associations == num_connections);
625TEST_CASE(
"dicom_server_v2 max associations handling",
"[v2][stress][limits]") {
634 server.register_service(std::make_shared<verification_scp>());
636 REQUIRE(server.start().is_ok());
637 std::this_thread::sleep_for(std::chrono::milliseconds{100});
639 std::vector<std::optional<association>> held_connections;
642 for (
int i = 0; i < 5; ++i) {
644 "localhost", port,
"V2_LIMIT",
645 "HOLD_" + std::to_string(i),
647 if (connect.is_ok()) {
648 held_connections.push_back(std::move(connect.value()));
652 REQUIRE(held_connections.size() == 5);
653 REQUIRE(server.active_associations() == 5);
656 if (held_connections[0]) {
657 (void)held_connections[0]->release(std::chrono::milliseconds{500});
658 held_connections[0].reset();
661 std::this_thread::sleep_for(std::chrono::milliseconds{200});
665 "localhost", port,
"V2_LIMIT",
"NEW_CLIENT",
668 REQUIRE(new_connect.is_ok());
672 for (
auto& opt_assoc : held_connections) {
674 (void)opt_assoc->release(std::chrono::milliseconds{500});
685TEST_CASE(
"dicom_server_v2 API compatibility with v1",
"[v2][migration][api]") {
691 config_v1.
ae_title =
"MIGRATION_V1";
692 config_v1.
port = port_v1;
697 server_v1.register_service(std::make_shared<verification_scp>());
701 config_v2.
ae_title =
"MIGRATION_V2";
702 config_v2.
port = port_v2;
707 server_v2.register_service(std::make_shared<verification_scp>());
709 REQUIRE(server_v1.start().is_ok());
710 REQUIRE(server_v2.start().is_ok());
711 std::this_thread::sleep_for(std::chrono::milliseconds{100});
713 SECTION(
"Same configuration produces same behavior") {
716 "localhost", port_v1,
"MIGRATION_V1",
"V1_CLIENT",
720 "localhost", port_v2,
"MIGRATION_V2",
"V2_CLIENT",
723 REQUIRE(connect_v1.is_ok());
724 REQUIRE(connect_v2.is_ok());
726 auto& assoc_v1 = connect_v1.value();
727 auto& assoc_v2 = connect_v2.value();
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());
746 REQUIRE(recv_v1.is_ok());
747 REQUIRE(recv_v2.is_ok());
749 CHECK(recv_v1.value().second.status() == status_success);
750 CHECK(recv_v2.value().second.status() == status_success);
756 SECTION(
"Statistics consistency") {
757 auto stats_v1 = server_v1.get_statistics();
758 auto stats_v2 = server_v2.get_statistics();
761 CHECK(stats_v1.total_associations >= 0);
762 CHECK(stats_v2.total_associations >= 0);
769TEST_CASE(
"dicom_server_v2 graceful shutdown comparison",
"[v2][migration][shutdown]") {
770 SECTION(
"V2 shutdown with active connections") {
772 test_server_v2 server(port,
"V2_SHUTDOWN");
773 server.register_service(std::make_shared<verification_scp>());
775 REQUIRE(server.start());
778 std::vector<std::optional<association>> connections;
779 for (
int i = 0; i < 3; ++i) {
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()));
789 REQUIRE(connections.size() == 3);
792 auto start = std::chrono::steady_clock::now();
794 auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(
795 std::chrono::steady_clock::now() - start);
798 INFO(
"Shutdown duration: " << duration.count() <<
" ms");
799 CHECK(duration < std::chrono::seconds{5});
802 for (
auto& opt_assoc : connections) {
814TEST_CASE(
"dicom_server_v2 callback invocation",
"[v2][callbacks]") {
823 server.register_service(std::make_shared<verification_scp>());
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;
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);
839 server.on_association_closed(
840 [&](
const std::string& session_id,
bool graceful) {
841 INFO(
"Association closed: " << session_id <<
" graceful=" << graceful);
846 [&](
const std::string&
error) {
847 std::lock_guard<std::mutex> lock(errors_mutex);
848 errors.push_back(
error);
851 REQUIRE(server.start().is_ok());
852 std::this_thread::sleep_for(std::chrono::milliseconds{100});
856 "localhost", port,
"V2_CALLBACK",
"CALLBACK_SCU",
858 REQUIRE(connect.is_ok());
860 std::this_thread::sleep_for(std::chrono::milliseconds{100});
861 CHECK(established_count == 1);
864 std::this_thread::sleep_for(std::chrono::milliseconds{200});
866 CHECK(closed_count == 1);
875TEST_CASE(
"dicom_server_v2 mixed operations stress",
"[v2][stress][mixed]") {
877 stress_test_server_v2 server(port,
"V2_MIXED");
879 REQUIRE(server.initialize());
880 REQUIRE(server.start());
882 constexpr int num_iterations = 10;
883 std::atomic<int> echo_success{0};
884 std::atomic<int> store_success{0};
886 std::vector<std::thread> threads;
889 for (
int i = 0; i < 3; ++i) {
890 threads.emplace_back([&, i]() {
891 for (
int j = 0; j < num_iterations; ++j) {
893 "localhost", port, server.ae_title(),
894 "ECHO_" + std::to_string(i),
895 {std::string(verification_sop_class_uid)});
897 if (connect.is_ok()) {
898 auto& assoc = connect.value();
902 if (assoc.send_dimse(*ctx, echo_rq).is_ok()) {
905 recv.value().second.status() == status_success) {
910 (void)assoc.release(std::chrono::milliseconds{500});
917 for (
int i = 0; i < 2; ++i) {
918 threads.emplace_back([&, i]() {
919 for (
int j = 0; j < num_iterations; ++j) {
926 "1.2.840.10008.5.1.4.1.1.2",
927 {
"1.2.840.10008.1.2.1"}
930 auto connect = association::connect(
933 if (connect.is_ok()) {
934 auto& assoc = connect.value();
937 auto result = scu.
store(assoc, ds);
938 if (result.is_ok() && result.value().is_success()) {
941 (void)assoc.release(std::chrono::milliseconds{500});
947 for (
auto& t : threads) {
951 INFO(
"Echo success: " << echo_success.load());
952 INFO(
"Store success: " << store_success.load());
954 REQUIRE(echo_success == 3 * num_iterations);
955 REQUIRE(store_success == 2 * num_iterations);
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");
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.
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::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.
Statistics for server monitoring.
Configuration for Storage SCU service.
Result of a C-STORE operation.
bool is_success() const noexcept
Check if the store operation was successful.
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)