vpr_core/repositories/
demographics.rs

1//! Patient demographics management.
2//!
3//! This module provides functionality for initialising and updating patient
4//! demographic information within the VPR system. It handles:
5//!
6//! - Creation of new patient records with unique UUIDs
7//! - Storage in a sharded directory structure under `patient_data/demographics/`
8//! - Version control using Git with signed commits
9//! - Updates to patient name and birth date information
10//!
11//! ## Storage Layout
12//!
13//! Demographics are stored as YAML files in a sharded structure:
14//!
15//! ```text
16//! demographics/
17//!   <s1>/
18//!     <s2>/
19//!       <uuid>/
20//!         patient.yaml    # FHIR-aligned patient resource
21//!         .git/           # Git repository for versioning
22//! ```
23//!
24//! where `s1` and `s2` are the first four hex characters of the UUID, providing
25//! scalable directory sharding.
26//!
27//! ## Pure Data Operations
28//!
29//! This module contains **only** data operations—no API concerns such as
30//! authentication, HTTP/gRPC servers, or service interfaces. API-level logic
31//! belongs in `api-grpc`, `api-rest`, or `api-shared`.
32
33use crate::author::Author;
34use crate::config::CoreConfig;
35use crate::constants::{DEFAULT_GITIGNORE, DEMOGRAPHICS_DIR_NAME};
36use crate::error::{PatientError, PatientResult};
37use crate::paths::common::GitIgnoreFile;
38use crate::paths::demographics::patient::PatientFile;
39use crate::versioned_files::{
40    DemographicsDomain::Record, FileToWrite, VersionedFileService, VprCommitAction,
41    VprCommitDomain, VprCommitMessage,
42};
43use crate::NonEmptyText;
44use crate::ShardableUuid;
45use api_shared::pb;
46use chrono::Utc;
47use fhir::{NameUse, Patient, PatientData};
48use std::fs;
49use std::path::Path;
50use std::sync::Arc;
51
52// ============================================================================
53// TYPE-STATE MARKERS
54// ============================================================================
55
56/// Marker type: demographics record does not yet exist.
57///
58/// Used in type-state pattern to prevent operations on non-existent records.
59/// Only `initialise()` can be called in this state.
60#[derive(Clone, Copy, Debug)]
61pub struct Uninitialised;
62
63/// Marker type: demographics record exists.
64///
65/// Indicates a valid demographics record with a known UUID.
66/// Enables operations like updating demographics and listing patients.
67#[derive(Clone, Debug)]
68pub struct Initialised {
69    demographics_id: ShardableUuid,
70}
71
72// ============================================================================
73// DEMOGRAPHICS SERVICE
74// ============================================================================
75
76/// Service for managing patient demographics operations.
77///
78/// Uses type-state pattern to enforce correct usage at compile time.
79/// Generic parameter `S` is either `Uninitialised` or `Initialised`.
80///
81/// This service handles creation, updates, and listing of patient demographic
82/// records. All operations are version-controlled via Git repositories in each
83/// patient's directory.
84#[derive(Clone, Debug)]
85pub struct DemographicsService<S> {
86    cfg: Arc<CoreConfig>,
87    state: S,
88}
89
90impl DemographicsService<Uninitialised> {
91    /// Creates a new demographics service in the uninitialised state.
92    ///
93    /// # Arguments
94    ///
95    /// * `cfg` - Core configuration containing patient data directory paths
96    pub fn new(cfg: Arc<CoreConfig>) -> Self {
97        Self {
98            cfg,
99            state: Uninitialised,
100        }
101    }
102}
103
104impl DemographicsService<Uninitialised> {
105    /// Initialises a new patient demographics record.
106    ///
107    /// Creates a new patient with a unique UUID, stores the initial demographics
108    /// in a YAML file within a sharded directory structure, and initialises a Git
109    /// repository for version control.
110    ///
111    /// **This method consumes `self`** and returns a new `DemographicsService<Initialised>` on success,
112    /// enforcing at compile time that you cannot call `initialise()` twice on the same service.
113    ///
114    /// # Arguments
115    ///
116    /// * `author` - Author information for the initial Git commit
117    /// * `care_location` - High-level organisational location for the commit (e.g., hospital name)
118    ///
119    /// # Returns
120    ///
121    /// Returns `DemographicsService<Initialised>` containing the newly created demographics record.
122    /// Use [`demographics_id()`](DemographicsService::demographics_id) to get the UUID.
123    ///
124    /// # Errors
125    ///
126    /// Returns `PatientError` if:
127    /// - YAML serialisation of patient data fails
128    /// - Patient directory cannot be created
129    /// - `patient.yaml` file cannot be written
130    /// - Git repository initialisation or commit fails
131    /// - Cleanup of a partially-created record directory fails ([`PatientError::CleanupAfterInitialiseFailed`])
132    ///
133    /// # Safety & Rollback
134    ///
135    /// If any operation fails during initialisation, this method attempts to clean up the
136    /// partially-created patient directory. If cleanup also fails, a
137    /// [`PatientError::CleanupAfterInitialiseFailed`] is returned with details of both errors.
138    pub fn initialise(
139        self,
140        author: Author,
141        care_location: NonEmptyText,
142    ) -> PatientResult<DemographicsService<Initialised>> {
143        author.validate_commit_author()?;
144
145        let commit_message = VprCommitMessage::new(
146            VprCommitDomain::Demographics(Record),
147            VprCommitAction::Create,
148            "Demographics record created",
149            care_location,
150        )?;
151
152        let data_dir = self.cfg.patient_data_dir();
153        let demographics_dir = data_dir.join(DEMOGRAPHICS_DIR_NAME);
154
155        let demographics_uuid = ShardableUuid::new();
156        let patient_dir = demographics_uuid.sharded_dir(&demographics_dir);
157        let created_at = Utc::now();
158
159        let patient_data = PatientData {
160            id: demographics_uuid.clone(),
161            use_type: None,
162            family: None,
163            given: vec![],
164            birth_date: None,
165            last_updated: Some(created_at),
166        };
167
168        let patient_data_raw = Patient::render(&patient_data)?;
169
170        let files = [
171            FileToWrite {
172                relative_path: Path::new(GitIgnoreFile::NAME),
173                content: DEFAULT_GITIGNORE,
174                old_content: None,
175            },
176            FileToWrite {
177                relative_path: Path::new(PatientFile::NAME),
178                content: &patient_data_raw,
179                old_content: None,
180            },
181        ];
182
183        VersionedFileService::init_and_commit(&patient_dir, &author, &commit_message, &files)?;
184
185        Ok(DemographicsService {
186            cfg: self.cfg,
187            state: Initialised {
188                demographics_id: demographics_uuid,
189            },
190        })
191    }
192}
193
194impl DemographicsService<Initialised> {
195    /// Creates a demographics service for an existing record.
196    ///
197    /// Use this when you already have a demographics record and want to perform
198    /// operations on it, such as updating demographics or listing patients.
199    ///
200    /// # Arguments
201    ///
202    /// * `cfg` - Core configuration containing patient data directory paths
203    /// * `demographics_id` - UUID string of the existing demographics record
204    pub fn with_id(cfg: Arc<CoreConfig>, demographics_id: &str) -> PatientResult<Self> {
205        let demographics_uuid = ShardableUuid::parse(demographics_id)?;
206        Ok(Self {
207            cfg,
208            state: Initialised {
209                demographics_id: demographics_uuid,
210            },
211        })
212    }
213
214    /// Returns the demographics UUID.
215    pub fn demographics_id(&self) -> &ShardableUuid {
216        &self.state.demographics_id
217    }
218}
219
220impl DemographicsService<Initialised> {
221    /// Updates the demographics of an existing patient.
222    ///
223    /// Reads the existing patient YAML file, updates the name and birth date fields,
224    /// and writes the changes back to the file. This operation does not create a new
225    /// Git commit—callers must commit changes separately if needed.
226    ///
227    /// # Arguments
228    ///
229    /// * `given_names` - Vector of given names for the patient
230    /// * `last_name` - Family/last name of the patient
231    /// * `birth_date` - Birth date of the patient as a string
232    ///
233    /// # Errors
234    ///
235    /// Returns `PatientError` if:
236    /// - `patient.yaml` file cannot be read, deserialised, serialised, or written
237    pub fn update(
238        &self,
239        given_names: Vec<NonEmptyText>,
240        last_name: &str,
241        birth_date: &str,
242    ) -> PatientResult<()> {
243        let data_dir = self.cfg.patient_data_dir();
244        let demographics_dir = data_dir.join(DEMOGRAPHICS_DIR_NAME);
245
246        let patient_dir = self.demographics_id().sharded_dir(&demographics_dir);
247        let filename = patient_dir.join(PatientFile::NAME);
248
249        // Read existing patient.yaml
250        let existing_yaml = fs::read_to_string(&filename).map_err(PatientError::FileRead)?;
251        let mut patient_data = Patient::parse(&existing_yaml)?;
252
253        // Update only the specified fields
254        patient_data.use_type = Some(NameUse::Official);
255        patient_data.family = Some(
256            NonEmptyText::new(last_name).map_err(|e| PatientError::InvalidInput(e.to_string()))?,
257        );
258        patient_data.given = given_names;
259        patient_data.birth_date = Some(
260            birth_date
261                .parse()
262                .map_err(|e| PatientError::InvalidInput(format!("Invalid birth date: {}", e)))?,
263        );
264
265        // Write back the updated YAML
266        let yaml = Patient::render(&patient_data)?;
267        fs::write(&filename, yaml).map_err(PatientError::FileWrite)?;
268
269        Ok(())
270    }
271}
272
273// ============================================================================
274// SHARED OPERATIONS (AVAILABLE ON BOTH STATES)
275// ============================================================================
276
277impl<S> DemographicsService<S> {
278    /// Lists all patient records from the file system.
279    ///
280    /// Traverses the sharded directory structure under `patient_data/demographics/`
281    /// and reads all `patient.yaml` files to reconstruct patient records.
282    ///
283    /// # Returns
284    ///
285    /// Vector of protobuf `Patient` messages containing all found patient records.
286    /// Individual patient files that cannot be parsed are logged as warnings and skipped.
287    ///
288    /// # Directory Structure
289    ///
290    /// Expects patients stored in:
291    /// ```text
292    /// <patient_data_dir>/demographics/<s1>/<s2>/<uuid>/patient.yaml
293    /// ```
294    /// where `s1`/`s2` are the first four hex characters of the UUID.
295    pub fn list_patients(&self) -> Vec<pb::Patient> {
296        let data_dir = self.cfg.patient_data_dir();
297
298        let mut patients = Vec::new();
299
300        let demographics_dir = data_dir.join(DEMOGRAPHICS_DIR_NAME);
301        let s1_iter = match fs::read_dir(&demographics_dir) {
302            Ok(it) => it,
303            Err(_) => return patients,
304        };
305        for s1 in s1_iter.flatten() {
306            let s1_path = s1.path();
307            if !s1_path.is_dir() {
308                continue;
309            }
310
311            let s2_iter = match fs::read_dir(&s1_path) {
312                Ok(it) => it,
313                Err(_) => continue,
314            };
315
316            for s2 in s2_iter.flatten() {
317                let s2_path = s2.path();
318                if !s2_path.is_dir() {
319                    continue;
320                }
321
322                let id_iter = match fs::read_dir(&s2_path) {
323                    Ok(it) => it,
324                    Err(_) => continue,
325                };
326
327                for id_ent in id_iter.flatten() {
328                    let id_path = id_ent.path();
329                    if !id_path.is_dir() {
330                        continue;
331                    }
332
333                    let patient_path = id_path.join(PatientFile::NAME);
334                    if !patient_path.is_file() {
335                        continue;
336                    }
337
338                    if let Ok(contents) = fs::read_to_string(&patient_path) {
339                        match Patient::parse(&contents) {
340                            Ok(patient_data) => {
341                                let id = patient_data.id.to_string();
342
343                                // Extract name information from flat structure
344                                let first_name = patient_data
345                                    .given
346                                    .first()
347                                    .map(|n| n.to_string())
348                                    .unwrap_or_else(|| String::from(""));
349                                let last_name = patient_data
350                                    .family
351                                    .as_ref()
352                                    .map(|n| n.to_string())
353                                    .unwrap_or_else(|| String::from(""));
354                                let created_at = patient_data
355                                    .last_updated
356                                    .map(|dt| dt.to_rfc3339())
357                                    .unwrap_or_default();
358
359                                patients.push(pb::Patient {
360                                    id,
361                                    first_name,
362                                    last_name,
363                                    created_at,
364                                    national_id: String::new(), // Not implemented in current demographics
365                                });
366                            }
367                            Err(e) => {
368                                tracing::warn!(
369                                    "failed to parse patient.yaml: {} - {}",
370                                    patient_path.display(),
371                                    e
372                                );
373                            }
374                        }
375                    }
376                }
377            }
378        }
379
380        patients
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::constants::DEMOGRAPHICS_DIR_NAME;
388    use crate::{EmailAddress, NonEmptyText};
389    use chrono::NaiveDate;
390    use std::fs;
391    use tempfile::TempDir;
392
393    fn test_author() -> Author {
394        Author {
395            name: NonEmptyText::new("Test Author").unwrap(),
396            role: NonEmptyText::new("Clinician").unwrap(),
397            email: EmailAddress::parse("test@example.com").unwrap(),
398            registrations: vec![],
399            signature: None,
400            certificate: None,
401        }
402    }
403
404    fn test_cfg(patient_data_dir: &Path) -> Arc<CoreConfig> {
405        use crate::config::rm_system_version_from_env_value;
406
407        let rm_system_version = rm_system_version_from_env_value(None)
408            .expect("rm_system_version_from_env_value should succeed");
409
410        Arc::new(
411            CoreConfig::new(
412                patient_data_dir.to_path_buf(),
413                rm_system_version,
414                crate::NonEmptyText::new("vpr.dev.1").unwrap(),
415            )
416            .expect("CoreConfig::new should succeed"),
417        )
418    }
419
420    fn count_allocated_patient_dirs(demographics_dir: &Path) -> usize {
421        let mut count = 0;
422        if let Ok(s1_iter) = fs::read_dir(demographics_dir) {
423            for s1 in s1_iter.flatten() {
424                if let Ok(s2_iter) = fs::read_dir(s1.path()) {
425                    for s2 in s2_iter.flatten() {
426                        if let Ok(id_iter) = fs::read_dir(s2.path()) {
427                            for _id in id_iter.flatten() {
428                                count += 1;
429                            }
430                        }
431                    }
432                }
433            }
434        }
435        count
436    }
437
438    #[test]
439    fn test_initialise_creates_demographics_record() {
440        let temp_dir = TempDir::new().expect("Failed to create temp dir");
441        let cfg = test_cfg(temp_dir.path());
442        let service = DemographicsService::new(cfg.clone());
443
444        let author = test_author();
445        let demographics_service = service
446            .initialise(author, NonEmptyText::new("Test Hospital").unwrap())
447            .expect("initialise should succeed");
448
449        let demographics_id = demographics_service.demographics_id();
450        let demographics_dir = temp_dir.path().join(DEMOGRAPHICS_DIR_NAME);
451        let patient_dir = demographics_id.sharded_dir(&demographics_dir);
452
453        assert!(patient_dir.exists(), "patient directory should exist");
454        assert!(
455            patient_dir.join(".git").is_dir(),
456            "git repository should be initialised"
457        );
458        assert!(
459            patient_dir.join(".gitignore").is_file(),
460            ".gitignore file should exist"
461        );
462        assert!(
463            patient_dir.join(PatientFile::NAME).is_file(),
464            "patient.yaml file should exist"
465        );
466
467        // Verify patient.yaml content
468        let yaml_content = fs::read_to_string(patient_dir.join(PatientFile::NAME))
469            .expect("should read patient.yaml");
470        let patient_data = Patient::parse(&yaml_content).expect("should parse patient.yaml");
471
472        assert_eq!(patient_data.id.uuid(), demographics_id.uuid());
473        assert_eq!(patient_data.use_type, None);
474        assert_eq!(patient_data.family, None);
475        assert_eq!(patient_data.given.len(), 0);
476        assert_eq!(patient_data.birth_date, None);
477        assert!(patient_data.last_updated.is_some());
478    }
479
480    #[test]
481    fn test_initialise_fails_fast_on_invalid_author_and_creates_no_files() {
482        let temp_dir = TempDir::new().expect("Failed to create temp dir");
483        let cfg = test_cfg(temp_dir.path());
484        let _service = DemographicsService::new(cfg);
485
486        // NonEmptyText validation prevents empty strings at the type level
487        let err =
488            NonEmptyText::new("").expect_err("creating NonEmptyText from empty string should fail");
489
490        assert!(
491            matches!(err, crate::TextError::Empty),
492            "should return TextError::Empty"
493        );
494
495        let demographics_dir = temp_dir.path().join(DEMOGRAPHICS_DIR_NAME);
496        assert_eq!(
497            count_allocated_patient_dirs(&demographics_dir),
498            0,
499            "no patient directories should be created"
500        );
501    }
502
503    #[test]
504    fn test_initialise_rejects_missing_care_location_and_creates_no_files() {
505        let temp_dir = TempDir::new().expect("Failed to create temp dir");
506        let cfg = test_cfg(temp_dir.path());
507        let _service = DemographicsService::new(cfg);
508
509        let _author = test_author();
510        // NonEmptyText validation prevents empty strings at the type level
511        let err =
512            NonEmptyText::new("").expect_err("creating NonEmptyText from empty string should fail");
513
514        assert!(
515            matches!(err, crate::TextError::Empty),
516            "should return TextError::Empty"
517        );
518
519        let demographics_dir = temp_dir.path().join(DEMOGRAPHICS_DIR_NAME);
520        assert_eq!(
521            count_allocated_patient_dirs(&demographics_dir),
522            0,
523            "no patient directories should be created"
524        );
525    }
526
527    #[test]
528    fn test_initialise_cleans_up_on_failure() {
529        let temp_dir = TempDir::new().expect("Failed to create temp dir");
530        let cfg = test_cfg(temp_dir.path());
531        let _service = DemographicsService::new(cfg);
532
533        let _author = test_author();
534
535        // Trigger a validation failure - NonEmptyText prevents empty strings at type level
536        let _err =
537            NonEmptyText::new("").expect_err("creating NonEmptyText from empty string should fail");
538
539        let demographics_dir = temp_dir.path().join(DEMOGRAPHICS_DIR_NAME);
540        assert_eq!(
541            count_allocated_patient_dirs(&demographics_dir),
542            0,
543            "no patient directories should exist after failure"
544        );
545    }
546
547    #[test]
548    fn test_with_id_creates_initialised_service() {
549        let temp_dir = TempDir::new().expect("Failed to create temp dir");
550        let cfg = test_cfg(temp_dir.path());
551
552        let demographics_uuid = ShardableUuid::new();
553        let demographics_id_str = demographics_uuid.to_string();
554
555        let service = DemographicsService::with_id(cfg, &demographics_id_str)
556            .expect("with_id should succeed");
557
558        assert_eq!(
559            service.demographics_id().to_string(),
560            demographics_id_str,
561            "demographics_id should match"
562        );
563    }
564
565    #[test]
566    fn test_with_id_rejects_invalid_uuid() {
567        let temp_dir = TempDir::new().expect("Failed to create temp dir");
568        let cfg = test_cfg(temp_dir.path());
569
570        let err = DemographicsService::with_id(cfg, "not-a-valid-uuid")
571            .expect_err("with_id should fail with invalid UUID");
572
573        assert!(
574            matches!(err, PatientError::Uuid(_)),
575            "should return Uuid error"
576        );
577    }
578
579    #[test]
580    fn test_update_modifies_patient_data() {
581        let temp_dir = TempDir::new().expect("Failed to create temp dir");
582        let cfg = test_cfg(temp_dir.path());
583        let service = DemographicsService::new(cfg);
584
585        let author = test_author();
586        let demographics_service = service
587            .initialise(author, NonEmptyText::new("Test Hospital").unwrap())
588            .expect("initialise should succeed");
589
590        // Update demographics
591        demographics_service
592            .update(
593                vec![
594                    NonEmptyText::new("John").unwrap(),
595                    NonEmptyText::new("Paul").unwrap(),
596                ],
597                "Smith",
598                "1990-01-15",
599            )
600            .expect("update should succeed");
601
602        // Read and verify updated data
603        let demographics_dir = temp_dir.path().join(DEMOGRAPHICS_DIR_NAME);
604        let patient_dir = demographics_service
605            .demographics_id()
606            .sharded_dir(&demographics_dir);
607        let yaml_content = fs::read_to_string(patient_dir.join(PatientFile::NAME))
608            .expect("should read patient.yaml");
609        let patient_data = Patient::parse(&yaml_content).expect("should parse patient.yaml");
610
611        assert_eq!(patient_data.use_type, Some(NameUse::Official));
612        assert_eq!(
613            patient_data.family,
614            Some(NonEmptyText::new("Smith").unwrap())
615        );
616        assert_eq!(
617            patient_data.given,
618            vec![
619                NonEmptyText::new("John").unwrap(),
620                NonEmptyText::new("Paul").unwrap()
621            ]
622        );
623        assert_eq!(
624            patient_data.birth_date,
625            Some(NaiveDate::from_ymd_opt(1990, 1, 15).unwrap())
626        );
627    }
628
629    #[test]
630    fn test_list_patients_returns_empty_for_nonexistent_directory() {
631        let temp_dir = TempDir::new().expect("Failed to create temp dir");
632        let cfg = test_cfg(temp_dir.path());
633        let service = DemographicsService::new(cfg);
634
635        let patients = service.list_patients();
636        assert_eq!(patients.len(), 0, "should return empty list");
637    }
638
639    #[test]
640    fn test_list_patients_returns_created_patients() {
641        let temp_dir = TempDir::new().expect("Failed to create temp dir");
642        let cfg = test_cfg(temp_dir.path());
643
644        // Create first patient
645        let service1 = DemographicsService::new(cfg.clone());
646        let demographics_service1 = service1
647            .initialise(test_author(), NonEmptyText::new("Test Hospital").unwrap())
648            .expect("initialise should succeed");
649        demographics_service1
650            .update(
651                vec![NonEmptyText::new("Alice").unwrap()],
652                "Smith",
653                "1990-01-15",
654            )
655            .expect("update should succeed");
656
657        // Create second patient
658        let service2 = DemographicsService::new(cfg.clone());
659        let demographics_service2 = service2
660            .initialise(test_author(), NonEmptyText::new("Test Hospital").unwrap())
661            .expect("initialise should succeed");
662        demographics_service2
663            .update(
664                vec![NonEmptyText::new("Bob").unwrap()],
665                "Jones",
666                "1985-06-20",
667            )
668            .expect("update should succeed");
669
670        // List all patients
671        let list_service = DemographicsService::new(cfg);
672        let patients = list_service.list_patients();
673
674        assert_eq!(patients.len(), 2, "should return 2 patients");
675
676        // Verify patient data (order not guaranteed)
677        let alice = patients.iter().find(|p| p.first_name == "Alice");
678        let bob = patients.iter().find(|p| p.first_name == "Bob");
679
680        assert!(alice.is_some(), "should find Alice");
681        assert!(bob.is_some(), "should find Bob");
682
683        let alice = alice.unwrap();
684        assert_eq!(alice.last_name, "Smith");
685        assert!(!alice.created_at.is_empty());
686
687        let bob = bob.unwrap();
688        assert_eq!(bob.last_name, "Jones");
689        assert!(!bob.created_at.is_empty());
690    }
691
692    #[test]
693    fn test_list_patients_skips_invalid_yaml() {
694        let temp_dir = TempDir::new().expect("Failed to create temp dir");
695        let cfg = test_cfg(temp_dir.path());
696
697        // Create valid patient
698        let service1 = DemographicsService::new(cfg.clone());
699        let demographics_service1 = service1
700            .initialise(test_author(), NonEmptyText::new("Test Hospital").unwrap())
701            .expect("initialise should succeed");
702        demographics_service1
703            .update(
704                vec![NonEmptyText::new("Valid").unwrap()],
705                "Patient",
706                "1990-01-15",
707            )
708            .expect("update should succeed");
709
710        // Create invalid patient.yaml manually
711        let demographics_uuid = ShardableUuid::new();
712        let demographics_dir = temp_dir.path().join(DEMOGRAPHICS_DIR_NAME);
713        let invalid_patient_dir = demographics_uuid.sharded_dir(&demographics_dir);
714        fs::create_dir_all(&invalid_patient_dir).expect("should create directory");
715        fs::write(
716            invalid_patient_dir.join(PatientFile::NAME),
717            "invalid: yaml: content: [[[",
718        )
719        .expect("should write invalid yaml");
720
721        // List patients should skip the invalid one
722        let list_service = DemographicsService::new(cfg);
723        let patients = list_service.list_patients();
724
725        assert_eq!(
726            patients.len(),
727            1,
728            "should return only 1 valid patient, skipping invalid"
729        );
730        assert_eq!(patients[0].first_name, "Valid");
731    }
732}