1use async_trait::async_trait;
47use chrono::{DateTime, Utc};
48use serde::{Deserialize, Serialize};
49use serde_json::Value;
50use uuid::Uuid;
51
52#[cfg(feature = "audit")]
54pub mod audit {
55 use helios_audit::{AuditAction, AuditEventBuilder, AuditSink};
56
57 use super::ExportLevel;
58
59 #[allow(clippy::too_many_arguments)]
63 pub async fn record_export_event(
64 sink: &dyn AuditSink,
65 source_observer: &str,
66 agent: Option<&str>,
67 job_id: &str,
68 level: &ExportLevel,
69 resource_types: &[String],
70 outcome: &str,
71 outcome_desc: Option<&str>,
72 ) {
73 let mut builder = AuditEventBuilder::new(source_observer)
74 .event_type(
75 "http://terminology.hl7.org/CodeSystem/audit-event-type",
76 "object",
77 )
78 .action(AuditAction::Execute)
79 .outcome(outcome)
80 .detail("audit-operation", "bulk-export")
81 .detail("job-id", job_id)
82 .detail("export-level", level.to_string());
83 if !resource_types.is_empty() {
84 builder = builder.detail("resource-types", resource_types.join(","));
85 }
86 if let Some(a) = agent {
87 builder = builder.agent(a, None, true);
88 }
89 if let Some(d) = outcome_desc {
90 builder = builder.outcome_desc(d);
91 }
92 sink.record(builder.build()).await;
93 }
94
95 #[cfg(test)]
96 mod tests {
97 use helios_audit::sinks::NullSink;
98
99 use super::*;
100 use crate::core::bulk_export::ExportLevel;
101
102 #[tokio::test]
103 async fn test_export_event_has_job_id() {
104 let sink = NullSink;
105 record_export_event(
106 &sink,
107 "Device/hfs",
108 Some("Practitioner/dr-1"),
109 "job-abc",
110 &ExportLevel::System,
111 &["Patient".to_string()],
112 "0",
113 None,
114 )
115 .await;
116 }
118
119 #[test]
120 fn test_export_event_type_is_object_for_bulk_export() {
121 let event = AuditEventBuilder::new("Device/hfs")
122 .event_type(
123 "http://terminology.hl7.org/CodeSystem/audit-event-type",
124 "object",
125 )
126 .action(AuditAction::Execute)
127 .outcome("0")
128 .detail("audit-operation", "bulk-export")
129 .detail("job-id", "j1")
130 .build();
131 assert_eq!(
132 event.r#type.code.as_ref().and_then(|c| c.value.as_deref()),
133 Some("object")
134 );
135 }
136 }
137}
138
139use crate::error::StorageResult;
140use crate::tenant::TenantContext;
141
142#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
144pub struct ExportJobId(String);
145
146impl ExportJobId {
147 pub fn new() -> Self {
149 Self(Uuid::new_v4().to_string())
150 }
151
152 pub fn from_string(id: impl Into<String>) -> Self {
154 Self(id.into())
155 }
156
157 pub fn as_str(&self) -> &str {
159 &self.0
160 }
161}
162
163impl Default for ExportJobId {
164 fn default() -> Self {
165 Self::new()
166 }
167}
168
169impl std::fmt::Display for ExportJobId {
170 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171 write!(f, "{}", self.0)
172 }
173}
174
175impl From<String> for ExportJobId {
176 fn from(s: String) -> Self {
177 Self(s)
178 }
179}
180
181impl From<&str> for ExportJobId {
182 fn from(s: &str) -> Self {
183 Self(s.to_string())
184 }
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "lowercase")]
190pub enum ExportStatus {
191 Accepted,
193 InProgress,
195 Complete,
197 Error,
199 Cancelled,
201}
202
203impl ExportStatus {
204 pub fn is_terminal(&self) -> bool {
206 matches!(self, Self::Complete | Self::Error | Self::Cancelled)
207 }
208
209 pub fn is_active(&self) -> bool {
211 matches!(self, Self::Accepted | Self::InProgress)
212 }
213}
214
215impl std::fmt::Display for ExportStatus {
216 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217 match self {
218 Self::Accepted => write!(f, "accepted"),
219 Self::InProgress => write!(f, "in-progress"),
220 Self::Complete => write!(f, "complete"),
221 Self::Error => write!(f, "error"),
222 Self::Cancelled => write!(f, "cancelled"),
223 }
224 }
225}
226
227impl std::str::FromStr for ExportStatus {
228 type Err = String;
229
230 fn from_str(s: &str) -> Result<Self, Self::Err> {
231 match s {
232 "accepted" => Ok(Self::Accepted),
233 "in-progress" | "in_progress" => Ok(Self::InProgress),
234 "complete" => Ok(Self::Complete),
235 "error" => Ok(Self::Error),
236 "cancelled" => Ok(Self::Cancelled),
237 _ => Err(format!("unknown export status: {}", s)),
238 }
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(rename_all = "lowercase")]
245pub enum ExportLevel {
246 System,
248 Patient,
250 Group {
252 group_id: String,
254 },
255}
256
257impl ExportLevel {
258 pub fn system() -> Self {
260 Self::System
261 }
262
263 pub fn patient() -> Self {
265 Self::Patient
266 }
267
268 pub fn group(group_id: impl Into<String>) -> Self {
270 Self::Group {
271 group_id: group_id.into(),
272 }
273 }
274}
275
276impl std::fmt::Display for ExportLevel {
277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 match self {
279 Self::System => write!(f, "system"),
280 Self::Patient => write!(f, "patient"),
281 Self::Group { group_id } => write!(f, "group/{}", group_id),
282 }
283 }
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct TypeFilter {
292 pub resource_type: String,
294 pub query: String,
296}
297
298impl TypeFilter {
299 pub fn new(resource_type: impl Into<String>, query: impl Into<String>) -> Self {
301 Self {
302 resource_type: resource_type.into(),
303 query: query.into(),
304 }
305 }
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct ExportRequest {
311 pub level: ExportLevel,
313
314 #[serde(default)]
316 pub resource_types: Vec<String>,
317
318 #[serde(skip_serializing_if = "Option::is_none")]
320 pub since: Option<DateTime<Utc>>,
321
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub until: Option<DateTime<Utc>>,
325
326 #[serde(default)]
328 pub type_filters: Vec<TypeFilter>,
329
330 #[serde(default)]
334 pub elements: Vec<String>,
335
336 #[serde(default)]
339 pub include_associated_data: Vec<String>,
340
341 #[serde(default)]
344 pub patient_refs: Vec<String>,
345
346 #[serde(default = "default_batch_size")]
348 pub batch_size: u32,
349
350 #[serde(default = "default_output_format")]
352 pub output_format: String,
353}
354
355fn default_batch_size() -> u32 {
356 1000
357}
358
359fn default_output_format() -> String {
360 "application/fhir+ndjson".to_string()
361}
362
363impl ExportRequest {
364 pub fn new(level: ExportLevel) -> Self {
366 Self {
367 level,
368 resource_types: Vec::new(),
369 since: None,
370 until: None,
371 type_filters: Vec::new(),
372 elements: Vec::new(),
373 include_associated_data: Vec::new(),
374 patient_refs: Vec::new(),
375 batch_size: default_batch_size(),
376 output_format: default_output_format(),
377 }
378 }
379
380 pub fn system() -> Self {
382 Self::new(ExportLevel::System)
383 }
384
385 pub fn patient() -> Self {
387 Self::new(ExportLevel::Patient)
388 }
389
390 pub fn group(group_id: impl Into<String>) -> Self {
392 Self::new(ExportLevel::Group {
393 group_id: group_id.into(),
394 })
395 }
396
397 pub fn with_types(mut self, types: Vec<String>) -> Self {
399 self.resource_types = types;
400 self
401 }
402
403 pub fn with_since(mut self, since: DateTime<Utc>) -> Self {
405 self.since = Some(since);
406 self
407 }
408
409 pub fn with_until(mut self, until: DateTime<Utc>) -> Self {
411 self.until = Some(until);
412 self
413 }
414
415 pub fn with_elements(mut self, elements: Vec<String>) -> Self {
417 self.elements = elements;
418 self
419 }
420
421 pub fn with_patient_refs(mut self, patient_refs: Vec<String>) -> Self {
423 self.patient_refs = patient_refs;
424 self
425 }
426
427 pub fn with_type_filter(mut self, filter: TypeFilter) -> Self {
429 self.type_filters.push(filter);
430 self
431 }
432
433 pub fn with_type_filters(mut self, filters: Vec<TypeFilter>) -> Self {
435 self.type_filters.extend(filters);
436 self
437 }
438
439 pub fn with_batch_size(mut self, batch_size: u32) -> Self {
441 self.batch_size = batch_size;
442 self
443 }
444
445 pub fn group_id(&self) -> Option<&str> {
447 match &self.level {
448 ExportLevel::Group { group_id } => Some(group_id),
449 _ => None,
450 }
451 }
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct TypeExportProgress {
457 pub resource_type: String,
459 pub total_count: Option<u64>,
461 pub exported_count: u64,
463 pub error_count: u64,
465 #[serde(skip_serializing_if = "Option::is_none")]
467 pub cursor_state: Option<String>,
468}
469
470impl TypeExportProgress {
471 pub fn new(resource_type: impl Into<String>) -> Self {
473 Self {
474 resource_type: resource_type.into(),
475 total_count: None,
476 exported_count: 0,
477 error_count: 0,
478 cursor_state: None,
479 }
480 }
481
482 pub fn with_total(mut self, total: u64) -> Self {
484 self.total_count = Some(total);
485 self
486 }
487
488 pub fn progress_fraction(&self) -> Option<f64> {
490 self.total_count.map(|total| {
491 if total == 0 {
492 1.0
493 } else {
494 self.exported_count as f64 / total as f64
495 }
496 })
497 }
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct ExportProgress {
503 pub job_id: ExportJobId,
505 pub status: ExportStatus,
507 pub level: ExportLevel,
509 pub transaction_time: DateTime<Utc>,
511 #[serde(skip_serializing_if = "Option::is_none")]
513 pub started_at: Option<DateTime<Utc>>,
514 #[serde(skip_serializing_if = "Option::is_none")]
516 pub completed_at: Option<DateTime<Utc>>,
517 pub type_progress: Vec<TypeExportProgress>,
519 #[serde(skip_serializing_if = "Option::is_none")]
521 pub current_type: Option<String>,
522 #[serde(skip_serializing_if = "Option::is_none")]
524 pub error_message: Option<String>,
525}
526
527impl ExportProgress {
528 pub fn accepted(
530 job_id: ExportJobId,
531 level: ExportLevel,
532 transaction_time: DateTime<Utc>,
533 ) -> Self {
534 Self {
535 job_id,
536 status: ExportStatus::Accepted,
537 level,
538 transaction_time,
539 started_at: None,
540 completed_at: None,
541 type_progress: Vec::new(),
542 current_type: None,
543 error_message: None,
544 }
545 }
546
547 pub fn overall_progress(&self) -> f64 {
549 if self.type_progress.is_empty() {
550 return 0.0;
551 }
552
553 let (total_exported, total_count) = self
554 .type_progress
555 .iter()
556 .fold((0u64, 0u64), |(exp, tot), tp| {
557 (exp + tp.exported_count, tot + tp.total_count.unwrap_or(0))
558 });
559
560 if total_count == 0 {
561 0.0
562 } else {
563 total_exported as f64 / total_count as f64
564 }
565 }
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct ExportOutputFile {
571 #[serde(rename = "type")]
573 pub resource_type: String,
574 pub url: String,
576 #[serde(skip_serializing_if = "Option::is_none")]
578 pub count: Option<u64>,
579}
580
581impl ExportOutputFile {
582 pub fn new(resource_type: impl Into<String>, url: impl Into<String>) -> Self {
584 Self {
585 resource_type: resource_type.into(),
586 url: url.into(),
587 count: None,
588 }
589 }
590
591 pub fn with_count(mut self, count: u64) -> Self {
593 self.count = Some(count);
594 self
595 }
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize)]
602pub struct ExportManifest {
603 #[serde(rename = "transactionTime")]
605 pub transaction_time: DateTime<Utc>,
606 pub request: String,
608 #[serde(rename = "requiresAccessToken")]
610 pub requires_access_token: bool,
611 pub output: Vec<ExportOutputFile>,
613 #[serde(default)]
615 pub error: Vec<ExportOutputFile>,
616 #[serde(default)]
618 pub deleted: Vec<ExportOutputFile>,
619 #[serde(default)]
621 pub link: Vec<String>,
622 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub message: Option<String>,
625 #[serde(default, skip_serializing_if = "Option::is_none")]
627 pub extension: Option<Value>,
628}
629
630impl ExportManifest {
631 pub fn new(transaction_time: DateTime<Utc>, request: impl Into<String>) -> Self {
633 Self {
634 transaction_time,
635 request: request.into(),
636 requires_access_token: true,
637 output: Vec::new(),
638 error: Vec::new(),
639 deleted: Vec::new(),
640 link: Vec::new(),
641 message: None,
642 extension: None,
643 }
644 }
645
646 pub fn with_output(mut self, file: ExportOutputFile) -> Self {
648 self.output.push(file);
649 self
650 }
651
652 pub fn with_error(mut self, file: ExportOutputFile) -> Self {
654 self.error.push(file);
655 self
656 }
657
658 pub fn with_message(mut self, message: impl Into<String>) -> Self {
660 self.message = Some(message.into());
661 self
662 }
663}
664
665#[derive(Debug, Clone)]
667pub struct NdjsonBatch {
668 pub lines: Vec<String>,
670 pub next_cursor: Option<String>,
672 pub is_last: bool,
674}
675
676impl NdjsonBatch {
677 pub fn new(lines: Vec<String>) -> Self {
679 Self {
680 lines,
681 next_cursor: None,
682 is_last: false,
683 }
684 }
685
686 pub fn empty() -> Self {
688 Self {
689 lines: Vec::new(),
690 next_cursor: None,
691 is_last: true,
692 }
693 }
694
695 pub fn with_cursor(mut self, cursor: impl Into<String>) -> Self {
697 self.next_cursor = Some(cursor.into());
698 self
699 }
700
701 pub fn as_last(mut self) -> Self {
703 self.is_last = true;
704 self.next_cursor = None;
705 self
706 }
707
708 pub fn len(&self) -> usize {
710 self.lines.len()
711 }
712
713 pub fn is_empty(&self) -> bool {
715 self.lines.is_empty()
716 }
717}
718
719#[derive(Debug, Clone)]
726pub struct StartExportInput {
727 pub request: ExportRequest,
729 pub transaction_time: DateTime<Utc>,
731 pub request_url: String,
733 pub owner_subject: Option<String>,
735 pub fhir_version: helios_fhir::FhirVersion,
737}
738
739#[derive(Debug, Clone)]
741pub struct RawManifestEntry {
742 pub resource_type: String,
744 pub key: crate::core::bulk_export_output::ExportPartKey,
746 pub count: u64,
748}
749
750#[derive(Debug, Clone)]
756pub struct RawExportManifest {
757 pub transaction_time: DateTime<Utc>,
759 pub request_url: String,
761 pub status: ExportStatus,
763 pub error_message: Option<String>,
765 pub completed_at: Option<DateTime<Utc>>,
767 pub output: Vec<RawManifestEntry>,
769 pub errors: Vec<RawManifestEntry>,
771}
772
773#[derive(Debug, Clone)]
778pub struct ExportJobMetadata {
779 pub job_id: ExportJobId,
781 pub status: ExportStatus,
783 pub level: ExportLevel,
785 pub owner_subject: Option<String>,
787 pub transaction_time: DateTime<Utc>,
789 pub completed_at: Option<DateTime<Utc>>,
791 pub request_url: String,
793}
794
795#[derive(Debug, Clone)]
797pub struct ExportFileMetadata {
798 pub key: crate::core::bulk_export_output::ExportPartKey,
800 pub resource_type: String,
802 pub file_type: String,
804 pub line_count: u64,
806 pub job_owner_subject: Option<String>,
808}
809
810#[derive(Debug, Clone)]
812pub struct ExpiredExportRef {
813 pub tenant: TenantContext,
815 pub job_id: ExportJobId,
817}
818
819#[async_trait]
828pub trait BulkExportStorage: Send + Sync {
829 async fn start_export(
846 &self,
847 tenant: &TenantContext,
848 input: StartExportInput,
849 ) -> StorageResult<ExportJobId>;
850
851 async fn get_export_status(
866 &self,
867 tenant: &TenantContext,
868 job_id: &ExportJobId,
869 ) -> StorageResult<ExportProgress>;
870
871 async fn cancel_export(
883 &self,
884 tenant: &TenantContext,
885 job_id: &ExportJobId,
886 ) -> StorageResult<()>;
887
888 async fn delete_export(
899 &self,
900 tenant: &TenantContext,
901 job_id: &ExportJobId,
902 ) -> StorageResult<()>;
903
904 async fn get_export_manifest(
914 &self,
915 tenant: &TenantContext,
916 job_id: &ExportJobId,
917 ) -> StorageResult<RawExportManifest>;
918
919 async fn list_exports(
930 &self,
931 tenant: &TenantContext,
932 include_completed: bool,
933 ) -> StorageResult<Vec<ExportProgress>>;
934
935 async fn get_export_job_metadata(
943 &self,
944 tenant: &TenantContext,
945 job_id: &ExportJobId,
946 ) -> StorageResult<ExportJobMetadata>;
947
948 async fn get_export_file_metadata(
955 &self,
956 tenant: &TenantContext,
957 job_id: &ExportJobId,
958 part: &str,
959 ) -> StorageResult<ExportFileMetadata>;
960
961 async fn count_active_exports(&self, tenant: &TenantContext) -> StorageResult<u64>;
964
965 async fn list_expired_exports(
970 &self,
971 now: DateTime<Utc>,
972 output_ttl: std::time::Duration,
973 limit: u32,
974 ) -> StorageResult<Vec<ExpiredExportRef>>;
975}
976
977#[async_trait]
982pub trait ExportDataProvider: Send + Sync {
983 async fn list_export_types(
994 &self,
995 tenant: &TenantContext,
996 request: &ExportRequest,
997 ) -> StorageResult<Vec<String>>;
998
999 async fn count_export_resources(
1011 &self,
1012 tenant: &TenantContext,
1013 request: &ExportRequest,
1014 resource_type: &str,
1015 ) -> StorageResult<u64>;
1016
1017 async fn fetch_export_batch(
1031 &self,
1032 tenant: &TenantContext,
1033 request: &ExportRequest,
1034 resource_type: &str,
1035 cursor: Option<&str>,
1036 batch_size: u32,
1037 ) -> StorageResult<NdjsonBatch>;
1038}
1039
1040#[async_trait]
1045pub trait PatientExportProvider: ExportDataProvider {
1046 async fn list_patient_ids(
1059 &self,
1060 tenant: &TenantContext,
1061 request: &ExportRequest,
1062 cursor: Option<&str>,
1063 batch_size: u32,
1064 ) -> StorageResult<(Vec<String>, Option<String>)>;
1065
1066 async fn fetch_patient_compartment_batch(
1081 &self,
1082 tenant: &TenantContext,
1083 request: &ExportRequest,
1084 resource_type: &str,
1085 patient_ids: &[String],
1086 cursor: Option<&str>,
1087 batch_size: u32,
1088 ) -> StorageResult<NdjsonBatch>;
1089}
1090
1091#[async_trait]
1096pub trait GroupExportProvider: PatientExportProvider {
1097 async fn get_group_members(
1112 &self,
1113 tenant: &TenantContext,
1114 group_id: &str,
1115 ) -> StorageResult<Vec<String>>;
1116
1117 async fn resolve_group_patient_ids(
1131 &self,
1132 tenant: &TenantContext,
1133 group_id: &str,
1134 ) -> StorageResult<Vec<String>>;
1135
1136 async fn get_group_members_with_periods(
1144 &self,
1145 tenant: &TenantContext,
1146 group_id: &str,
1147 ) -> StorageResult<Vec<(String, Option<DateTime<Utc>>)>> {
1148 let members = self.get_group_members(tenant, group_id).await?;
1149 Ok(members.into_iter().map(|m| (m, None)).collect())
1150 }
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155 use super::*;
1156
1157 #[test]
1158 fn test_export_job_id() {
1159 let id = ExportJobId::new();
1160 assert!(!id.as_str().is_empty());
1161
1162 let id2 = ExportJobId::from_string("test-123");
1163 assert_eq!(id2.as_str(), "test-123");
1164 assert_eq!(id2.to_string(), "test-123");
1165 }
1166
1167 #[test]
1168 fn test_export_status() {
1169 assert!(ExportStatus::Complete.is_terminal());
1170 assert!(ExportStatus::Error.is_terminal());
1171 assert!(ExportStatus::Cancelled.is_terminal());
1172 assert!(!ExportStatus::Accepted.is_terminal());
1173 assert!(!ExportStatus::InProgress.is_terminal());
1174
1175 assert!(ExportStatus::Accepted.is_active());
1176 assert!(ExportStatus::InProgress.is_active());
1177 assert!(!ExportStatus::Complete.is_active());
1178 }
1179
1180 #[test]
1181 fn test_export_status_display_parse() {
1182 let status = ExportStatus::InProgress;
1183 assert_eq!(status.to_string(), "in-progress");
1184
1185 let parsed: ExportStatus = "in-progress".parse().unwrap();
1186 assert_eq!(parsed, ExportStatus::InProgress);
1187
1188 let parsed: ExportStatus = "in_progress".parse().unwrap();
1190 assert_eq!(parsed, ExportStatus::InProgress);
1191 }
1192
1193 #[test]
1194 fn test_export_level() {
1195 let system = ExportLevel::system();
1196 assert!(matches!(system, ExportLevel::System));
1197
1198 let patient = ExportLevel::patient();
1199 assert!(matches!(patient, ExportLevel::Patient));
1200
1201 let group = ExportLevel::group("grp-123");
1202 assert!(matches!(group, ExportLevel::Group { group_id } if group_id == "grp-123"));
1203 }
1204
1205 #[test]
1206 fn test_export_request_builder() {
1207 let request = ExportRequest::system()
1208 .with_types(vec!["Patient".to_string(), "Observation".to_string()])
1209 .with_batch_size(500)
1210 .with_type_filter(TypeFilter::new("Observation", "code=1234"));
1211
1212 assert!(matches!(request.level, ExportLevel::System));
1213 assert_eq!(request.resource_types, vec!["Patient", "Observation"]);
1214 assert_eq!(request.batch_size, 500);
1215 assert_eq!(request.type_filters.len(), 1);
1216 }
1217
1218 #[test]
1219 fn test_export_request_group_id() {
1220 let request = ExportRequest::group("grp-123");
1221 assert_eq!(request.group_id(), Some("grp-123"));
1222
1223 let system_request = ExportRequest::system();
1224 assert_eq!(system_request.group_id(), None);
1225 }
1226
1227 #[test]
1228 fn test_type_export_progress() {
1229 let progress = TypeExportProgress::new("Patient").with_total(100);
1230 assert_eq!(progress.total_count, Some(100));
1231 assert_eq!(progress.progress_fraction(), Some(0.0));
1232
1233 let mut progress = progress;
1234 progress.exported_count = 50;
1235 assert_eq!(progress.progress_fraction(), Some(0.5));
1236 }
1237
1238 #[test]
1239 fn test_export_manifest() {
1240 let manifest = ExportManifest::new(Utc::now(), "https://example.com/$export")
1241 .with_output(
1242 ExportOutputFile::new("Patient", "/exports/Patient.ndjson").with_count(100),
1243 )
1244 .with_message("Export complete");
1245
1246 assert_eq!(manifest.output.len(), 1);
1247 assert_eq!(manifest.output[0].resource_type, "Patient");
1248 assert_eq!(manifest.output[0].count, Some(100));
1249 assert!(manifest.message.is_some());
1250 }
1251
1252 #[test]
1253 fn test_ndjson_batch() {
1254 let batch = NdjsonBatch::new(vec![
1255 r#"{"resourceType":"Patient","id":"1"}"#.to_string(),
1256 r#"{"resourceType":"Patient","id":"2"}"#.to_string(),
1257 ])
1258 .with_cursor("next-page");
1259
1260 assert_eq!(batch.len(), 2);
1261 assert!(!batch.is_empty());
1262 assert!(!batch.is_last);
1263 assert_eq!(batch.next_cursor, Some("next-page".to_string()));
1264
1265 let final_batch = batch.as_last();
1266 assert!(final_batch.is_last);
1267 assert!(final_batch.next_cursor.is_none());
1268 }
1269
1270 #[test]
1271 fn test_ndjson_batch_empty() {
1272 let batch = NdjsonBatch::empty();
1273 assert!(batch.is_empty());
1274 assert!(batch.is_last);
1275 }
1276}