17#include <catch2/catch_test_macros.hpp>
18#include <catch2/matchers/catch_matchers_string.hpp>
51class simple_pacs_server {
53 explicit simple_pacs_server(uint16_t port,
const std::string& ae_title =
"TEST_PACS")
56 , test_dir_(
"pacs_server_test_")
57 , storage_dir_(test_dir_.path() /
"archive")
58 , db_path_(test_dir_.path() /
"index.db") {
60 std::filesystem::create_directories(storage_dir_);
71 server_ = std::make_unique<dicom_server>(config);
76 file_storage_ = std::make_unique<file_storage>(fs_config);
77 auto db_result = index_database::open(db_path_.string());
78 if (db_result.is_err()) {
79 throw std::runtime_error(
"Failed to open database: " + db_result.error().message);
81 database_ = std::move(db_result.value());
86 server_->register_service(std::make_shared<verification_scp>());
89 auto storage_scp_ptr = std::make_shared<storage_scp>();
90 storage_scp_ptr->set_handler([
this](
92 const std::string& calling_ae,
93 const std::string& sop_class_uid,
96 return handle_store(dataset, calling_ae, sop_class_uid, sop_instance_uid);
98 server_->register_service(storage_scp_ptr);
101 auto query_scp_ptr = std::make_shared<query_scp>();
102 query_scp_ptr->set_handler([
this](
105 const std::string& calling_ae) -> std::vector<dicom_dataset> {
107 return handle_query(level, query_keys, calling_ae);
109 server_->register_service(query_scp_ptr);
112 auto retrieve_scp_ptr = std::make_shared<retrieve_scp>();
113 retrieve_scp_ptr->set_retrieve_handler([
this](
114 const dicom_dataset& query_keys) -> std::vector<dicom_file> {
116 return handle_retrieve(query_keys);
118 server_->register_service(retrieve_scp_ptr);
124 auto result = server_->start();
125 if (result.is_ok()) {
126 std::this_thread::sleep_for(std::chrono::milliseconds{100});
136 uint16_t port()
const {
return port_; }
137 const std::string& ae_title()
const {
return ae_title_; }
138 size_t stored_count()
const {
return stored_count_; }
145 const std::string& ) {
150 return storage_status::storage_error;
155 auto pat_id = dataset.
get_string(tags::patient_id);
156 auto pat_name = dataset.
get_string(tags::patient_name);
157 auto pat_birth = dataset.
get_string(tags::patient_birth_date);
158 auto pat_sex = dataset.
get_string(tags::patient_sex);
160 auto pat_res = database_->upsert_patient(pat_id, pat_name, pat_birth, pat_sex);
161 if (pat_res.is_err())
return storage_status::storage_error;
162 auto pat_pk = pat_res.value();
165 auto study_uid = dataset.
get_string(tags::study_instance_uid);
166 auto study_res = database_->upsert_study(pat_pk, study_uid);
167 if (study_res.is_err())
return storage_status::storage_error;
168 auto study_pk = study_res.value();
171 auto series_uid = dataset.
get_string(tags::series_instance_uid);
172 auto series_res = database_->upsert_series(study_pk, series_uid);
173 if (series_res.is_err())
return storage_status::storage_error;
174 auto series_pk = series_res.value();
177 auto sop_uid = dataset.
get_string(tags::sop_instance_uid);
178 auto sop_class = dataset.
get_string(tags::sop_class_uid);
179 auto file_path = file_storage_->get_file_path(sop_uid).string();
182 auto file_size = std::filesystem::file_size(file_path, ec);
183 if (ec) file_size = 0;
185 auto inst_res = database_->upsert_instance(series_pk, sop_uid, sop_class, file_path,
static_cast<int64_t
>(file_size));
186 if (inst_res.is_err())
return storage_status::storage_error;
189 return storage_status::success;
192 std::vector<dicom_dataset> handle_query(
195 const std::string& ) {
197 std::vector<dicom_dataset> results;
199 if (level == query_level::study) {
201 auto study_uid_val = query_keys.
get_string(tags::study_instance_uid);
202 if (!study_uid_val.empty())
query.study_uid = study_uid_val;
204 auto pat_id_val = query_keys.
get_string(tags::patient_id);
205 if (!pat_id_val.empty())
query.patient_id = pat_id_val;
207 auto pat_name_val = query_keys.
get_string(tags::patient_name);
208 if (!pat_name_val.empty())
query.patient_name = pat_name_val;
210 auto studies_result = database_->search_studies(query);
211 if (studies_result.is_ok()) {
212 for (
const auto&
study : studies_result.value()) {
218 ds.
set_string(tags::accession_number, vr_type::SH,
study.accession_number);
219 ds.
set_string(tags::study_description, vr_type::LO,
study.study_description);
220 ds.
set_string(tags::query_retrieve_level, vr_type::CS,
"STUDY");
222 auto patient = database_->find_patient_by_pk(
study.patient_pk);
230 results.push_back(std::move(ds));
237 std::vector<dicom_file> handle_retrieve(
const dicom_dataset& query_keys) {
238 std::vector<dicom_file> results;
240 auto study_uid = query_keys.
get_string(tags::study_instance_uid);
241 if (!study_uid.empty()) {
242 auto series_list_result = database_->list_series(study_uid);
243 if (series_list_result.is_ok()) {
244 for (
const auto&
series : series_list_result.value()) {
245 auto instance_list_result = database_->list_instances(
series.series_uid);
246 if (instance_list_result.is_ok()) {
247 for (
const auto& inst : instance_list_result.value()) {
248 auto path = file_storage_->get_file_path(inst.sop_uid);
249 auto file_result = dicom_file::open(path);
250 if (file_result.is_ok()) {
251 results.push_back(std::move(file_result.value()));
262 std::string ae_title_;
264 std::filesystem::path storage_dir_;
265 std::filesystem::path db_path_;
267 std::unique_ptr<dicom_server> server_;
268 std::unique_ptr<file_storage> file_storage_;
269 std::unique_ptr<index_database> database_;
271 std::atomic<size_t> stored_count_{0};
280TEST_CASE(
"Store single DICOM file and query",
"[store_query][basic]") {
282 simple_pacs_server server(port,
"TEST_PACS");
284 REQUIRE(server.initialize());
285 REQUIRE(server.start());
287 SECTION(
"Store CT image and query at study level") {
299 "1.2.840.10008.5.1.4.1.1.2",
300 {
"1.2.840.10008.1.2.1",
"1.2.840.10008.1.2"}
303 auto connect_result = association::connect(
305 REQUIRE(connect_result.is_ok());
307 auto& assoc = connect_result.value();
321 REQUIRE(server.stored_count() == 1);
331 {
"1.2.840.10008.1.2.1",
"1.2.840.10008.1.2"}
334 auto query_connect = association::connect(
336 REQUIRE(query_connect.is_ok());
338 auto& query_assoc = query_connect.value();
342 query_keys.
set_string(tags::query_retrieve_level, vr_type::CS,
"STUDY");
343 query_keys.
set_string(tags::study_instance_uid, vr_type::UI, study_uid);
344 query_keys.
set_string(tags::patient_name, vr_type::PN,
"");
346 auto context_id = *query_assoc.accepted_context_id(
354 find_rq.set_dataset(std::move(query_keys));
355 auto send_result = query_assoc.send_dimse(context_id, find_rq);
356 REQUIRE(send_result.is_ok());
359 std::vector<dicom_dataset> query_results;
362 REQUIRE(recv_result.is_ok());
364 auto& [recv_ctx, rsp] = recv_result.value();
365 if (rsp.status() == status_success) {
367 }
else if (rsp.status() == status_pending) {
368 if (rsp.has_dataset()) {
369 auto ds_result = rsp.dataset();
370 if (ds_result.is_ok()) {
371 query_results.push_back(ds_result.value().get());
375 FAIL(
"Unexpected query status");
379 REQUIRE(query_results.size() == 1);
380 REQUIRE(query_results[0].get_string(tags::study_instance_uid) == study_uid);
388TEST_CASE(
"Store multiple files from same study",
"[store_query][multi]") {
390 simple_pacs_server server(port,
"TEST_PACS");
392 REQUIRE(server.initialize());
393 REQUIRE(server.start());
398 constexpr int num_images = 5;
400 std::vector<dicom_dataset> datasets;
401 for (
int i = 0; i < num_images; ++i) {
403 ds.
set_string(tags::instance_number, vr_type::IS, std::to_string(i + 1));
404 datasets.push_back(std::move(ds));
414 "1.2.840.10008.5.1.4.1.1.2",
415 {
"1.2.840.10008.1.2.1",
"1.2.840.10008.1.2"}
418 auto connect_result = association::connect(
420 REQUIRE(connect_result.is_ok());
422 auto& assoc = connect_result.value();
427 for (
const auto& ds : datasets) {
428 auto result = scu.store(assoc, ds);
429 REQUIRE(result.is_ok());
430 REQUIRE(result.value().is_success());
435 REQUIRE(server.stored_count() == num_images);
445 {
"1.2.840.10008.1.2.1",
"1.2.840.10008.1.2"}
448 auto query_connect = association::connect(
450 REQUIRE(query_connect.is_ok());
452 auto& query_assoc = query_connect.value();
455 query_keys.
set_string(tags::query_retrieve_level, vr_type::CS,
"SERIES");
456 query_keys.
set_string(tags::study_instance_uid, vr_type::UI, study_uid);
457 query_keys.
set_string(tags::series_instance_uid, vr_type::UI,
"");
458 query_keys.
set_string(tags::number_of_series_related_instances, vr_type::IS,
"");
460 auto context_id = *query_assoc.accepted_context_id(
464 find_rq.set_dataset(std::move(query_keys));
465 (void)query_assoc.send_dimse(context_id, find_rq);
467 std::vector<dicom_dataset> results;
470 if (recv_result.is_err())
break;
472 auto& [recv_ctx, rsp] = recv_result.value();
473 if (rsp.status() == status_success)
break;
474 if (rsp.status() == status_pending && rsp.has_dataset()) {
475 auto ds_result = rsp.dataset();
476 if (ds_result.is_ok()) {
477 results.push_back(ds_result.value().get());
482 REQUIRE(results.size() == 1);
483 REQUIRE(results[0].get_string(tags::series_instance_uid) == series_uid);
486 auto num_instances_str = results[0].get_string(tags::number_of_series_related_instances);
487 if (!num_instances_str.empty()) {
488 REQUIRE(std::stoi(num_instances_str) == num_images);
495TEST_CASE(
"Store files from multiple modalities",
"[store_query][modality]") {
497 simple_pacs_server server(port,
"TEST_PACS");
499 REQUIRE(server.initialize());
500 REQUIRE(server.start());
512 "1.2.840.10008.5.1.4.1.1.2",
513 {
"1.2.840.10008.1.2.1"}
517 "1.2.840.10008.5.1.4.1.1.4",
518 {
"1.2.840.10008.1.2.1"}
521 auto connect_result = association::connect(
523 REQUIRE(connect_result.is_ok());
525 auto& assoc = connect_result.value();
528 auto ct_result = scu.
store(assoc, ct_dataset);
529 REQUIRE(ct_result.is_ok());
531 auto mr_result = scu.
store(assoc, mr_dataset);
532 REQUIRE(mr_result.is_ok());
536 REQUIRE(server.stored_count() == 2);
546 {
"1.2.840.10008.1.2.1"}
549 auto query_connect = association::connect(
551 REQUIRE(query_connect.is_ok());
553 auto& query_assoc = query_connect.value();
557 ct_query.
set_string(tags::query_retrieve_level, vr_type::CS,
"STUDY");
558 ct_query.
set_string(tags::modalities_in_study, vr_type::CS,
"CT");
559 ct_query.
set_string(tags::study_instance_uid, vr_type::UI,
"");
561 auto context_id = *query_assoc.accepted_context_id(
565 find_rq.set_dataset(std::move(ct_query));
566 (void)query_assoc.send_dimse(context_id, find_rq);
568 std::vector<dicom_dataset> ct_results;
571 if (recv_result.is_err())
break;
573 auto& [recv_ctx, rsp] = recv_result.value();
574 if (rsp.status() == status_success)
break;
575 if (rsp.status() == status_pending && rsp.has_dataset()) {
576 auto ds_result = rsp.dataset();
577 if (ds_result.is_ok()) {
578 ct_results.push_back(ds_result.value().get());
584 REQUIRE(ct_results.size() == 1);
590TEST_CASE(
"Query with wildcards",
"[store_query][wildcard]") {
592 simple_pacs_server server(port,
"TEST_PACS");
594 REQUIRE(server.initialize());
595 REQUIRE(server.start());
598 std::vector<std::string> patient_names = {
599 "SMITH^JOHN",
"SMITH^JANE",
"JONES^WILLIAM"
608 "1.2.840.10008.5.1.4.1.1.2",
609 {
"1.2.840.10008.1.2.1"}
612 auto connect_result = association::connect(
614 REQUIRE(connect_result.is_ok());
616 auto& assoc = connect_result.value();
619 for (
const auto&
name : patient_names) {
622 ds.
set_string(tags::patient_id, vr_type::LO,
"PID_" +
name.substr(0, 5));
624 auto result = scu.
store(assoc, ds);
625 REQUIRE(result.is_ok());
638 {
"1.2.840.10008.1.2.1"}
641 auto query_connect = association::connect(
643 REQUIRE(query_connect.is_ok());
645 auto& query_assoc = query_connect.value();
649 query_keys.
set_string(tags::query_retrieve_level, vr_type::CS,
"STUDY");
650 query_keys.
set_string(tags::patient_name, vr_type::PN,
"SMITH*");
651 query_keys.
set_string(tags::study_instance_uid, vr_type::UI,
"");
653 auto context_id = *query_assoc.accepted_context_id(
657 find_rq.set_dataset(std::move(query_keys));
658 (void)query_assoc.send_dimse(context_id, find_rq);
660 std::vector<dicom_dataset> results;
663 if (recv_result.is_err())
break;
665 auto& [recv_ctx, rsp] = recv_result.value();
666 if (rsp.status() == status_success)
break;
667 if (rsp.status() == status_pending && rsp.has_dataset()) {
668 auto ds_result = rsp.dataset();
669 if (ds_result.is_ok()) {
670 results.push_back(ds_result.value().get());
676 REQUIRE(results.size() == 2);
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.
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_find_rq(uint16_t message_id, std::string_view sop_class_uid, uint16_t priority=priority_medium) -> dimse_message
Create a C-FIND request message.
const atna_coded_value query
Query (110112)
constexpr std::string_view study_root_find_sop_class_uid
Study Root Query/Retrieve Information Model - FIND.
storage_status
Storage operation status codes.
query_level
DICOM Query/Retrieve level enumeration.
@ study
Study level - query study information.
@ patient
Patient level - query patient demographics.
@ series
Series level - query series information.
DICOM Query SCP service (C-FIND handler)
DICOM Retrieve SCP service (C-MOVE/C-GET handler)
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.
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)