lib.rs (22480B)
1 #![forbid(unsafe_code)] 2 3 mod publish; 4 5 pub use publish::{ 6 AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, 7 AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, AppOrderDecisionPublishPayload, 8 AppOrderRequestItemPayload, AppOrderRequestPublishPayload, 9 AppOrderRevisionDecisionPublishPayload, AppOrderRevisionProposalPublishPayload, 10 AppPublishContext, AppPublishPayload, AppPublishPayloadJsonError, AppPublishValidationFailure, 11 AppPublishValidationFailureSet, AppPublishWorkKind, 12 }; 13 14 use radroots_app_view::{FarmId, FulfillmentWindowId, OrderId, ProductId}; 15 use serde::{Deserialize, Serialize}; 16 use thiserror::Error; 17 18 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 19 #[serde( 20 tag = "aggregate_kind", 21 content = "aggregate_id", 22 rename_all = "snake_case" 23 )] 24 pub enum SyncAggregateRef { 25 Farm(FarmId), 26 FulfillmentWindow(FulfillmentWindowId), 27 Product(ProductId), 28 Order(OrderId), 29 } 30 31 impl SyncAggregateRef { 32 pub const fn aggregate_kind(&self) -> &'static str { 33 match self { 34 Self::Farm(_) => "farm", 35 Self::FulfillmentWindow(_) => "fulfillment_window", 36 Self::Product(_) => "product", 37 Self::Order(_) => "order", 38 } 39 } 40 41 pub fn aggregate_id(&self) -> String { 42 match self { 43 Self::Farm(farm_id) => farm_id.to_string(), 44 Self::FulfillmentWindow(fulfillment_window_id) => fulfillment_window_id.to_string(), 45 Self::Product(product_id) => product_id.to_string(), 46 Self::Order(order_id) => order_id.to_string(), 47 } 48 } 49 } 50 51 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 52 #[serde(rename_all = "snake_case")] 53 pub enum SyncTrigger { 54 AppLaunch, 55 ForegroundResume, 56 #[default] 57 ManualRefresh, 58 LocalMutation, 59 } 60 61 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 62 #[serde(rename_all = "snake_case")] 63 pub enum SyncOperationKind { 64 Upsert, 65 Delete, 66 } 67 68 impl SyncOperationKind { 69 pub const fn storage_key(self) -> &'static str { 70 match self { 71 Self::Upsert => "upsert", 72 Self::Delete => "delete", 73 } 74 } 75 } 76 77 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 78 #[serde(rename_all = "snake_case")] 79 pub enum PendingSyncOperationState { 80 Pending, 81 InProgress, 82 Succeeded, 83 Failed, 84 Blocked, 85 Retryable, 86 } 87 88 impl PendingSyncOperationState { 89 pub const fn storage_key(self) -> &'static str { 90 match self { 91 Self::Pending => "pending", 92 Self::InProgress => "in_progress", 93 Self::Succeeded => "succeeded", 94 Self::Failed => "failed", 95 Self::Blocked => "blocked", 96 Self::Retryable => "retryable", 97 } 98 } 99 100 pub const fn is_active(self) -> bool { 101 !matches!(self, Self::Succeeded) 102 } 103 } 104 105 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 106 pub struct PendingSyncOperation { 107 pub operation_key: String, 108 pub aggregate: SyncAggregateRef, 109 pub operation: SyncOperationKind, 110 pub payload_json: String, 111 pub created_at: String, 112 pub available_at: String, 113 pub attempt_count: u32, 114 pub state: PendingSyncOperationState, 115 pub last_error_message: Option<String>, 116 } 117 118 impl PendingSyncOperation { 119 pub fn new( 120 aggregate: SyncAggregateRef, 121 operation: SyncOperationKind, 122 payload_json: impl Into<String>, 123 created_at: impl Into<String>, 124 ) -> Self { 125 let operation_key = Self::deterministic_operation_key(&aggregate, operation); 126 let created_at = created_at.into(); 127 Self { 128 operation_key, 129 aggregate, 130 operation, 131 payload_json: payload_json.into(), 132 created_at: created_at.clone(), 133 available_at: created_at, 134 attempt_count: 0, 135 state: PendingSyncOperationState::Pending, 136 last_error_message: None, 137 } 138 } 139 140 pub fn deterministic_operation_key( 141 aggregate: &SyncAggregateRef, 142 operation: SyncOperationKind, 143 ) -> String { 144 format!( 145 "{}:{}:{}", 146 aggregate.aggregate_kind(), 147 aggregate.aggregate_id(), 148 operation.storage_key() 149 ) 150 } 151 152 pub const fn is_retry(&self) -> bool { 153 self.attempt_count > 0 154 } 155 } 156 157 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 158 #[serde(rename_all = "snake_case")] 159 pub enum SyncConflictKind { 160 RevisionMismatch, 161 RemoteDelete, 162 RemoteValidationReject, 163 } 164 165 impl SyncConflictKind { 166 pub const fn storage_key(self) -> &'static str { 167 match self { 168 Self::RevisionMismatch => "revision_mismatch", 169 Self::RemoteDelete => "remote_delete", 170 Self::RemoteValidationReject => "remote_validation_reject", 171 } 172 } 173 } 174 175 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 176 #[serde(rename_all = "snake_case")] 177 pub enum SyncConflictSeverity { 178 ReviewRequired, 179 Blocking, 180 } 181 182 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] 183 #[serde(rename_all = "snake_case")] 184 pub enum SyncConflictResolutionStatus { 185 Unresolved, 186 AcceptedLocal, 187 AcceptedRemote, 188 Dismissed, 189 } 190 191 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 192 pub struct SyncConflict { 193 pub aggregate: SyncAggregateRef, 194 pub kind: SyncConflictKind, 195 pub severity: SyncConflictSeverity, 196 pub resolution: SyncConflictResolutionStatus, 197 pub local_payload_json: String, 198 pub remote_payload_json: Option<String>, 199 pub detected_at: String, 200 pub resolved_at: Option<String>, 201 } 202 203 impl SyncConflict { 204 pub const fn is_unresolved(&self) -> bool { 205 matches!(self.resolution, SyncConflictResolutionStatus::Unresolved) 206 } 207 } 208 209 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 210 pub struct SyncConflictStatus { 211 pub unresolved_count: usize, 212 pub blocking_count: usize, 213 } 214 215 impl SyncConflictStatus { 216 pub const fn clear() -> Self { 217 Self { 218 unresolved_count: 0, 219 blocking_count: 0, 220 } 221 } 222 223 pub fn from_conflicts(conflicts: &[SyncConflict]) -> Self { 224 let unresolved_conflicts = conflicts.iter().filter(|conflict| conflict.is_unresolved()); 225 let unresolved_count = unresolved_conflicts.clone().count(); 226 let blocking_count = unresolved_conflicts 227 .filter(|conflict| matches!(conflict.severity, SyncConflictSeverity::Blocking)) 228 .count(); 229 230 Self { 231 unresolved_count, 232 blocking_count, 233 } 234 } 235 236 pub const fn is_clear(&self) -> bool { 237 self.unresolved_count == 0 238 } 239 240 pub const fn requires_attention(&self) -> bool { 241 self.unresolved_count > 0 242 } 243 244 pub const fn has_blocking_conflicts(&self) -> bool { 245 self.blocking_count > 0 246 } 247 } 248 249 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 250 #[serde(rename_all = "snake_case")] 251 pub enum SyncCheckpointState { 252 #[default] 253 NeverSynced, 254 Syncing, 255 Current, 256 Failed, 257 } 258 259 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 260 pub struct SyncCheckpointStatus { 261 pub state: SyncCheckpointState, 262 pub last_sync_started_at: Option<String>, 263 pub last_sync_completed_at: Option<String>, 264 pub last_remote_cursor: Option<String>, 265 pub last_error_message: Option<String>, 266 } 267 268 impl Default for SyncCheckpointStatus { 269 fn default() -> Self { 270 Self::never_synced() 271 } 272 } 273 274 impl SyncCheckpointStatus { 275 pub const fn never_synced() -> Self { 276 Self { 277 state: SyncCheckpointState::NeverSynced, 278 last_sync_started_at: None, 279 last_sync_completed_at: None, 280 last_remote_cursor: None, 281 last_error_message: None, 282 } 283 } 284 285 pub fn syncing(started_at: impl Into<String>, last_remote_cursor: Option<String>) -> Self { 286 Self { 287 state: SyncCheckpointState::Syncing, 288 last_sync_started_at: Some(started_at.into()), 289 last_sync_completed_at: None, 290 last_remote_cursor, 291 last_error_message: None, 292 } 293 } 294 295 pub fn current( 296 started_at: Option<String>, 297 completed_at: impl Into<String>, 298 last_remote_cursor: Option<String>, 299 ) -> Self { 300 Self { 301 state: SyncCheckpointState::Current, 302 last_sync_started_at: started_at, 303 last_sync_completed_at: Some(completed_at.into()), 304 last_remote_cursor, 305 last_error_message: None, 306 } 307 } 308 309 pub fn failed( 310 started_at: Option<String>, 311 completed_at: Option<String>, 312 last_remote_cursor: Option<String>, 313 message: impl Into<String>, 314 ) -> Self { 315 Self { 316 state: SyncCheckpointState::Failed, 317 last_sync_started_at: started_at, 318 last_sync_completed_at: completed_at, 319 last_remote_cursor, 320 last_error_message: Some(message.into()), 321 } 322 } 323 324 pub const fn is_failed(&self) -> bool { 325 matches!(self.state, SyncCheckpointState::Failed) 326 } 327 328 pub const fn is_syncing(&self) -> bool { 329 matches!(self.state, SyncCheckpointState::Syncing) 330 } 331 } 332 333 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 334 #[serde(rename_all = "snake_case")] 335 pub enum AppSyncRunStatus { 336 #[default] 337 Idle, 338 Syncing, 339 Succeeded, 340 Conflicted, 341 Failed, 342 } 343 344 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 345 pub struct AppSyncProjection { 346 pub run_status: AppSyncRunStatus, 347 pub checkpoint: SyncCheckpointStatus, 348 pub conflict_status: SyncConflictStatus, 349 } 350 351 impl Default for AppSyncProjection { 352 fn default() -> Self { 353 Self { 354 run_status: AppSyncRunStatus::Idle, 355 checkpoint: SyncCheckpointStatus::never_synced(), 356 conflict_status: SyncConflictStatus::clear(), 357 } 358 } 359 } 360 361 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 362 #[serde(rename_all = "snake_case")] 363 pub enum AppRelayIngestFreshnessState { 364 Fresh, 365 #[default] 366 Stale, 367 Failed, 368 } 369 370 impl AppRelayIngestFreshnessState { 371 pub const fn storage_key(self) -> &'static str { 372 match self { 373 Self::Fresh => "fresh", 374 Self::Stale => "stale", 375 Self::Failed => "failed", 376 } 377 } 378 } 379 380 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 381 #[serde(rename_all = "snake_case")] 382 pub enum AppRelayIngestScopeStatus { 383 Fresh, 384 #[default] 385 Stale, 386 Partial, 387 Failed, 388 } 389 390 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 391 pub struct AppRelayIngestRelayFreshness { 392 pub relay_url: String, 393 pub state: AppRelayIngestFreshnessState, 394 pub cursor_since_unix_seconds: Option<i64>, 395 pub last_event_created_at_unix_seconds: Option<i64>, 396 pub last_fetch_started_at: Option<String>, 397 pub last_fetch_completed_at: Option<String>, 398 pub last_success_at: Option<String>, 399 pub last_error_message: Option<String>, 400 } 401 402 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] 403 pub struct AppRelayIngestScopeFreshness { 404 pub scope_key: String, 405 pub status: AppRelayIngestScopeStatus, 406 pub relays: Vec<AppRelayIngestRelayFreshness>, 407 } 408 409 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 410 pub struct AppSyncRequest { 411 pub trigger: SyncTrigger, 412 pub checkpoint: SyncCheckpointStatus, 413 pub pending_operations: Vec<PendingSyncOperation>, 414 pub known_conflicts: Vec<SyncConflict>, 415 } 416 417 impl AppSyncRequest { 418 pub fn conflict_status(&self) -> SyncConflictStatus { 419 SyncConflictStatus::from_conflicts(&self.known_conflicts) 420 } 421 } 422 423 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 424 pub struct AppSyncResult { 425 pub run_status: AppSyncRunStatus, 426 pub checkpoint: SyncCheckpointStatus, 427 pub pushed_operation_count: usize, 428 pub pulled_record_count: usize, 429 pub conflicts: Vec<SyncConflict>, 430 #[serde(default)] 431 pub published_receipts: Vec<AppPublishedOperationReceipt>, 432 } 433 434 impl AppSyncResult { 435 pub fn projection(&self) -> AppSyncProjection { 436 AppSyncProjection { 437 run_status: self.run_status, 438 checkpoint: self.checkpoint.clone(), 439 conflict_status: SyncConflictStatus::from_conflicts(&self.conflicts), 440 } 441 } 442 } 443 444 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] 445 pub struct AppPublishedOperationReceipt { 446 pub operation_key: String, 447 pub source_account_id: String, 448 pub source_local_event_id: Option<String>, 449 #[serde(default)] 450 pub listing_addr: Option<String>, 451 pub event_id: String, 452 pub event_kind: u32, 453 pub event_pubkey: String, 454 pub event_created_at: u32, 455 pub event_tags_json: serde_json::Value, 456 pub event_content: String, 457 pub event_sig: String, 458 pub raw_event_json: serde_json::Value, 459 pub relay_set_fingerprint: String, 460 pub relay_delivery_json: serde_json::Value, 461 } 462 463 #[derive(Clone, Debug, Eq, Error, PartialEq)] 464 pub enum AppSyncTransportError { 465 #[error("app sync transport is unavailable: {message}")] 466 Unavailable { message: String }, 467 #[error("app sync transport failed: {message}")] 468 Failed { message: String }, 469 } 470 471 impl AppSyncTransportError { 472 pub fn unavailable(message: impl Into<String>) -> Self { 473 Self::Unavailable { 474 message: message.into(), 475 } 476 } 477 478 pub fn failed(message: impl Into<String>) -> Self { 479 Self::Failed { 480 message: message.into(), 481 } 482 } 483 } 484 485 pub trait AppSyncTransport { 486 fn sync(&mut self, request: AppSyncRequest) -> Result<AppSyncResult, AppSyncTransportError>; 487 488 fn supports_empty_sync_request(&self) -> bool { 489 true 490 } 491 } 492 493 #[derive(Clone, Debug)] 494 pub struct RecordedAppSyncTransport { 495 result: Result<AppSyncResult, AppSyncTransportError>, 496 last_request: Option<AppSyncRequest>, 497 call_count: usize, 498 } 499 500 impl RecordedAppSyncTransport { 501 pub fn succeed(result: AppSyncResult) -> Self { 502 Self { 503 result: Ok(result), 504 last_request: None, 505 call_count: 0, 506 } 507 } 508 509 pub fn fail(error: AppSyncTransportError) -> Self { 510 Self { 511 result: Err(error), 512 last_request: None, 513 call_count: 0, 514 } 515 } 516 517 pub fn last_request(&self) -> Option<&AppSyncRequest> { 518 self.last_request.as_ref() 519 } 520 521 pub const fn call_count(&self) -> usize { 522 self.call_count 523 } 524 } 525 526 impl AppSyncTransport for RecordedAppSyncTransport { 527 fn sync(&mut self, request: AppSyncRequest) -> Result<AppSyncResult, AppSyncTransportError> { 528 self.call_count += 1; 529 self.last_request = Some(request); 530 self.result.clone() 531 } 532 } 533 534 #[cfg(test)] 535 mod tests { 536 use super::{ 537 AppSyncProjection, AppSyncRequest, AppSyncResult, AppSyncRunStatus, AppSyncTransport, 538 AppSyncTransportError, PendingSyncOperation, RecordedAppSyncTransport, SyncAggregateRef, 539 SyncCheckpointState, SyncCheckpointStatus, SyncConflict, SyncConflictKind, 540 SyncConflictResolutionStatus, SyncConflictSeverity, SyncConflictStatus, SyncOperationKind, 541 SyncTrigger, 542 }; 543 use radroots_app_view::{FarmId, ProductId}; 544 545 #[test] 546 fn default_projection_starts_idle_and_clear() { 547 let projection = AppSyncProjection::default(); 548 549 assert_eq!(projection.run_status, AppSyncRunStatus::Idle); 550 assert_eq!( 551 projection.checkpoint.state, 552 SyncCheckpointState::NeverSynced 553 ); 554 assert!(projection.conflict_status.is_clear()); 555 } 556 557 #[test] 558 fn checkpoint_constructors_keep_sync_and_failure_state_explicit() { 559 let syncing = 560 SyncCheckpointStatus::syncing("2026-04-17T19:30:00Z", Some("cursor-1".to_owned())); 561 let failed = SyncCheckpointStatus::failed( 562 Some("2026-04-17T19:30:00Z".to_owned()), 563 Some("2026-04-17T19:30:30Z".to_owned()), 564 Some("cursor-1".to_owned()), 565 "relay timeout", 566 ); 567 let current = SyncCheckpointStatus::current( 568 Some("2026-04-17T19:30:00Z".to_owned()), 569 "2026-04-17T19:30:30Z", 570 Some("cursor-2".to_owned()), 571 ); 572 573 assert!(syncing.is_syncing()); 574 assert_eq!(syncing.last_sync_completed_at, None); 575 assert_eq!(syncing.last_error_message, None); 576 577 assert!(failed.is_failed()); 578 assert_eq!(failed.last_error_message.as_deref(), Some("relay timeout")); 579 580 assert_eq!(current.state, SyncCheckpointState::Current); 581 assert_eq!(current.last_remote_cursor.as_deref(), Some("cursor-2")); 582 assert_eq!(current.last_error_message, None); 583 } 584 585 #[test] 586 fn conflict_status_counts_only_unresolved_conflicts() { 587 let conflicts = vec![ 588 SyncConflict { 589 aggregate: SyncAggregateRef::Product(ProductId::new()), 590 kind: SyncConflictKind::RevisionMismatch, 591 severity: SyncConflictSeverity::Blocking, 592 resolution: SyncConflictResolutionStatus::Unresolved, 593 local_payload_json: "{\"title\":\"carrots\"}".to_owned(), 594 remote_payload_json: Some("{\"title\":\"rainbow carrots\"}".to_owned()), 595 detected_at: "2026-04-17T19:31:00Z".to_owned(), 596 resolved_at: None, 597 }, 598 SyncConflict { 599 aggregate: SyncAggregateRef::Farm(FarmId::new()), 600 kind: SyncConflictKind::RemoteValidationReject, 601 severity: SyncConflictSeverity::ReviewRequired, 602 resolution: SyncConflictResolutionStatus::AcceptedRemote, 603 local_payload_json: "{\"display_name\":\"Sunrise Farm\"}".to_owned(), 604 remote_payload_json: Some("{\"display_name\":\"Sunrise Farm LLC\"}".to_owned()), 605 detected_at: "2026-04-17T19:31:30Z".to_owned(), 606 resolved_at: Some("2026-04-17T19:32:00Z".to_owned()), 607 }, 608 ]; 609 610 let status = SyncConflictStatus::from_conflicts(&conflicts); 611 612 assert_eq!(status.unresolved_count, 1); 613 assert_eq!(status.blocking_count, 1); 614 assert!(status.requires_attention()); 615 assert!(status.has_blocking_conflicts()); 616 } 617 618 #[test] 619 fn request_and_result_surface_conflict_status_through_typed_contracts() { 620 let mut pending_operation = PendingSyncOperation::new( 621 SyncAggregateRef::Product(ProductId::new()), 622 SyncOperationKind::Upsert, 623 "{\"title\":\"greens\"}", 624 "2026-04-17T19:32:00Z", 625 ); 626 pending_operation.attempt_count = 1; 627 let conflict = SyncConflict { 628 aggregate: SyncAggregateRef::Product(ProductId::new()), 629 kind: SyncConflictKind::RevisionMismatch, 630 severity: SyncConflictSeverity::ReviewRequired, 631 resolution: SyncConflictResolutionStatus::Unresolved, 632 local_payload_json: "{\"stock_count\":4}".to_owned(), 633 remote_payload_json: Some("{\"stock_count\":6}".to_owned()), 634 detected_at: "2026-04-17T19:33:00Z".to_owned(), 635 resolved_at: None, 636 }; 637 let request = AppSyncRequest { 638 trigger: SyncTrigger::LocalMutation, 639 checkpoint: SyncCheckpointStatus::current( 640 Some("2026-04-17T19:30:00Z".to_owned()), 641 "2026-04-17T19:32:30Z", 642 Some("cursor-4".to_owned()), 643 ), 644 pending_operations: vec![pending_operation.clone()], 645 known_conflicts: vec![conflict.clone()], 646 }; 647 let result = AppSyncResult { 648 run_status: AppSyncRunStatus::Conflicted, 649 checkpoint: request.checkpoint.clone(), 650 pushed_operation_count: 1, 651 pulled_record_count: 3, 652 conflicts: vec![conflict], 653 published_receipts: Vec::new(), 654 }; 655 656 assert_eq!(request.conflict_status().unresolved_count, 1); 657 assert!(pending_operation.is_retry()); 658 assert_eq!(pending_operation.operation.storage_key(), "upsert"); 659 660 let projection = result.projection(); 661 assert_eq!(projection.run_status, AppSyncRunStatus::Conflicted); 662 assert_eq!( 663 projection.checkpoint.last_remote_cursor.as_deref(), 664 Some("cursor-4") 665 ); 666 assert_eq!(projection.conflict_status.unresolved_count, 1); 667 } 668 669 #[test] 670 fn recorded_transport_is_mockable_and_records_requests() { 671 let request = AppSyncRequest { 672 trigger: SyncTrigger::ManualRefresh, 673 checkpoint: SyncCheckpointStatus::never_synced(), 674 pending_operations: vec![], 675 known_conflicts: vec![], 676 }; 677 let expected_result = AppSyncResult { 678 run_status: AppSyncRunStatus::Succeeded, 679 checkpoint: SyncCheckpointStatus::current( 680 Some("2026-04-17T19:34:00Z".to_owned()), 681 "2026-04-17T19:34:10Z", 682 Some("cursor-9".to_owned()), 683 ), 684 pushed_operation_count: 0, 685 pulled_record_count: 2, 686 conflicts: vec![], 687 published_receipts: Vec::new(), 688 }; 689 let mut transport = RecordedAppSyncTransport::succeed(expected_result.clone()); 690 691 let actual_result = transport 692 .sync(request.clone()) 693 .expect("recorded transport should succeed"); 694 695 assert_eq!(actual_result, expected_result); 696 assert_eq!(transport.last_request(), Some(&request)); 697 assert_eq!(transport.call_count(), 1); 698 } 699 700 #[test] 701 fn recorded_transport_can_fail_without_a_live_backend() { 702 let mut transport = 703 RecordedAppSyncTransport::fail(AppSyncTransportError::unavailable("offline")); 704 705 let error = transport 706 .sync(AppSyncRequest { 707 trigger: SyncTrigger::AppLaunch, 708 checkpoint: SyncCheckpointStatus::never_synced(), 709 pending_operations: vec![], 710 known_conflicts: vec![], 711 }) 712 .expect_err("recorded transport should fail"); 713 714 assert_eq!(error, AppSyncTransportError::unavailable("offline")); 715 assert_eq!(transport.call_count(), 1); 716 } 717 }