1use crate::author::Author;
11use crate::config::CoreConfig;
12use crate::constants::{CLINICAL_DIR_NAME, DEFAULT_GITIGNORE};
13use crate::error::{PatientError, PatientResult};
14use crate::paths::{
15 clinical::{ehr_status::EhrStatusFile, letter::LetterPaths},
16 common::GitIgnoreFile,
17};
18use crate::repositories::shared::create_uuid_and_shard_dir;
19use crate::NonEmptyText;
20
21#[cfg(test)]
23use crate::repositories::shared::create_uuid_and_shard_dir_with_source;
24use crate::versioned_files::{
25 ClinicalDomain::Record, FileToWrite, VersionedFileService, VprCommitAction, VprCommitDomain,
26 VprCommitMessage,
27};
28use crate::ShardableUuid;
29use openehr::{
30 extract_rm_version, validate_namespace_uri_safe, ClinicalList, EhrId, EhrStatus,
31 ExternalReference, Letter, LetterData,
32};
33use std::{
34 fs,
35 path::{Path, PathBuf},
36 sync::Arc,
37};
38use vpr_uuid::{Sha256Hash, TimestampId, TimestampIdGenerator};
39
40#[cfg(test)]
41use std::io::ErrorKind;
42use uuid::Uuid;
43
44#[derive(Clone, Copy, Debug)]
56pub struct Uninitialised;
57
58#[derive(Clone, Copy, Debug)]
71pub struct Initialised {
72 clinical_id: Uuid,
73}
74
75#[derive(Debug, Clone)]
79pub struct ReadLetterResult {
80 pub body_content: NonEmptyText,
82 pub letter_data: LetterData,
84}
85
86#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
92pub struct AttachmentMetadata {
93 pub metadata_filename: NonEmptyText,
95 pub hash: Sha256Hash,
97 pub file_storage_path: NonEmptyText,
99 pub size_bytes: u64,
101 pub media_type: Option<NonEmptyText>,
103 pub original_filename: NonEmptyText,
105}
106
107#[derive(Debug, Clone)]
111pub struct LetterAttachment {
112 pub metadata: AttachmentMetadata,
114 pub content: Vec<u8>,
116}
117
118#[derive(Clone, Debug)]
133pub struct ClinicalService<S> {
134 cfg: Arc<CoreConfig>,
135 state: S,
136}
137
138impl ClinicalService<Uninitialised> {
139 pub fn new(cfg: Arc<CoreConfig>) -> Self {
152 Self {
153 cfg,
154 state: Uninitialised,
155 }
156 }
157}
158
159impl ClinicalService<Initialised> {
160 pub fn with_id(cfg: Arc<CoreConfig>, clinical_id: Uuid) -> Self {
179 Self {
180 cfg,
181 state: Initialised { clinical_id },
182 }
183 }
184
185 pub fn clinical_id(&self) -> Uuid {
191 self.state.clinical_id
192 }
193}
194
195impl ClinicalService<Uninitialised> {
196 pub fn initialise(
230 self,
231 author: Author,
232 care_location: NonEmptyText,
233 ) -> PatientResult<ClinicalService<Initialised>> {
234 author.validate_commit_author()?;
235
236 let clinical_dir = self.clinical_dir();
237 let (clinical_uuid, patient_dir) = create_uuid_and_shard_dir(&clinical_dir)?;
238
239 let commit_message = VprCommitMessage::new(
240 VprCommitDomain::Clinical(Record),
241 VprCommitAction::Create,
242 "Initialised the clinical record",
243 care_location,
244 )?;
245
246 let rm_version = self.cfg.rm_system_version();
247
248 let ehr_status_yaml = EhrStatus::render(
250 rm_version,
251 None,
252 Some(&EhrId::from_uuid(clinical_uuid.uuid())),
253 None,
254 )?;
255
256 let files = [
257 FileToWrite {
258 relative_path: Path::new(GitIgnoreFile::NAME),
259 content: DEFAULT_GITIGNORE,
260 old_content: None,
261 },
262 FileToWrite {
263 relative_path: Path::new(EhrStatusFile::NAME),
264 content: &ehr_status_yaml,
265 old_content: None,
266 },
267 ];
268
269 VersionedFileService::init_and_commit(&patient_dir, &author, &commit_message, &files)?;
270
271 Ok(ClinicalService {
272 cfg: self.cfg,
273 state: Initialised {
274 clinical_id: clinical_uuid.uuid(),
275 },
276 })
277 }
278}
279
280impl ClinicalService<Initialised> {
281 pub fn link_to_demographics(
322 &self,
323 author: &Author,
324 care_location: NonEmptyText,
325 demographics_uuid: &str,
326 namespace: Option<NonEmptyText>,
327 ) -> PatientResult<()> {
328 author.validate_commit_author()?;
329
330 let msg = VprCommitMessage::new(
331 VprCommitDomain::Clinical(Record),
332 VprCommitAction::Update,
333 "EHR status linked to demographics",
334 care_location,
335 )?;
336
337 let clinical_uuid = ShardableUuid::parse(&self.clinical_id().simple().to_string())?;
338 let demographics_uuid = ShardableUuid::parse(demographics_uuid)?;
339
340 let namespace = namespace
341 .as_ref()
342 .map(|n| n.as_str())
343 .unwrap_or(self.cfg.vpr_namespace())
344 .trim();
345
346 validate_namespace_uri_safe(namespace)?;
347
348 let patient_dir = self.clinical_patient_dir(&clinical_uuid);
349 let filename = patient_dir.join("ehr_status.yaml");
350
351 if !filename.exists() {
352 return Err(PatientError::InvalidInput(format!(
353 "{} does not exist for clinical record {}",
354 "ehr_status.yaml", clinical_uuid
355 )));
356 }
357
358 let external_reference = Some(vec![ExternalReference {
359 namespace: format!("ehr://{}/mpi", namespace),
360 id: demographics_uuid.uuid(),
361 }]);
362
363 let previous_data = fs::read_to_string(&filename).map_err(PatientError::FileRead)?;
364
365 let rm_version = extract_rm_version(&previous_data)?;
366
367 let yaml_content =
368 EhrStatus::render(rm_version, Some(&previous_data), None, external_reference)?;
369
370 VersionedFileService::write_and_commit_files(
371 &patient_dir,
372 author,
373 &msg,
374 &[FileToWrite {
375 relative_path: Path::new("ehr_status.yaml"),
376 content: &yaml_content,
377 old_content: Some(&previous_data),
378 }],
379 )
380 }
381
382 pub fn create_letter(
415 &self,
416 author: &Author,
417 care_location: NonEmptyText,
418 body_content: Option<NonEmptyText>,
419 attachment_files: &[PathBuf],
420 clinical_lists: Option<&[ClinicalList]>,
421 ) -> PatientResult<TimestampId> {
422 if body_content.is_none() && attachment_files.is_empty() {
424 return Err(PatientError::InvalidInput(
425 "Letter must have either body content or attachments (or both)".to_string(),
426 ));
427 }
428
429 author.validate_commit_author()?;
430
431 let commit_message = VprCommitMessage::new(
432 VprCommitDomain::Clinical(Record),
433 VprCommitAction::Create,
434 "Created new letter",
435 care_location,
436 )?;
437
438 let timestamp_id = TimestampIdGenerator::generate(None)?;
439 let letter_paths = LetterPaths::new(×tamp_id);
440
441 let clinical_uuid = ShardableUuid::parse(&self.clinical_id().simple().to_string())?;
442 let patient_dir = self.clinical_patient_dir(&clinical_uuid);
443
444 let mut attachment_metadata_list = Vec::new();
446 let mut attachment_files_to_write = Vec::new();
447
448 if !attachment_files.is_empty() {
449 let clinical_dir = self.clinical_dir();
450 let files_service = vpr_files::FilesService::new(&clinical_dir, clinical_uuid.clone())
451 .map_err(|e| {
452 PatientError::InvalidInput(format!("Failed to initialize files service: {}", e))
453 })?;
454
455 for (index, file_path) in attachment_files.iter().enumerate() {
456 let file_metadata = files_service.add(file_path).map_err(|e| {
458 PatientError::InvalidInput(format!("Failed to add attachment file: {}", e))
459 })?;
460
461 let attachment_filename = format!("attachment_{}.yaml", index + 1);
463 let attachment_metadata = AttachmentMetadata {
464 metadata_filename: NonEmptyText::new(&attachment_filename).map_err(|e| {
465 PatientError::InvalidInput(format!("Invalid attachment filename: {}", e))
466 })?,
467 hash: Sha256Hash::parse(file_metadata.hash.as_str()).map_err(|e| {
468 PatientError::InvalidInput(format!("Invalid SHA-256 hash: {}", e))
469 })?,
470 file_storage_path: NonEmptyText::new(file_metadata.relative_path.as_str())
471 .map_err(|e| {
472 PatientError::InvalidInput(format!("Invalid file storage path: {}", e))
473 })?,
474 size_bytes: file_metadata.size_bytes,
475 media_type: file_metadata
476 .media_type
477 .as_ref()
478 .map(|mt| NonEmptyText::new(mt.as_str()))
479 .transpose()
480 .map_err(|e| {
481 PatientError::InvalidInput(format!("Invalid media type: {}", e))
482 })?,
483 original_filename: NonEmptyText::new(&file_metadata.original_filename)
484 .map_err(|e| {
485 PatientError::InvalidInput(format!("Invalid original filename: {}", e))
486 })?,
487 };
488
489 let attachment_yaml = serde_yaml::to_string(&attachment_metadata).map_err(|e| {
491 PatientError::InvalidInput(format!(
492 "Failed to serialize attachment metadata: {}",
493 e
494 ))
495 })?;
496
497 let attachment_path = letter_paths.attachment(&attachment_filename);
499 attachment_files_to_write.push((attachment_path, attachment_yaml));
500 attachment_metadata_list.push(attachment_metadata);
501 }
502 }
503
504 let rm_version = self.cfg.rm_system_version();
506 let start_time = timestamp_id.timestamp();
507
508 let attachment_refs: Vec<openehr::AttachmentReference> = attachment_metadata_list
510 .iter()
511 .map(|meta| openehr::AttachmentReference {
512 path: format!("./attachments/{}", meta.metadata_filename),
513 })
514 .collect();
515
516 let letter_data = openehr::LetterData {
518 rm_version,
519 uid: timestamp_id.clone(),
520 composer_name: author.name.to_string(),
521 composer_role: "Clinical Practitioner".to_string(),
522 start_time,
523 clinical_lists: clinical_lists
524 .map(|lists| lists.to_vec())
525 .unwrap_or_default(),
526 has_body: body_content.is_some(),
527 attachments: attachment_refs,
528 };
529
530 let composition_content =
531 Letter::composition_render(rm_version, &letter_data).map_err(|e| {
532 PatientError::InvalidInput(format!("Failed to create letter composition: {}", e))
533 })?;
534
535 let composition_yaml_relative_path = letter_paths.composition_yaml();
536
537 let mut files_to_write_vec = vec![FileToWrite {
539 relative_path: &composition_yaml_relative_path,
540 content: &composition_content,
541 old_content: None,
542 }];
543
544 let body_md_relative_path;
546 if let Some(ref body) = body_content {
547 body_md_relative_path = letter_paths.body_md();
548 files_to_write_vec.push(FileToWrite {
549 relative_path: &body_md_relative_path,
550 content: body.as_str(),
551 old_content: None,
552 });
553 }
554
555 let attachment_paths: Vec<PathBuf> = attachment_files_to_write
557 .iter()
558 .map(|(path, _)| path.clone())
559 .collect();
560
561 for (path, content) in attachment_paths
562 .iter()
563 .zip(attachment_files_to_write.iter())
564 {
565 files_to_write_vec.push(FileToWrite {
566 relative_path: path,
567 content: content.1.as_str(),
568 old_content: None,
569 });
570 }
571
572 VersionedFileService::write_and_commit_files(
573 &patient_dir,
574 author,
575 &commit_message,
576 &files_to_write_vec,
577 )?;
578
579 Ok(timestamp_id)
580 }
581
582 pub fn new_letter(
621 &self,
622 author: &Author,
623 care_location: NonEmptyText,
624 letter_content: NonEmptyText,
625 clinical_lists: Option<&[ClinicalList]>,
626 ) -> PatientResult<TimestampId> {
627 author.validate_commit_author()?;
628
629 let msg = VprCommitMessage::new(
630 VprCommitDomain::Clinical(Record),
631 VprCommitAction::Create,
632 "Created new letter",
633 care_location,
634 )?;
635
636 let timestamp_id = TimestampIdGenerator::generate(None)?;
637 let letter_paths = LetterPaths::new(×tamp_id);
638
639 let clinical_uuid = ShardableUuid::parse(&self.clinical_id().simple().to_string())?;
640 let patient_dir = self.clinical_patient_dir(&clinical_uuid);
641
642 let body_md_relative_path = letter_paths.body_md();
643 let composition_yaml_relative_path = letter_paths.composition_yaml();
644
645 let rm_version = self.cfg.rm_system_version();
647 let start_time = timestamp_id.timestamp();
648
649 let letter_data = openehr::LetterData {
651 rm_version,
652 uid: timestamp_id.clone(),
653 composer_name: author.name.to_string(),
654 composer_role: "Clinical Practitioner".to_string(),
655 start_time,
656 clinical_lists: clinical_lists
657 .map(|lists| lists.to_vec())
658 .unwrap_or_default(),
659 has_body: true,
660 attachments: vec![],
661 };
662
663 let composition_content =
664 Letter::composition_render(rm_version, &letter_data).map_err(|e| {
665 PatientError::InvalidInput(format!("Failed to create letter composition: {}", e))
666 })?;
667
668 let files_to_write = [
669 FileToWrite {
670 relative_path: &composition_yaml_relative_path,
671 content: &composition_content,
672 old_content: None,
673 },
674 FileToWrite {
675 relative_path: &body_md_relative_path,
676 content: letter_content.as_str(),
677 old_content: None,
678 },
679 ];
680
681 VersionedFileService::write_and_commit_files(&patient_dir, author, &msg, &files_to_write)?;
682
683 Ok(timestamp_id)
684 }
685
686 pub fn read_letter(&self, timestamp_id: &str) -> PatientResult<ReadLetterResult> {
708 let timestamp_id: vpr_uuid::TimestampId = timestamp_id
709 .parse()
710 .map_err(|e| PatientError::InvalidInput(format!("Invalid timestamp ID: {}", e)))?;
711 let letter_paths = LetterPaths::new(×tamp_id);
712
713 let clinical_uuid = ShardableUuid::parse(&self.clinical_id().simple().to_string())?;
714 let patient_dir = self.clinical_patient_dir(&clinical_uuid);
715
716 let body_md_path = patient_dir.join(letter_paths.body_md());
717 let composition_yaml_path = patient_dir.join(letter_paths.composition_yaml());
718
719 if !body_md_path.exists() {
721 return Err(PatientError::InvalidInput(format!(
722 "Letter body file not found: {}",
723 body_md_path.display()
724 )));
725 }
726
727 if !composition_yaml_path.exists() {
728 return Err(PatientError::InvalidInput(format!(
729 "Letter composition file not found: {}",
730 composition_yaml_path.display()
731 )));
732 }
733
734 let body_content_str = fs::read_to_string(&body_md_path).map_err(PatientError::FileRead)?;
736 let body_content = NonEmptyText::new(&body_content_str).map_err(|_| {
737 PatientError::InvalidInput(format!(
738 "Letter body is empty for timestamp ID: {}",
739 timestamp_id
740 ))
741 })?;
742
743 let composition_yaml =
745 fs::read_to_string(&composition_yaml_path).map_err(PatientError::FileRead)?;
746
747 let rm_version = extract_rm_version(&composition_yaml)?;
748
749 let letter_data = Letter::composition_parse(rm_version, &composition_yaml)
750 .map_err(PatientError::Openehr)?;
751
752 Ok(ReadLetterResult {
753 body_content,
754 letter_data,
755 })
756 }
757
758 pub fn get_letter_attachments(
780 &self,
781 timestamp_id: &str,
782 ) -> PatientResult<Vec<LetterAttachment>> {
783 let timestamp_id: vpr_uuid::TimestampId = timestamp_id
784 .parse()
785 .map_err(|e| PatientError::InvalidInput(format!("Invalid timestamp ID: {}", e)))?;
786 let letter_paths = LetterPaths::new(×tamp_id);
787
788 let clinical_uuid = ShardableUuid::parse(&self.clinical_id().simple().to_string())?;
789 let patient_dir = self.clinical_patient_dir(&clinical_uuid);
790
791 let attachments_dir = patient_dir.join(letter_paths.attachments_dir());
792
793 if !attachments_dir.exists() {
795 return Ok(Vec::new());
797 }
798
799 let clinical_dir = self.clinical_dir();
801 let files_service =
802 vpr_files::FilesService::new(&clinical_dir, clinical_uuid).map_err(|e| {
803 PatientError::InvalidInput(format!("Failed to initialize files service: {}", e))
804 })?;
805
806 let mut attachments = Vec::new();
807
808 let entries = fs::read_dir(&attachments_dir).map_err(|e| {
810 PatientError::FileRead(std::io::Error::new(
811 e.kind(),
812 format!("Failed to read attachments directory: {}", e),
813 ))
814 })?;
815
816 for entry in entries {
817 let entry = entry.map_err(PatientError::FileRead)?;
818 let path = entry.path();
819
820 if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
822 continue;
823 }
824
825 let metadata_content = fs::read_to_string(&path).map_err(PatientError::FileRead)?;
827 let metadata: AttachmentMetadata =
828 serde_yaml::from_str(&metadata_content).map_err(|e| {
829 PatientError::InvalidInput(format!(
830 "Failed to parse attachment metadata: {}",
831 e
832 ))
833 })?;
834
835 let content = files_service.read(metadata.hash.as_str()).map_err(|e| {
837 PatientError::InvalidInput(format!("Failed to read attachment file: {}", e))
838 })?;
839
840 attachments.push(LetterAttachment { metadata, content });
841 }
842
843 Ok(attachments)
844 }
845
846 pub fn new_letter_with_attachments(
862 &self,
863 author: &Author,
864 care_location: NonEmptyText,
865 attachment_files: &[PathBuf],
866 clinical_lists: Option<&[ClinicalList]>,
867 ) -> PatientResult<TimestampId> {
868 self.create_letter(
869 author,
870 care_location,
871 None,
872 attachment_files,
873 clinical_lists,
874 )
875 }
876}
877
878impl<S> ClinicalService<S> {
879 fn clinical_dir(&self) -> PathBuf {
888 let data_dir = self.cfg.patient_data_dir().to_path_buf();
889 data_dir.join(CLINICAL_DIR_NAME)
890 }
891
892 fn clinical_patient_dir(&self, clinical_uuid: &ShardableUuid) -> PathBuf {
907 let clinical_dir = self.clinical_dir();
908 clinical_uuid.sharded_dir(&clinical_dir)
909 }
910}
911
912#[cfg(test)]
913mod tests {
914 use super::*;
915 use crate::config::rm_system_version_from_env_value;
916 use crate::{EmailAddress, NonEmptyText};
917
918 use crate::CoreConfig;
919 use p256::ecdsa::SigningKey;
920 use p256::pkcs8::{EncodePrivateKey, EncodePublicKey};
921 use rcgen::{CertificateParams, KeyPair};
922 use std::path::Path;
923 use std::sync::Arc;
924 use tempfile::TempDir;
925
926 fn test_cfg(patient_data_dir: &Path) -> Arc<CoreConfig> {
927 let rm_system_version = rm_system_version_from_env_value(None)
928 .expect("rm_system_version_from_env_value should succeed");
929
930 Arc::new(
931 CoreConfig::new(
932 patient_data_dir.to_path_buf(),
933 rm_system_version,
934 crate::NonEmptyText::new("vpr.dev.1").unwrap(),
935 )
936 .expect("CoreConfig::new should succeed"),
937 )
938 }
939
940 #[test]
941 fn create_uuid_and_shard_dir_creates_first_available_candidate() {
942 let temp_dir = TempDir::new().expect("Failed to create temp dir");
943 let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
944
945 let uuids = vec![ShardableUuid::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
946 .expect("uuid should be canonical")];
947 let mut iter = uuids.into_iter();
948
949 let (uuid, patient_dir) =
950 create_uuid_and_shard_dir_with_source(&clinical_dir, || iter.next().unwrap())
951 .expect("allocation should succeed");
952
953 assert_eq!(uuid.to_string(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
954 assert_eq!(
955 patient_dir,
956 clinical_dir
957 .join("aa")
958 .join("aa")
959 .join("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
960 );
961 assert!(patient_dir.exists(), "patient directory should exist");
962 }
963
964 #[test]
965 fn create_uuid_and_shard_dir_skips_existing_candidate() {
966 let temp_dir = TempDir::new().expect("Failed to create temp dir");
967 let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
968
969 let first = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
970 let second = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
971 let first_dir = clinical_dir
972 .join("aa")
973 .join("aa")
974 .join("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
975 fs::create_dir_all(&first_dir).expect("Failed to pre-create first candidate dir");
976
977 let uuids = vec![
978 ShardableUuid::parse(first).expect("uuid should be canonical"),
979 ShardableUuid::parse(second).expect("uuid should be canonical"),
980 ];
981 let mut iter = uuids.into_iter();
982
983 let (uuid, patient_dir) =
984 create_uuid_and_shard_dir_with_source(&clinical_dir, || iter.next().unwrap())
985 .expect("allocation should succeed");
986
987 assert_eq!(uuid.to_string(), second);
988 assert_eq!(
989 patient_dir,
990 clinical_dir
991 .join("bb")
992 .join("bb")
993 .join("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
994 );
995 assert!(patient_dir.exists(), "patient directory should exist");
996 }
997
998 #[test]
999 fn create_uuid_and_shard_dir_fails_after_five_attempts() {
1000 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1001 let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
1002
1003 let ids = [
1004 "11111111111111111111111111111111",
1005 "22222222222222222222222222222222",
1006 "33333333333333333333333333333333",
1007 "44444444444444444444444444444444",
1008 "55555555555555555555555555555555",
1009 ];
1010
1011 for id in ids {
1012 let dir = clinical_dir.join(&id[0..2]).join(&id[2..4]).join(id);
1013 fs::create_dir_all(&dir).expect("Failed to pre-create candidate dir");
1014 }
1015
1016 let uuids = ids
1017 .into_iter()
1018 .map(|s| ShardableUuid::parse(s).expect("uuid should be canonical"))
1019 .collect::<Vec<_>>();
1020 let mut iter = uuids.into_iter();
1021
1022 let err = create_uuid_and_shard_dir_with_source(&clinical_dir, || iter.next().unwrap())
1023 .expect_err("allocation should fail");
1024
1025 match err {
1026 PatientError::PatientDirCreation(e) => {
1027 assert_eq!(e.kind(), ErrorKind::AlreadyExists);
1028 }
1029 other => panic!("unexpected error: {other:?}"),
1030 }
1031 }
1032
1033 #[test]
1034 fn create_uuid_and_shard_dir_returns_error_if_parent_dir_creation_fails() {
1035 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1036 let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
1037
1038 fs::create_dir_all(&clinical_dir).expect("Failed to create clinical_dir");
1041 let blocking_path = clinical_dir.join("aa");
1042 fs::write(&blocking_path, b"not a directory").expect("Failed to create blocking file");
1043
1044 let uuids = vec![ShardableUuid::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
1045 .expect("uuid should be canonical")];
1046 let mut iter = uuids.into_iter();
1047
1048 let err = create_uuid_and_shard_dir_with_source(&clinical_dir, || iter.next().unwrap())
1049 .expect_err("allocation should fail when parent dir creation fails");
1050
1051 assert!(matches!(err, PatientError::PatientDirCreation(_)));
1052 }
1053
1054 #[test]
1055 fn test_initialise_fails_fast_on_invalid_author_and_creates_no_files() {
1056 let patient_data_dir = TempDir::new().expect("Failed to create temp dir");
1057
1058 let rm_system_version = rm_system_version_from_env_value(None)
1059 .expect("rm_system_version_from_env_value should succeed");
1060
1061 let cfg = Arc::new(
1062 CoreConfig::new(
1063 patient_data_dir.path().to_path_buf(),
1064 rm_system_version,
1065 crate::NonEmptyText::new("vpr.dev.1").unwrap(),
1066 )
1067 .expect("CoreConfig::new should succeed"),
1068 );
1069
1070 let _service = ClinicalService::new(cfg);
1071
1072 let err =
1074 NonEmptyText::new(" ").expect_err("creating NonEmptyText from whitespace should fail");
1075 assert!(matches!(err, crate::TextError::Empty));
1076
1077 assert!(
1078 !patient_data_dir.path().join(CLINICAL_DIR_NAME).exists(),
1079 "initialise should not perform filesystem side-effects when validation fails"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_initialise_rejects_missing_care_location_and_creates_no_files() {
1085 let patient_data_dir = TempDir::new().expect("Failed to create temp dir");
1086
1087 let rm_system_version = rm_system_version_from_env_value(None)
1088 .expect("rm_system_version_from_env_value should succeed");
1089
1090 let cfg = Arc::new(
1091 CoreConfig::new(
1092 patient_data_dir.path().to_path_buf(),
1093 rm_system_version,
1094 crate::NonEmptyText::new("vpr.dev.1").unwrap(),
1095 )
1096 .expect("CoreConfig::new should succeed"),
1097 );
1098
1099 let _service = ClinicalService::new(cfg);
1100
1101 let _author = Author {
1102 name: NonEmptyText::new("Test Author").unwrap(),
1103 role: NonEmptyText::new("Clinician").unwrap(),
1104 email: EmailAddress::parse("test@example.com").unwrap(),
1105 registrations: vec![],
1106 signature: None,
1107 certificate: None,
1108 };
1109
1110 let err = NonEmptyText::new(" \t\n")
1112 .expect_err("creating NonEmptyText from whitespace should fail");
1113 assert!(matches!(err, crate::TextError::Empty));
1114
1115 assert!(
1116 !patient_data_dir.path().join(CLINICAL_DIR_NAME).exists(),
1117 "initialise should not perform filesystem side-effects when validation fails"
1118 );
1119 }
1120
1121 #[test]
1122 fn test_initialise_creates_clinical_record() {
1123 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1125
1126 let author = Author {
1128 name: NonEmptyText::new("Test Author").unwrap(),
1129 role: NonEmptyText::new("Clinician").unwrap(),
1130 email: EmailAddress::parse("test@example.com").unwrap(),
1131 registrations: vec![],
1132 signature: None,
1133 certificate: None,
1134 };
1135
1136 let cfg = test_cfg(temp_dir.path());
1137 let service = ClinicalService::new(cfg);
1138
1139 let result = service.initialise(author, NonEmptyText::new("Test Hospital").unwrap());
1141 assert!(result.is_ok(), "initialise should succeed");
1142
1143 let clinical_uuid = result.unwrap();
1144 let clinical_uuid_str = clinical_uuid.clinical_id().simple().to_string();
1145 assert_eq!(clinical_uuid_str.len(), 32, "UUID should be 32 characters");
1146
1147 let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
1149 assert!(clinical_dir.exists(), "clinical directory should exist");
1150
1151 let s1 = &clinical_uuid_str[0..2];
1153 let s2 = &clinical_uuid_str[2..4];
1154 let patient_dir = clinical_dir.join(s1).join(s2).join(&clinical_uuid_str);
1155 assert!(patient_dir.exists(), "patient directory should exist");
1156
1157 let ehr_status_file = patient_dir.join("ehr_status.yaml");
1159 assert!(ehr_status_file.exists(), "ehr_status.yaml should exist");
1160
1161 let gitignore_file = patient_dir.join(".gitignore");
1162 assert!(gitignore_file.exists(), ".gitignore should exist");
1163
1164 let repo = git2::Repository::open(&patient_dir).expect("Failed to open Git repo");
1166 let head = repo.head().expect("Failed to get HEAD");
1167 let commit = head.peel_to_commit().expect("Failed to get commit");
1168 assert_eq!(
1169 commit.message().unwrap(),
1170 "record:create: Initialised the clinical record\n\nAuthor-Name: Test Author\nAuthor-Role: Clinician\nCare-Location: Test Hospital"
1171 );
1172 }
1173
1174 #[test]
1175 fn test_link_to_demographics_updates_ehr_status() {
1176 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1178
1179 let author = Author {
1181 name: NonEmptyText::new("Test Author").unwrap(),
1182 role: NonEmptyText::new("Clinician").unwrap(),
1183 email: EmailAddress::parse("test@example.com").unwrap(),
1184 registrations: vec![],
1185 signature: None,
1186 certificate: None,
1187 };
1188
1189 let cfg = test_cfg(temp_dir.path());
1191 let service = ClinicalService::new(cfg.clone());
1192
1193 let care_location: NonEmptyText = NonEmptyText::new("Test Hospital").unwrap();
1195 let service = service
1196 .initialise(author.clone(), care_location.clone())
1197 .expect("initialise should succeed");
1198 let clinical_uuid = service.clinical_id();
1199 let clinical_uuid_str = clinical_uuid.simple().to_string();
1200
1201 let demographics_uuid = "12345678123412341234123456789abc";
1203 let result = service.link_to_demographics(&author, care_location, demographics_uuid, None);
1204 assert!(result.is_ok(), "link_to_demographics should succeed");
1205
1206 let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
1208 let patient_dir = ShardableUuid::parse(&clinical_uuid_str)
1209 .expect("clinical_uuid should be canonical")
1210 .sharded_dir(&clinical_dir);
1211 let ehr_status_file = patient_dir.join("ehr_status.yaml");
1212
1213 assert!(ehr_status_file.exists(), "ehr_status.yaml should exist");
1214 }
1215
1216 #[test]
1217 fn test_link_to_demographics_rejects_invalid_clinical_uuid() {
1218 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1219 let cfg = test_cfg(temp_dir.path());
1220 let _service = ClinicalService::new(cfg);
1224
1225 }
1229
1230 #[test]
1231 fn test_link_to_demographics_rejects_invalid_demographics_uuid() {
1232 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1233 let cfg = test_cfg(temp_dir.path());
1234 let service = ClinicalService::new(cfg);
1235
1236 let author = Author {
1238 name: NonEmptyText::new("Test Author").unwrap(),
1239 role: NonEmptyText::new("Clinician").unwrap(),
1240 email: EmailAddress::parse("test@example.com").unwrap(),
1241 registrations: vec![],
1242 signature: None,
1243 certificate: None,
1244 };
1245
1246 let service = service
1247 .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1248 .expect("initialise should succeed");
1249
1250 let err = service
1252 .link_to_demographics(
1253 &author,
1254 NonEmptyText::new("Test Hospital").unwrap(),
1255 "invalid-demographics-uuid",
1256 None,
1257 )
1258 .expect_err("expected validation failure for invalid demographics UUID");
1259 assert!(matches!(err, PatientError::Uuid(_)));
1260 }
1261
1262 #[test]
1263 fn test_link_to_demographics_rejects_invalid_namespace() {
1264 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1265 let cfg = test_cfg(temp_dir.path());
1266 let service = ClinicalService::new(cfg);
1267
1268 let author = Author {
1270 name: NonEmptyText::new("Test Author").unwrap(),
1271 role: NonEmptyText::new("Clinician").unwrap(),
1272 email: EmailAddress::parse("test@example.com").unwrap(),
1273 registrations: vec![],
1274 signature: None,
1275 certificate: None,
1276 };
1277
1278 let service = service
1279 .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1280 .expect("initialise should succeed");
1281
1282 let demographics_uuid = ShardableUuid::new();
1284 let demographics_uuid_str = demographics_uuid.to_string();
1285
1286 let err = service
1287 .link_to_demographics(
1288 &author,
1289 NonEmptyText::new("Test Hospital").unwrap(),
1290 &demographics_uuid_str,
1291 Some(NonEmptyText::new("unsafe<namespace>with/special\\chars").unwrap()),
1292 )
1293 .expect_err("expected validation failure for unsafe namespace");
1294 assert!(matches!(err, PatientError::Openehr(_)));
1295 }
1296
1297 #[test]
1298 fn test_link_to_demographics_fails_when_ehr_status_missing() {
1299 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1300 let cfg = test_cfg(temp_dir.path());
1301
1302 let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
1304 let clinical_uuid = ShardableUuid::new();
1305 let patient_dir = clinical_uuid.sharded_dir(&clinical_dir);
1306 fs::create_dir_all(&patient_dir).expect("Failed to create patient dir");
1307
1308 VersionedFileService::init(&patient_dir).expect("Failed to init git");
1310
1311 let author = Author {
1312 name: NonEmptyText::new("Test Author").unwrap(),
1313 role: NonEmptyText::new("Clinician").unwrap(),
1314 email: EmailAddress::parse("test@example.com").unwrap(),
1315 registrations: vec![],
1316 signature: None,
1317 certificate: None,
1318 };
1319
1320 let demographics_uuid = ShardableUuid::new();
1321 let demographics_uuid_str = demographics_uuid.to_string();
1322
1323 let service = ClinicalService::with_id(cfg, clinical_uuid.uuid());
1325 let err = service
1326 .link_to_demographics(
1327 &author,
1328 NonEmptyText::new("Test Hospital").unwrap(),
1329 &demographics_uuid_str,
1330 None,
1331 )
1332 .expect_err("link_to_demographics should fail when ehr_status.yaml is missing");
1333
1334 assert!(
1335 matches!(err, PatientError::InvalidInput(_)),
1336 "Should return InvalidInput error when ehr_status.yaml is missing"
1337 );
1338 }
1339
1340 #[test]
1341 fn test_link_to_demographics_rejects_corrupted_ehr_status() {
1342 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1343 let cfg = test_cfg(temp_dir.path());
1344
1345 let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
1347 let clinical_uuid = ShardableUuid::new();
1348 let patient_dir = clinical_uuid.sharded_dir(&clinical_dir);
1349 fs::create_dir_all(&patient_dir).expect("Failed to create patient dir");
1350
1351 VersionedFileService::init(&patient_dir).expect("Failed to init git");
1353
1354 let ehr_status_file = patient_dir.join("ehr_status.yaml");
1356 fs::write(&ehr_status_file, "archetype_node_id: some_id\nname: Test")
1357 .expect("Failed to write corrupted file");
1358
1359 let author = Author {
1360 name: NonEmptyText::new("Test Author").unwrap(),
1361 role: NonEmptyText::new("Clinician").unwrap(),
1362 email: EmailAddress::parse("test@example.com").unwrap(),
1363 registrations: vec![],
1364 signature: None,
1365 certificate: None,
1366 };
1367
1368 let demographics_uuid = ShardableUuid::new();
1369 let demographics_uuid_str = demographics_uuid.to_string();
1370
1371 let service = ClinicalService::with_id(cfg, clinical_uuid.uuid());
1372 let err = service
1373 .link_to_demographics(
1374 &author,
1375 NonEmptyText::new("Test Hospital").unwrap(),
1376 &demographics_uuid_str,
1377 None,
1378 )
1379 .expect_err("expected failure due to corrupted ehr_status");
1380
1381 assert!(matches!(
1383 err,
1384 PatientError::InvalidInput(_)
1385 | PatientError::YamlDeserialization(_)
1386 | PatientError::Openehr(_)
1387 ));
1388 }
1389
1390 #[test]
1391 fn test_verify_commit_signature() {
1392 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1394
1395 let signing_key = SigningKey::random(&mut rand::thread_rng());
1397 let verifying_key = signing_key.verifying_key();
1398
1399 let private_key_pem = signing_key
1401 .to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
1402 .expect("Failed to encode private key");
1403
1404 let public_key_pem = verifying_key
1406 .to_public_key_pem(p256::pkcs8::LineEnding::LF)
1407 .expect("Failed to encode public key");
1408
1409 let cfg = test_cfg(temp_dir.path());
1410 let service = ClinicalService::new(cfg.clone());
1411 let clinical_dir = cfg
1412 .patient_data_dir()
1413 .join(crate::constants::CLINICAL_DIR_NAME);
1414 let author = Author {
1415 name: NonEmptyText::new("Test Author").unwrap(),
1416 role: NonEmptyText::new("Clinician").unwrap(),
1417 email: EmailAddress::parse("test@example.com").unwrap(),
1418 registrations: vec![],
1419 signature: Some(private_key_pem.to_string().into_bytes()),
1420 certificate: None,
1421 };
1422
1423 let result = service.initialise(author, NonEmptyText::new("Test Hospital").unwrap());
1425 assert!(result.is_ok(), "initialise should succeed");
1426 let clinical_uuid = result.unwrap();
1427 let clinical_uuid_str = clinical_uuid.clinical_id().simple().to_string();
1428
1429 let verify_result = VersionedFileService::verify_commit_signature(
1431 &clinical_dir,
1432 &clinical_uuid_str,
1433 &public_key_pem,
1434 );
1435 assert!(
1436 verify_result.is_ok(),
1437 "verify_commit_signature should succeed"
1438 );
1439 assert!(verify_result.unwrap(), "signature should be valid");
1440
1441 let wrong_signing_key = SigningKey::random(&mut rand::thread_rng());
1443 let wrong_pub_pem = wrong_signing_key
1444 .verifying_key()
1445 .to_public_key_pem(p256::pkcs8::LineEnding::LF)
1446 .expect("Failed to encode wrong public key");
1447 let wrong_verify = VersionedFileService::verify_commit_signature(
1448 &clinical_dir,
1449 &clinical_uuid_str,
1450 &wrong_pub_pem,
1451 );
1452 assert!(wrong_verify.is_ok(), "verify with wrong key should succeed");
1453 assert!(
1454 !wrong_verify.unwrap(),
1455 "signature should be invalid with wrong key"
1456 );
1457 }
1458
1459 #[test]
1460 fn test_verify_commit_signature_offline_with_embedded_public_key() {
1461 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1462
1463 let signing_key = SigningKey::random(&mut rand::thread_rng());
1464 let verifying_key = signing_key.verifying_key();
1465
1466 let private_key_pem = signing_key
1467 .to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
1468 .expect("Failed to encode private key");
1469 let public_key_pem = verifying_key
1470 .to_public_key_pem(p256::pkcs8::LineEnding::LF)
1471 .expect("Failed to encode public key");
1472
1473 let cfg = test_cfg(temp_dir.path());
1474 let service = ClinicalService::new(cfg.clone());
1475 let clinical_dir = cfg
1476 .patient_data_dir()
1477 .join(crate::constants::CLINICAL_DIR_NAME);
1478 let author = Author {
1479 name: NonEmptyText::new("Test Author").unwrap(),
1480 role: NonEmptyText::new("Clinician").unwrap(),
1481 email: EmailAddress::parse("test@example.com").unwrap(),
1482 registrations: vec![],
1483 signature: Some(private_key_pem.to_string().into_bytes()),
1484 certificate: None,
1485 };
1486
1487 let clinical_uuid = service
1488 .initialise(author, NonEmptyText::new("Test Hospital").unwrap())
1489 .expect("initialise should succeed");
1490 let clinical_uuid_str = clinical_uuid.clinical_id().simple().to_string();
1491
1492 let ok =
1494 VersionedFileService::verify_commit_signature(&clinical_dir, &clinical_uuid_str, "")
1495 .expect("verify_commit_signature should succeed");
1496 assert!(ok, "embedded public key verification should succeed");
1497
1498 let ok = VersionedFileService::verify_commit_signature(
1500 &clinical_dir,
1501 &clinical_uuid_str,
1502 &public_key_pem,
1503 )
1504 .expect("verify_commit_signature should succeed");
1505 assert!(ok, "verification with explicit public key should succeed");
1506 }
1507
1508 #[test]
1509 fn test_initialise_rejects_mismatched_author_certificate() {
1510 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1511
1512 let signing_key = SigningKey::random(&mut rand::thread_rng());
1514 let private_key_pem = signing_key
1515 .to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
1516 .expect("Failed to encode private key");
1517
1518 let other_key = SigningKey::random(&mut rand::thread_rng());
1520 let other_private_key_pem = other_key
1521 .to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
1522 .expect("Failed to encode other private key");
1523 let other_private_key_pem_str = other_private_key_pem.to_string();
1524
1525 let other_keypair = KeyPair::from_pem(&other_private_key_pem_str)
1526 .expect("KeyPair::from_pem should succeed");
1527 let params = CertificateParams::default();
1528 let cert = params
1529 .self_signed(&other_keypair)
1530 .expect("self_signed should succeed");
1531 let cert_pem = cert.pem();
1532
1533 let cfg = test_cfg(temp_dir.path());
1534 let service = ClinicalService::new(cfg);
1535 let author = Author {
1536 name: NonEmptyText::new("Test Author").unwrap(),
1537 role: NonEmptyText::new("Clinician").unwrap(),
1538 email: EmailAddress::parse("test@example.com").unwrap(),
1539 registrations: vec![],
1540 signature: Some(private_key_pem.to_string().into_bytes()),
1541 certificate: Some(cert_pem.into_bytes()),
1542 };
1543
1544 let err = service
1545 .initialise(author, NonEmptyText::new("Test Hospital").unwrap())
1546 .expect_err("initialise should fail due to certificate mismatch");
1547 assert!(matches!(
1548 err,
1549 PatientError::AuthorCertificatePublicKeyMismatch
1550 ));
1551 }
1552
1553 #[test]
1554 fn test_extract_embedded_commit_signature_from_head_commit() {
1555 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1556
1557 let signing_key = SigningKey::random(&mut rand::thread_rng());
1558 let private_key_pem = signing_key
1559 .to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
1560 .expect("Failed to encode private key");
1561
1562 let cfg = test_cfg(temp_dir.path());
1563 let service = ClinicalService::new(cfg);
1564 let author = Author {
1565 name: NonEmptyText::new("Test Author").unwrap(),
1566 role: NonEmptyText::new("Clinician").unwrap(),
1567 email: EmailAddress::parse("test@example.com").unwrap(),
1568 registrations: vec![],
1569 signature: Some(private_key_pem.to_string().into_bytes()),
1570 certificate: None,
1571 };
1572
1573 let clinical_uuid = service
1574 .initialise(author, NonEmptyText::new("Test Hospital").unwrap())
1575 .expect("initialise should succeed");
1576 let clinical_uuid_str = clinical_uuid.clinical_id().simple().to_string();
1577
1578 let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
1579 let patient_dir = ShardableUuid::parse(&clinical_uuid_str)
1580 .expect("clinical_uuid should be canonical")
1581 .sharded_dir(&clinical_dir);
1582
1583 let repo = git2::Repository::open(&patient_dir).expect("Failed to open Git repo");
1584 let head = repo.head().expect("Failed to get HEAD");
1585 let commit = head.peel_to_commit().expect("Failed to get commit");
1586
1587 let embedded = crate::author::extract_embedded_commit_signature(&commit)
1588 .expect("extract_embedded_commit_signature should succeed");
1589 assert_eq!(embedded.signature.len(), 64);
1590 assert!(!embedded.public_key.is_empty());
1591 assert!(embedded.certificate.is_none());
1592 }
1593
1594 #[test]
1595 fn test_initialise_without_signature_creates_commit_without_embedded_signature() {
1596 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1597
1598 let cfg = test_cfg(temp_dir.path());
1599 let service = ClinicalService::new(cfg.clone());
1600 let clinical_dir = cfg
1601 .patient_data_dir()
1602 .join(crate::constants::CLINICAL_DIR_NAME);
1603
1604 let author = Author {
1605 name: NonEmptyText::new("Test Author").unwrap(),
1606 role: NonEmptyText::new("Clinician").unwrap(),
1607 email: EmailAddress::parse("test@example.com").unwrap(),
1608 registrations: vec![],
1609 signature: None,
1610 certificate: None,
1611 };
1612
1613 let clinical_uuid = service
1614 .initialise(author, NonEmptyText::new("Test Hospital").unwrap())
1615 .expect("initialise should succeed");
1616 let clinical_uuid_str = clinical_uuid.clinical_id().simple().to_string();
1617
1618 let patient_dir = ShardableUuid::parse(&clinical_uuid_str)
1619 .expect("clinical_uuid should be canonical")
1620 .sharded_dir(&clinical_dir);
1621
1622 let repo = git2::Repository::open(&patient_dir).expect("Failed to open Git repo");
1623 let head = repo.head().expect("Failed to get HEAD");
1624 let commit = head.peel_to_commit().expect("Failed to get commit");
1625
1626 assert!(
1627 crate::author::extract_embedded_commit_signature(&commit).is_err(),
1628 "unsigned commits should not contain an embedded signature payload"
1629 );
1630
1631 let ok =
1632 VersionedFileService::verify_commit_signature(&clinical_dir, &clinical_uuid_str, "")
1633 .expect("verify_commit_signature should succeed");
1634 assert!(
1635 !ok,
1636 "verification should be false when no signature is embedded"
1637 );
1638 }
1639
1640 #[test]
1641 fn test_new_letter() {
1642 let patient_data_dir = TempDir::new().expect("Failed to create temp dir");
1643 let cfg = test_cfg(patient_data_dir.path());
1644
1645 let author = Author {
1646 name: NonEmptyText::new("Test Author").unwrap(),
1647 role: NonEmptyText::new("Clinician").unwrap(),
1648 email: EmailAddress::parse("test@example.com").unwrap(),
1649 registrations: vec![],
1650 signature: None,
1651 certificate: None,
1652 };
1653
1654 let service = ClinicalService::new(cfg.clone());
1655 let service = service
1656 .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1657 .expect("initialise should succeed");
1658
1659 let result = service.new_letter(
1660 &author,
1661 NonEmptyText::new("Test Hospital").unwrap(),
1662 NonEmptyText::new("Letter content").unwrap(),
1663 None,
1664 );
1665
1666 assert!(result.is_ok(), "new_letter should return Ok");
1667 let timestamp_id = result.unwrap();
1668 println!("Timestamp ID: {}", timestamp_id);
1669
1670 let timestamp_str = timestamp_id.to_string();
1671 assert!(!timestamp_str.is_empty());
1672 assert!(
1673 timestamp_str.contains("Z-"),
1674 "timestamp_id should contain 'Z-' separator"
1675 );
1676 }
1677
1678 #[test]
1679 fn test_new_letter_with_attachments() {
1680 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1681 let cfg = test_cfg(temp_dir.path());
1682
1683 let service = ClinicalService::new(cfg.clone());
1684 let author = Author {
1685 name: NonEmptyText::new("Dr. Test").unwrap(),
1686 role: NonEmptyText::new("Consultant").unwrap(),
1687 email: EmailAddress::parse("test@example.com").unwrap(),
1688 registrations: vec![],
1689 signature: None,
1690 certificate: None,
1691 };
1692
1693 let service = service
1695 .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1696 .expect("initialise should succeed");
1697
1698 let test_file = temp_dir.path().join("test_document.pdf");
1700 fs::write(&test_file, b"fake PDF content").expect("Failed to create test file");
1701
1702 let attachment_files = vec![test_file.clone()];
1703
1704 let result = service.new_letter_with_attachments(
1706 &author,
1707 NonEmptyText::new("Test Hospital").unwrap(),
1708 &attachment_files,
1709 None,
1710 );
1711
1712 assert!(result.is_ok(), "new_letter_with_attachments should succeed");
1713 let timestamp_id = result.unwrap();
1714
1715 let clinical_uuid = ShardableUuid::parse(&service.clinical_id().simple().to_string())
1717 .expect("should parse clinical UUID");
1718 let patient_dir = service.clinical_patient_dir(&clinical_uuid);
1719 let letter_paths = LetterPaths::new(×tamp_id);
1720
1721 let composition_path = patient_dir.join(letter_paths.composition_yaml());
1723 assert!(composition_path.exists(), "composition.yaml should exist");
1724
1725 let attachment_path = patient_dir.join(letter_paths.attachment("attachment_1.yaml"));
1727 assert!(attachment_path.exists(), "attachment_1.yaml should exist");
1728
1729 let attachment_content =
1731 fs::read_to_string(&attachment_path).expect("should read attachment metadata");
1732 let attachment_metadata: AttachmentMetadata =
1733 serde_yaml::from_str(&attachment_content).expect("should parse attachment metadata");
1734
1735 assert_eq!(
1736 attachment_metadata.metadata_filename.as_str(),
1737 "attachment_1.yaml"
1738 );
1739 assert_eq!(
1740 attachment_metadata.original_filename.as_str(),
1741 "test_document.pdf"
1742 );
1743 assert_eq!(attachment_metadata.size_bytes, 16); assert!(!attachment_metadata.hash.as_str().is_empty());
1745 }
1746
1747 #[test]
1748 fn test_get_letter_attachments() {
1749 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1750 let cfg = test_cfg(temp_dir.path());
1751
1752 let service = ClinicalService::new(cfg.clone());
1753 let author = Author {
1754 name: NonEmptyText::new("Dr. Test").unwrap(),
1755 role: NonEmptyText::new("Consultant").unwrap(),
1756 email: EmailAddress::parse("test@example.com").unwrap(),
1757 registrations: vec![],
1758 signature: None,
1759 certificate: None,
1760 };
1761
1762 let service = service
1764 .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1765 .expect("initialise should succeed");
1766
1767 let test_file1 = temp_dir.path().join("document1.pdf");
1769 let test_file2 = temp_dir.path().join("image.png");
1770 let content1 = b"PDF content here";
1771 let content2 = b"PNG content here";
1772 fs::write(&test_file1, content1).expect("Failed to create test file 1");
1773 fs::write(&test_file2, content2).expect("Failed to create test file 2");
1774
1775 let attachment_files = vec![test_file1, test_file2];
1776
1777 let timestamp_id = service
1779 .new_letter_with_attachments(
1780 &author,
1781 NonEmptyText::new("Test Hospital").unwrap(),
1782 &attachment_files,
1783 None,
1784 )
1785 .expect("new_letter_with_attachments should succeed");
1786
1787 let attachments = service
1789 .get_letter_attachments(×tamp_id.to_string())
1790 .expect("get_letter_attachments should succeed");
1791
1792 assert_eq!(attachments.len(), 2, "should have 2 attachments");
1794
1795 assert_eq!(
1797 attachments[0].metadata.original_filename.as_str(),
1798 "document1.pdf"
1799 );
1800 assert_eq!(attachments[0].metadata.size_bytes, content1.len() as u64);
1801 assert_eq!(attachments[0].content, content1);
1802
1803 assert_eq!(
1805 attachments[1].metadata.original_filename.as_str(),
1806 "image.png"
1807 );
1808 assert_eq!(attachments[1].metadata.size_bytes, content2.len() as u64);
1809 assert_eq!(attachments[1].content, content2);
1810 }
1811
1812 #[test]
1813 fn test_get_letter_attachments_empty() {
1814 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1815 let cfg = test_cfg(temp_dir.path());
1816
1817 let service = ClinicalService::new(cfg.clone());
1818 let author = Author {
1819 name: NonEmptyText::new("Dr. Test").unwrap(),
1820 role: NonEmptyText::new("Consultant").unwrap(),
1821 email: EmailAddress::parse("test@example.com").unwrap(),
1822 registrations: vec![],
1823 signature: None,
1824 certificate: None,
1825 };
1826
1827 let service = service
1829 .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1830 .expect("initialise should succeed");
1831
1832 let timestamp_id = service
1834 .new_letter(
1835 &author,
1836 NonEmptyText::new("Test Hospital").unwrap(),
1837 NonEmptyText::new("Test content").unwrap(),
1838 None,
1839 )
1840 .expect("new_letter should succeed");
1841
1842 let attachments = service
1844 .get_letter_attachments(×tamp_id.to_string())
1845 .expect("get_letter_attachments should succeed");
1846
1847 assert_eq!(attachments.len(), 0, "should have no attachments");
1849 }
1850
1851 #[test]
1852 fn test_read_letter() {
1853 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1854 let cfg = test_cfg(temp_dir.path());
1855
1856 let service = ClinicalService::new(cfg.clone());
1857 let author = Author {
1858 name: NonEmptyText::new("Dr. Test").unwrap(),
1859 role: NonEmptyText::new("Consultant").unwrap(),
1860 email: EmailAddress::parse("test@example.com").unwrap(),
1861 registrations: vec![],
1862 signature: None,
1863 certificate: None,
1864 };
1865
1866 let service = service
1868 .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1869 .expect("initialise should succeed");
1870
1871 let letter_content = "# Test Letter\n\nThis is a test letter.";
1872
1873 let timestamp_id = service
1875 .new_letter(
1876 &author,
1877 NonEmptyText::new("Test Hospital").unwrap(),
1878 NonEmptyText::new(letter_content).unwrap(),
1879 None,
1880 )
1881 .expect("new_letter should succeed");
1882
1883 let result = service
1885 .read_letter(×tamp_id.to_string())
1886 .expect("read_letter should succeed");
1887
1888 assert_eq!(result.body_content.as_str(), letter_content);
1890 assert_eq!(result.letter_data.composer_name, "Dr. Test");
1891 assert_eq!(result.letter_data.composer_role, "Clinical Practitioner");
1892 }
1893
1894 #[test]
1895 fn test_read_letter_invalid_timestamp() {
1896 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1897 let cfg = test_cfg(temp_dir.path());
1898
1899 let service = ClinicalService::new(cfg.clone());
1900 let author = Author {
1901 name: NonEmptyText::new("Dr. Test").unwrap(),
1902 role: NonEmptyText::new("Consultant").unwrap(),
1903 email: EmailAddress::parse("test@example.com").unwrap(),
1904 registrations: vec![],
1905 signature: None,
1906 certificate: None,
1907 };
1908
1909 let service = service
1910 .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1911 .expect("initialise should succeed");
1912
1913 let result = service.read_letter("invalid-timestamp");
1915
1916 assert!(result.is_err());
1917 assert!(matches!(result, Err(PatientError::InvalidInput(_))));
1918 }
1919
1920 #[test]
1921 fn test_read_letter_nonexistent() {
1922 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1923 let cfg = test_cfg(temp_dir.path());
1924
1925 let service = ClinicalService::new(cfg.clone());
1926 let author = Author {
1927 name: NonEmptyText::new("Dr. Test").unwrap(),
1928 role: NonEmptyText::new("Consultant").unwrap(),
1929 email: EmailAddress::parse("test@example.com").unwrap(),
1930 registrations: vec![],
1931 signature: None,
1932 certificate: None,
1933 };
1934
1935 let service = service
1936 .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1937 .expect("initialise should succeed");
1938
1939 let fake_timestamp = "20260125T120000.000Z-550e8400-e29b-41d4-a716-446655440000";
1941 let result = service.read_letter(fake_timestamp);
1942
1943 assert!(result.is_err());
1944 assert!(matches!(result, Err(PatientError::InvalidInput(_))));
1945 }
1946
1947 #[test]
1948 fn test_new_letter_with_attachments_nonexistent_file() {
1949 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1950 let cfg = test_cfg(temp_dir.path());
1951
1952 let service = ClinicalService::new(cfg.clone());
1953 let author = Author {
1954 name: NonEmptyText::new("Dr. Test").unwrap(),
1955 role: NonEmptyText::new("Consultant").unwrap(),
1956 email: EmailAddress::parse("test@example.com").unwrap(),
1957 registrations: vec![],
1958 signature: None,
1959 certificate: None,
1960 };
1961
1962 let service = service
1963 .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1964 .expect("initialise should succeed");
1965
1966 let nonexistent_file = temp_dir.path().join("does_not_exist.pdf");
1968 let attachment_files = vec![nonexistent_file];
1969
1970 let result = service.new_letter_with_attachments(
1971 &author,
1972 NonEmptyText::new("Test Hospital").unwrap(),
1973 &attachment_files,
1974 None,
1975 );
1976
1977 assert!(result.is_err());
1978 assert!(matches!(result, Err(PatientError::InvalidInput(_))));
1979 }
1980
1981 #[test]
1982 fn test_new_letter_with_attachments_multiple_files() {
1983 let temp_dir = TempDir::new().expect("Failed to create temp dir");
1984 let cfg = test_cfg(temp_dir.path());
1985
1986 let service = ClinicalService::new(cfg.clone());
1987 let author = Author {
1988 name: NonEmptyText::new("Dr. Test").unwrap(),
1989 role: NonEmptyText::new("Consultant").unwrap(),
1990 email: EmailAddress::parse("test@example.com").unwrap(),
1991 registrations: vec![],
1992 signature: None,
1993 certificate: None,
1994 };
1995
1996 let service = service
1997 .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1998 .expect("initialise should succeed");
1999
2000 let test_files = vec![
2002 (temp_dir.path().join("doc1.pdf"), b"content1" as &[u8]),
2003 (temp_dir.path().join("doc2.txt"), b"content2"),
2004 (temp_dir.path().join("image.png"), b"content3"),
2005 ];
2006
2007 for (path, content) in &test_files {
2008 fs::write(path, content).expect("Failed to create test file");
2009 }
2010
2011 let attachment_files: Vec<PathBuf> =
2012 test_files.iter().map(|(path, _)| path.clone()).collect();
2013
2014 let timestamp_id = service
2015 .new_letter_with_attachments(
2016 &author,
2017 NonEmptyText::new("Test Hospital").unwrap(),
2018 &attachment_files,
2019 None,
2020 )
2021 .expect("new_letter_with_attachments should succeed");
2022
2023 let clinical_uuid = ShardableUuid::parse(&service.clinical_id().simple().to_string())
2025 .expect("should parse clinical UUID");
2026 let patient_dir = service.clinical_patient_dir(&clinical_uuid);
2027 let letter_paths = LetterPaths::new(×tamp_id);
2028
2029 for i in 1..=3 {
2030 let attachment_path =
2031 patient_dir.join(letter_paths.attachment(&format!("attachment_{}.yaml", i)));
2032 assert!(
2033 attachment_path.exists(),
2034 "attachment_{}.yaml should exist",
2035 i
2036 );
2037 }
2038 }
2039
2040 #[test]
2041 fn test_get_letter_attachments_invalid_timestamp() {
2042 let temp_dir = TempDir::new().expect("Failed to create temp dir");
2043 let cfg = test_cfg(temp_dir.path());
2044
2045 let service = ClinicalService::new(cfg.clone());
2046 let author = Author {
2047 name: NonEmptyText::new("Dr. Test").unwrap(),
2048 role: NonEmptyText::new("Consultant").unwrap(),
2049 email: EmailAddress::parse("test@example.com").unwrap(),
2050 registrations: vec![],
2051 signature: None,
2052 certificate: None,
2053 };
2054
2055 let service = service
2056 .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
2057 .expect("initialise should succeed");
2058
2059 let result = service.get_letter_attachments("invalid-timestamp");
2060
2061 assert!(result.is_err());
2062 assert!(matches!(result, Err(PatientError::InvalidInput(_))));
2063 }
2064
2065 #[test]
2066 fn test_get_letter_attachments_with_clinical_lists() {
2067 let temp_dir = TempDir::new().expect("Failed to create temp dir");
2068 let cfg = test_cfg(temp_dir.path());
2069
2070 let service = ClinicalService::new(cfg.clone());
2071 let author = Author {
2072 name: NonEmptyText::new("Dr. Test").unwrap(),
2073 role: NonEmptyText::new("Consultant").unwrap(),
2074 email: EmailAddress::parse("test@example.com").unwrap(),
2075 registrations: vec![],
2076 signature: None,
2077 certificate: None,
2078 };
2079
2080 let service = service
2081 .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
2082 .expect("initialise should succeed");
2083
2084 let test_file = temp_dir.path().join("report.pdf");
2086 fs::write(&test_file, b"test report content").expect("Failed to create test file");
2087
2088 let clinical_lists = vec![openehr::ClinicalList {
2090 name: "Diagnoses".to_string(),
2091 kind: "diagnoses".to_string(),
2092 items: vec![openehr::ClinicalListItem {
2093 text: "Hypertension".to_string(),
2094 code: None,
2095 }],
2096 }];
2097
2098 let timestamp_id = service
2099 .new_letter_with_attachments(
2100 &author,
2101 NonEmptyText::new("Test Hospital").unwrap(),
2102 &[test_file],
2103 Some(&clinical_lists),
2104 )
2105 .expect("new_letter_with_attachments should succeed");
2106
2107 let attachments = service
2109 .get_letter_attachments(×tamp_id.to_string())
2110 .expect("get_letter_attachments should succeed");
2111
2112 assert_eq!(attachments.len(), 1);
2113 assert_eq!(
2114 attachments[0].metadata.original_filename.as_str(),
2115 "report.pdf"
2116 );
2117 }
2118
2119 #[test]
2120 fn test_attachment_metadata_serialization() {
2121 let metadata = AttachmentMetadata {
2122 metadata_filename: NonEmptyText::new("attachment_1.yaml").unwrap(),
2123 hash: Sha256Hash::parse(
2124 "abc123def456789012345678901234567890123456789012345678901234abcd",
2125 )
2126 .unwrap(),
2127 file_storage_path: NonEmptyText::new("files/sha256/ab/c1/abc123").unwrap(),
2128 size_bytes: 1024,
2129 media_type: Some(NonEmptyText::new("application/pdf").unwrap()),
2130 original_filename: NonEmptyText::new("test.pdf").unwrap(),
2131 };
2132
2133 let yaml = serde_yaml::to_string(&metadata).expect("should serialize");
2135 assert!(yaml.contains("filename:"));
2136 assert!(yaml.contains("test.pdf"));
2137
2138 let deserialized: AttachmentMetadata =
2140 serde_yaml::from_str(&yaml).expect("should deserialize");
2141 assert_eq!(deserialized.metadata_filename, metadata.metadata_filename);
2142 assert_eq!(deserialized.original_filename, metadata.original_filename);
2143 assert_eq!(deserialized.hash, metadata.hash);
2144 assert_eq!(deserialized.size_bytes, metadata.size_bytes);
2145 }
2146}