1use async_trait::async_trait;
51use chrono::{DateTime, Utc};
52use serde::{Deserialize, Serialize};
53use serde_json::Value;
54use tokio::io::AsyncBufRead;
55use uuid::Uuid;
56
57use crate::core::storage::ResourceStorage;
58use crate::error::StorageResult;
59use crate::tenant::TenantContext;
60
61#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub struct SubmissionId {
67 pub submitter: String,
69 pub submission_id: String,
71}
72
73impl SubmissionId {
74 pub fn new(submitter: impl Into<String>, submission_id: impl Into<String>) -> Self {
76 Self {
77 submitter: submitter.into(),
78 submission_id: submission_id.into(),
79 }
80 }
81
82 pub fn generate(submitter: impl Into<String>) -> Self {
84 Self {
85 submitter: submitter.into(),
86 submission_id: Uuid::new_v4().to_string(),
87 }
88 }
89}
90
91impl std::fmt::Display for SubmissionId {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 write!(f, "{}/{}", self.submitter, self.submission_id)
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "kebab-case")]
100pub enum SubmissionStatus {
101 InProgress,
103 Complete,
105 Aborted,
107}
108
109impl SubmissionStatus {
110 pub fn is_terminal(&self) -> bool {
112 matches!(self, Self::Complete | Self::Aborted)
113 }
114}
115
116impl std::fmt::Display for SubmissionStatus {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 match self {
119 Self::InProgress => write!(f, "in-progress"),
120 Self::Complete => write!(f, "complete"),
121 Self::Aborted => write!(f, "aborted"),
122 }
123 }
124}
125
126impl std::str::FromStr for SubmissionStatus {
127 type Err = String;
128
129 fn from_str(s: &str) -> Result<Self, Self::Err> {
130 match s {
131 "in-progress" | "in_progress" => Ok(Self::InProgress),
132 "complete" => Ok(Self::Complete),
133 "aborted" => Ok(Self::Aborted),
134 _ => Err(format!("unknown submission status: {}", s)),
135 }
136 }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "kebab-case")]
142pub enum ManifestStatus {
143 Pending,
145 Processing,
147 Completed,
149 Failed,
151}
152
153impl ManifestStatus {
154 pub fn is_terminal(&self) -> bool {
156 matches!(self, Self::Completed | Self::Failed)
157 }
158}
159
160impl std::fmt::Display for ManifestStatus {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 match self {
163 Self::Pending => write!(f, "pending"),
164 Self::Processing => write!(f, "processing"),
165 Self::Completed => write!(f, "completed"),
166 Self::Failed => write!(f, "failed"),
167 }
168 }
169}
170
171impl std::str::FromStr for ManifestStatus {
172 type Err = String;
173
174 fn from_str(s: &str) -> Result<Self, Self::Err> {
175 match s {
176 "pending" => Ok(Self::Pending),
177 "processing" => Ok(Self::Processing),
178 "completed" => Ok(Self::Completed),
179 "failed" => Ok(Self::Failed),
180 _ => Err(format!("unknown manifest status: {}", s)),
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct SubmissionManifest {
188 pub manifest_id: String,
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub manifest_url: Option<String>,
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub replaces_manifest_url: Option<String>,
196 pub status: ManifestStatus,
198 pub added_at: DateTime<Utc>,
200 pub total_entries: u64,
202 pub processed_entries: u64,
204 pub failed_entries: u64,
206}
207
208impl SubmissionManifest {
209 pub fn new(manifest_id: impl Into<String>) -> Self {
211 Self {
212 manifest_id: manifest_id.into(),
213 manifest_url: None,
214 replaces_manifest_url: None,
215 status: ManifestStatus::Pending,
216 added_at: Utc::now(),
217 total_entries: 0,
218 processed_entries: 0,
219 failed_entries: 0,
220 }
221 }
222
223 pub fn with_url(mut self, url: impl Into<String>) -> Self {
225 self.manifest_url = Some(url.into());
226 self
227 }
228
229 pub fn with_replaces(mut self, url: impl Into<String>) -> Self {
231 self.replaces_manifest_url = Some(url.into());
232 self
233 }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(rename_all = "kebab-case")]
239pub enum BulkEntryOutcome {
240 Success,
242 ValidationError,
244 ProcessingError,
246 Skipped,
248}
249
250impl std::fmt::Display for BulkEntryOutcome {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 match self {
253 Self::Success => write!(f, "success"),
254 Self::ValidationError => write!(f, "validation-error"),
255 Self::ProcessingError => write!(f, "processing-error"),
256 Self::Skipped => write!(f, "skipped"),
257 }
258 }
259}
260
261impl std::str::FromStr for BulkEntryOutcome {
262 type Err = String;
263
264 fn from_str(s: &str) -> Result<Self, Self::Err> {
265 match s {
266 "success" => Ok(Self::Success),
267 "validation-error" | "validation_error" => Ok(Self::ValidationError),
268 "processing-error" | "processing_error" => Ok(Self::ProcessingError),
269 "skipped" => Ok(Self::Skipped),
270 _ => Err(format!("unknown entry outcome: {}", s)),
271 }
272 }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct BulkEntryResult {
278 pub line_number: u64,
280 pub resource_type: String,
282 #[serde(skip_serializing_if = "Option::is_none")]
284 pub resource_id: Option<String>,
285 pub created: bool,
287 pub outcome: BulkEntryOutcome,
289 #[serde(skip_serializing_if = "Option::is_none")]
291 pub operation_outcome: Option<Value>,
292}
293
294impl BulkEntryResult {
295 pub fn success(
297 line_number: u64,
298 resource_type: impl Into<String>,
299 resource_id: impl Into<String>,
300 created: bool,
301 ) -> Self {
302 Self {
303 line_number,
304 resource_type: resource_type.into(),
305 resource_id: Some(resource_id.into()),
306 created,
307 outcome: BulkEntryOutcome::Success,
308 operation_outcome: None,
309 }
310 }
311
312 pub fn validation_error(
314 line_number: u64,
315 resource_type: impl Into<String>,
316 outcome: Value,
317 ) -> Self {
318 Self {
319 line_number,
320 resource_type: resource_type.into(),
321 resource_id: None,
322 created: false,
323 outcome: BulkEntryOutcome::ValidationError,
324 operation_outcome: Some(outcome),
325 }
326 }
327
328 pub fn processing_error(
330 line_number: u64,
331 resource_type: impl Into<String>,
332 outcome: Value,
333 ) -> Self {
334 Self {
335 line_number,
336 resource_type: resource_type.into(),
337 resource_id: None,
338 created: false,
339 outcome: BulkEntryOutcome::ProcessingError,
340 operation_outcome: Some(outcome),
341 }
342 }
343
344 pub fn skipped(line_number: u64, resource_type: impl Into<String>, reason: &str) -> Self {
346 Self {
347 line_number,
348 resource_type: resource_type.into(),
349 resource_id: None,
350 created: false,
351 outcome: BulkEntryOutcome::Skipped,
352 operation_outcome: Some(serde_json::json!({
353 "resourceType": "OperationOutcome",
354 "issue": [{
355 "severity": "information",
356 "code": "informational",
357 "diagnostics": reason
358 }]
359 })),
360 }
361 }
362
363 pub fn is_success(&self) -> bool {
365 self.outcome == BulkEntryOutcome::Success
366 }
367
368 pub fn is_error(&self) -> bool {
370 matches!(
371 self.outcome,
372 BulkEntryOutcome::ValidationError | BulkEntryOutcome::ProcessingError
373 )
374 }
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct SubmissionSummary {
380 pub id: SubmissionId,
382 pub status: SubmissionStatus,
384 pub created_at: DateTime<Utc>,
386 pub updated_at: DateTime<Utc>,
388 #[serde(skip_serializing_if = "Option::is_none")]
390 pub completed_at: Option<DateTime<Utc>>,
391 pub manifest_count: u32,
393 pub total_entries: u64,
395 pub success_count: u64,
397 pub error_count: u64,
399 pub skipped_count: u64,
401 #[serde(skip_serializing_if = "Option::is_none")]
403 pub metadata: Option<Value>,
404}
405
406impl SubmissionSummary {
407 pub fn new(id: SubmissionId) -> Self {
409 let now = Utc::now();
410 Self {
411 id,
412 status: SubmissionStatus::InProgress,
413 created_at: now,
414 updated_at: now,
415 completed_at: None,
416 manifest_count: 0,
417 total_entries: 0,
418 success_count: 0,
419 error_count: 0,
420 skipped_count: 0,
421 metadata: None,
422 }
423 }
424
425 pub fn with_metadata(mut self, metadata: Value) -> Self {
427 self.metadata = Some(metadata);
428 self
429 }
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct NdjsonEntry {
435 pub line_number: u64,
437 pub resource_type: String,
439 #[serde(skip_serializing_if = "Option::is_none")]
441 pub resource_id: Option<String>,
442 pub resource: Value,
444}
445
446impl NdjsonEntry {
447 pub fn new(line_number: u64, resource_type: impl Into<String>, resource: Value) -> Self {
449 let resource_type = resource_type.into();
450 let resource_id = resource
451 .get("id")
452 .and_then(|v| v.as_str())
453 .map(String::from);
454 Self {
455 line_number,
456 resource_type,
457 resource_id,
458 resource,
459 }
460 }
461
462 pub fn parse(line_number: u64, line: &str) -> Result<Self, String> {
464 let resource: Value =
465 serde_json::from_str(line).map_err(|e| format!("invalid JSON: {}", e))?;
466
467 let resource_type = resource
468 .get("resourceType")
469 .and_then(|v| v.as_str())
470 .ok_or_else(|| "missing resourceType".to_string())?
471 .to_string();
472
473 Ok(Self::new(line_number, resource_type, resource))
474 }
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct BulkProcessingOptions {
480 #[serde(default = "default_submit_batch_size")]
482 pub batch_size: u32,
483 #[serde(default = "default_continue_on_error")]
485 pub continue_on_error: bool,
486 #[serde(default)]
488 pub max_errors: u32,
489 #[serde(default = "default_allow_updates")]
491 pub allow_updates: bool,
492}
493
494fn default_submit_batch_size() -> u32 {
495 100
496}
497
498fn default_continue_on_error() -> bool {
499 true
500}
501
502fn default_allow_updates() -> bool {
503 true
504}
505
506impl Default for BulkProcessingOptions {
507 fn default() -> Self {
508 Self::new()
509 }
510}
511
512impl BulkProcessingOptions {
513 pub fn new() -> Self {
515 Self {
516 batch_size: default_submit_batch_size(),
517 continue_on_error: default_continue_on_error(),
518 max_errors: 0,
519 allow_updates: default_allow_updates(),
520 }
521 }
522
523 pub fn with_batch_size(mut self, batch_size: u32) -> Self {
525 self.batch_size = batch_size;
526 self
527 }
528
529 pub fn with_continue_on_error(mut self, continue_on_error: bool) -> Self {
531 self.continue_on_error = continue_on_error;
532 self
533 }
534
535 pub fn with_max_errors(mut self, max_errors: u32) -> Self {
537 self.max_errors = max_errors;
538 self
539 }
540
541 pub fn with_allow_updates(mut self, allow_updates: bool) -> Self {
543 self.allow_updates = allow_updates;
544 self
545 }
546
547 pub fn strict() -> Self {
549 Self {
550 batch_size: default_submit_batch_size(),
551 continue_on_error: false,
552 max_errors: 1,
553 allow_updates: true,
554 }
555 }
556
557 pub fn create_only() -> Self {
559 Self {
560 batch_size: default_submit_batch_size(),
561 continue_on_error: true,
562 max_errors: 0,
563 allow_updates: false,
564 }
565 }
566}
567
568#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
570#[serde(rename_all = "lowercase")]
571pub enum ChangeType {
572 Create,
574 Update,
576}
577
578impl std::fmt::Display for ChangeType {
579 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
580 match self {
581 Self::Create => write!(f, "create"),
582 Self::Update => write!(f, "update"),
583 }
584 }
585}
586
587impl std::str::FromStr for ChangeType {
588 type Err = String;
589
590 fn from_str(s: &str) -> Result<Self, Self::Err> {
591 match s {
592 "create" => Ok(Self::Create),
593 "update" => Ok(Self::Update),
594 _ => Err(format!("unknown change type: {}", s)),
595 }
596 }
597}
598
599#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct SubmissionChange {
602 pub change_id: String,
604 pub manifest_id: String,
606 pub change_type: ChangeType,
608 pub resource_type: String,
610 pub resource_id: String,
612 #[serde(skip_serializing_if = "Option::is_none")]
614 pub previous_version: Option<String>,
615 pub new_version: String,
617 #[serde(skip_serializing_if = "Option::is_none")]
619 pub previous_content: Option<Value>,
620 pub changed_at: DateTime<Utc>,
622}
623
624impl SubmissionChange {
625 pub fn create(
627 manifest_id: impl Into<String>,
628 resource_type: impl Into<String>,
629 resource_id: impl Into<String>,
630 new_version: impl Into<String>,
631 ) -> Self {
632 Self {
633 change_id: Uuid::new_v4().to_string(),
634 manifest_id: manifest_id.into(),
635 change_type: ChangeType::Create,
636 resource_type: resource_type.into(),
637 resource_id: resource_id.into(),
638 previous_version: None,
639 new_version: new_version.into(),
640 previous_content: None,
641 changed_at: Utc::now(),
642 }
643 }
644
645 pub fn update(
647 manifest_id: impl Into<String>,
648 resource_type: impl Into<String>,
649 resource_id: impl Into<String>,
650 previous_version: impl Into<String>,
651 new_version: impl Into<String>,
652 previous_content: Value,
653 ) -> Self {
654 Self {
655 change_id: Uuid::new_v4().to_string(),
656 manifest_id: manifest_id.into(),
657 change_type: ChangeType::Update,
658 resource_type: resource_type.into(),
659 resource_id: resource_id.into(),
660 previous_version: Some(previous_version.into()),
661 new_version: new_version.into(),
662 previous_content: Some(previous_content),
663 changed_at: Utc::now(),
664 }
665 }
666}
667
668#[derive(Debug, Clone, Default, Serialize, Deserialize)]
670pub struct EntryCountSummary {
671 pub total: u64,
673 pub success: u64,
675 pub validation_error: u64,
677 pub processing_error: u64,
679 pub skipped: u64,
681}
682
683impl EntryCountSummary {
684 pub fn new() -> Self {
686 Self::default()
687 }
688
689 pub fn error_count(&self) -> u64 {
691 self.validation_error + self.processing_error
692 }
693
694 pub fn increment(&mut self, outcome: BulkEntryOutcome) {
696 self.total += 1;
697 match outcome {
698 BulkEntryOutcome::Success => self.success += 1,
699 BulkEntryOutcome::ValidationError => self.validation_error += 1,
700 BulkEntryOutcome::ProcessingError => self.processing_error += 1,
701 BulkEntryOutcome::Skipped => self.skipped += 1,
702 }
703 }
704}
705
706#[derive(Debug, Clone, Serialize, Deserialize)]
708pub struct StreamProcessingResult {
709 pub lines_processed: u64,
711 pub counts: EntryCountSummary,
713 pub aborted: bool,
715 #[serde(skip_serializing_if = "Option::is_none")]
717 pub abort_reason: Option<String>,
718}
719
720impl StreamProcessingResult {
721 pub fn new() -> Self {
723 Self {
724 lines_processed: 0,
725 counts: EntryCountSummary::new(),
726 aborted: false,
727 abort_reason: None,
728 }
729 }
730
731 pub fn aborted(mut self, reason: impl Into<String>) -> Self {
733 self.aborted = true;
734 self.abort_reason = Some(reason.into());
735 self
736 }
737}
738
739impl Default for StreamProcessingResult {
740 fn default() -> Self {
741 Self::new()
742 }
743}
744
745#[async_trait]
755pub trait BulkSubmitProvider: ResourceStorage {
756 async fn create_submission(
772 &self,
773 tenant: &TenantContext,
774 id: &SubmissionId,
775 metadata: Option<Value>,
776 ) -> StorageResult<SubmissionSummary>;
777
778 async fn get_submission(
789 &self,
790 tenant: &TenantContext,
791 id: &SubmissionId,
792 ) -> StorageResult<Option<SubmissionSummary>>;
793
794 async fn list_submissions(
808 &self,
809 tenant: &TenantContext,
810 submitter: Option<&str>,
811 status: Option<SubmissionStatus>,
812 limit: u32,
813 offset: u32,
814 ) -> StorageResult<Vec<SubmissionSummary>>;
815
816 async fn complete_submission(
832 &self,
833 tenant: &TenantContext,
834 id: &SubmissionId,
835 ) -> StorageResult<SubmissionSummary>;
836
837 async fn abort_submission(
857 &self,
858 tenant: &TenantContext,
859 id: &SubmissionId,
860 reason: &str,
861 ) -> StorageResult<u64>;
862
863 async fn add_manifest(
881 &self,
882 tenant: &TenantContext,
883 submission_id: &SubmissionId,
884 manifest_url: Option<&str>,
885 replaces_manifest_url: Option<&str>,
886 ) -> StorageResult<SubmissionManifest>;
887
888 async fn get_manifest(
900 &self,
901 tenant: &TenantContext,
902 submission_id: &SubmissionId,
903 manifest_id: &str,
904 ) -> StorageResult<Option<SubmissionManifest>>;
905
906 async fn list_manifests(
917 &self,
918 tenant: &TenantContext,
919 submission_id: &SubmissionId,
920 ) -> StorageResult<Vec<SubmissionManifest>>;
921
922 async fn process_entries(
942 &self,
943 tenant: &TenantContext,
944 submission_id: &SubmissionId,
945 manifest_id: &str,
946 entries: Vec<NdjsonEntry>,
947 options: &BulkProcessingOptions,
948 ) -> StorageResult<Vec<BulkEntryResult>>;
949
950 async fn get_entry_results(
965 &self,
966 tenant: &TenantContext,
967 submission_id: &SubmissionId,
968 manifest_id: &str,
969 outcome_filter: Option<BulkEntryOutcome>,
970 limit: u32,
971 offset: u32,
972 ) -> StorageResult<Vec<BulkEntryResult>>;
973
974 async fn get_entry_counts(
986 &self,
987 tenant: &TenantContext,
988 submission_id: &SubmissionId,
989 manifest_id: &str,
990 ) -> StorageResult<EntryCountSummary>;
991}
992
993#[async_trait]
998pub trait StreamingBulkSubmitProvider: BulkSubmitProvider {
999 async fn process_ndjson_stream(
1014 &self,
1015 tenant: &TenantContext,
1016 submission_id: &SubmissionId,
1017 manifest_id: &str,
1018 resource_type: &str,
1019 reader: Box<dyn AsyncBufRead + Send + Unpin>,
1020 options: &BulkProcessingOptions,
1021 ) -> StorageResult<StreamProcessingResult>;
1022}
1023
1024#[async_trait]
1029pub trait BulkSubmitRollbackProvider: BulkSubmitProvider {
1030 async fn record_change(
1038 &self,
1039 tenant: &TenantContext,
1040 submission_id: &SubmissionId,
1041 change: &SubmissionChange,
1042 ) -> StorageResult<()>;
1043
1044 async fn list_changes(
1057 &self,
1058 tenant: &TenantContext,
1059 submission_id: &SubmissionId,
1060 limit: u32,
1061 offset: u32,
1062 ) -> StorageResult<Vec<SubmissionChange>>;
1063
1064 async fn rollback_change(
1079 &self,
1080 tenant: &TenantContext,
1081 submission_id: &SubmissionId,
1082 change: &SubmissionChange,
1083 ) -> StorageResult<bool>;
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088 use super::*;
1089
1090 #[test]
1091 fn test_submission_id() {
1092 let id = SubmissionId::new("my-system", "sub-123");
1093 assert_eq!(id.submitter, "my-system");
1094 assert_eq!(id.submission_id, "sub-123");
1095 assert_eq!(id.to_string(), "my-system/sub-123");
1096 }
1097
1098 #[test]
1099 fn test_submission_id_generate() {
1100 let id = SubmissionId::generate("my-system");
1101 assert_eq!(id.submitter, "my-system");
1102 assert!(!id.submission_id.is_empty());
1103 }
1104
1105 #[test]
1106 fn test_submission_status() {
1107 assert!(!SubmissionStatus::InProgress.is_terminal());
1108 assert!(SubmissionStatus::Complete.is_terminal());
1109 assert!(SubmissionStatus::Aborted.is_terminal());
1110
1111 let status: SubmissionStatus = "in-progress".parse().unwrap();
1112 assert_eq!(status, SubmissionStatus::InProgress);
1113 }
1114
1115 #[test]
1116 fn test_manifest_status() {
1117 assert!(!ManifestStatus::Pending.is_terminal());
1118 assert!(!ManifestStatus::Processing.is_terminal());
1119 assert!(ManifestStatus::Completed.is_terminal());
1120 assert!(ManifestStatus::Failed.is_terminal());
1121 }
1122
1123 #[test]
1124 fn test_bulk_entry_result() {
1125 let success = BulkEntryResult::success(1, "Patient", "pat-123", true);
1126 assert!(success.is_success());
1127 assert!(!success.is_error());
1128 assert!(success.created);
1129
1130 let error = BulkEntryResult::validation_error(
1131 2,
1132 "Patient",
1133 serde_json::json!({"resourceType": "OperationOutcome"}),
1134 );
1135 assert!(!error.is_success());
1136 assert!(error.is_error());
1137 }
1138
1139 #[test]
1140 fn test_ndjson_entry_parse() {
1141 let line = r#"{"resourceType":"Patient","id":"123","name":[{"family":"Smith"}]}"#;
1142 let entry = NdjsonEntry::parse(1, line).unwrap();
1143
1144 assert_eq!(entry.line_number, 1);
1145 assert_eq!(entry.resource_type, "Patient");
1146 assert_eq!(entry.resource_id, Some("123".to_string()));
1147 }
1148
1149 #[test]
1150 fn test_ndjson_entry_parse_error() {
1151 let result = NdjsonEntry::parse(1, "not json");
1152 assert!(result.is_err());
1153
1154 let result = NdjsonEntry::parse(1, r#"{"id":"123"}"#);
1155 assert!(result.is_err()); }
1157
1158 #[test]
1159 fn test_bulk_processing_options() {
1160 let options = BulkProcessingOptions::new()
1161 .with_batch_size(50)
1162 .with_max_errors(10)
1163 .with_continue_on_error(false);
1164
1165 assert_eq!(options.batch_size, 50);
1166 assert_eq!(options.max_errors, 10);
1167 assert!(!options.continue_on_error);
1168 }
1169
1170 #[test]
1171 fn test_bulk_processing_options_strict() {
1172 let options = BulkProcessingOptions::strict();
1173 assert!(!options.continue_on_error);
1174 assert_eq!(options.max_errors, 1);
1175 }
1176
1177 #[test]
1178 fn test_submission_change() {
1179 let create = SubmissionChange::create("manifest-1", "Patient", "pat-123", "1");
1180 assert_eq!(create.change_type, ChangeType::Create);
1181 assert!(create.previous_content.is_none());
1182
1183 let update = SubmissionChange::update(
1184 "manifest-1",
1185 "Patient",
1186 "pat-123",
1187 "1",
1188 "2",
1189 serde_json::json!({"resourceType": "Patient"}),
1190 );
1191 assert_eq!(update.change_type, ChangeType::Update);
1192 assert!(update.previous_content.is_some());
1193 }
1194
1195 #[test]
1196 fn test_entry_count_summary() {
1197 let mut counts = EntryCountSummary::new();
1198 counts.increment(BulkEntryOutcome::Success);
1199 counts.increment(BulkEntryOutcome::Success);
1200 counts.increment(BulkEntryOutcome::ValidationError);
1201 counts.increment(BulkEntryOutcome::ProcessingError);
1202 counts.increment(BulkEntryOutcome::Skipped);
1203
1204 assert_eq!(counts.total, 5);
1205 assert_eq!(counts.success, 2);
1206 assert_eq!(counts.error_count(), 2);
1207 assert_eq!(counts.skipped, 1);
1208 }
1209
1210 #[test]
1211 fn test_stream_processing_result() {
1212 let result = StreamProcessingResult::new().aborted("max errors exceeded");
1213 assert!(result.aborted);
1214 assert_eq!(result.abort_reason, Some("max errors exceeded".to_string()));
1215 }
1216}