//! AZ-647 integration tests driven by `wiremock`. //! //! Coverage: //! - AC-1: happy-path push — both endpoints 200, disk file cleared, //! `sync_state = synced`. //! - AC-2: partial success — `/mapobjects` 200 + `/mapobjects/ignored` 503; //! the disk file is rewritten to hold ONLY the ignored portion. //! - AC-3: persistent failure — both endpoints 503 across the retry budget; //! `sync_state = degraded`, disk file remains, manual-replay warning //! observable on health. //! - AC-4: crash-recovery push at startup — `recover_pending_pushes` finds //! the residual file from a previously terminated mission and replays it. //! - AC-5: 60-min mission proxy push within budget — 5 000 observations //! pushed in well under 2 min on loopback. use std::time::{Duration, Instant}; use chrono::{TimeZone, Utc}; use mission_client::{ MapObjectsDiff, MissionClient, MissionClientOptions, PerEndpointStatus, SyncState, }; use shared::models::mapobject::{ DiffKind, IgnoredItem, IgnoredItemSource, MapObjectObservation, RetentionScope, }; use uuid::Uuid; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn options_for( endpoint: &str, push_attempts: u32, tmp_dir: &std::path::Path, ) -> MissionClientOptions { let mut o = MissionClientOptions::new(endpoint); o.max_attempts = 3; o.post_max_attempts = 3; o.push_max_attempts = push_attempts; o.backoff_base = Duration::from_millis(5); o.backoff_cap = Duration::from_millis(20); o.request_timeout = Duration::from_secs(2); o.connect_timeout = Duration::from_secs(1); o.state_dir = tmp_dir.to_path_buf(); o } fn obs(mission_id: &str, i: u128) -> MapObjectObservation { MapObjectObservation { id: Uuid::from_u128(0xa0_00_00 + i), h3_cell: 1_000 + i as u64, class: "tank".into(), class_group: "armor".into(), mission_id: mission_id.into(), uav_id: "uav-test".into(), observed_at_monotonic_ns: 1_000_000 + i as u64, observed_at_wallclock: Utc.with_ymd_and_hms(2026, 5, 19, 12, 0, 0).unwrap(), gps_lat: 49.0 + (i as f64) * 1e-4, gps_lon: 31.0 + (i as f64) * 1e-4, mgrs: format!("MGRS-{i}"), size_width_m: 3.0, size_length_m: 6.0, confidence: 0.92, diff_kind: DiffKind::New, photo_ref: None, raw_evidence: None, } } fn ignored(mission_id: &str, i: u128) -> IgnoredItem { IgnoredItem { id: Uuid::from_u128(0xb0_00_00 + i), mgrs: format!("MGRS-IG-{i}"), h3_cell: 90_000 + i as u64, class_group: "civilian".into(), decline_time: Utc.with_ymd_and_hms(2026, 5, 19, 12, 0, 0).unwrap(), operator_id: None, mission_id: mission_id.into(), retention_scope: RetentionScope::Mission, expires_at: None, source: IgnoredItemSource::LocalAppended, pending_upload: true, } } #[tokio::test] async fn ac1_happy_path_push_clears_disk() { // Arrange let tmp = tempfile::tempdir().unwrap(); let mock = MockServer::start().await; let mission_id = "M-happy"; Mock::given(method("POST")) .and(path(format!("/missions/{mission_id}/mapobjects"))) .respond_with(ResponseTemplate::new(200).set_body_string("{}")) .expect(1) .mount(&mock) .await; Mock::given(method("POST")) .and(path(format!("/missions/{mission_id}/mapobjects/ignored"))) .respond_with(ResponseTemplate::new(200).set_body_string("{}")) .expect(1) .mount(&mock) .await; let client = MissionClient::new(options_for(&mock.uri(), 3, tmp.path())).expect("client builds"); let h = client.handle(); let diff = MapObjectsDiff { observations: vec![obs(mission_id, 0), obs(mission_id, 1)], ignored_items: vec![ignored(mission_id, 0)], }; // Act let report = h.push_mapobjects_diff(mission_id, diff).await; // Assert assert!(matches!(report.observations, PerEndpointStatus::Success)); assert!(matches!(report.ignored, PerEndpointStatus::Success)); assert_eq!(report.sync_state(), SyncState::Synced); let disk_file = tmp .path() .join("mapobjects_push") .join(format!("{mission_id}.json")); assert!( !disk_file.exists(), "disk file should be deleted on full success" ); } #[tokio::test] async fn ac2_partial_success_retains_only_failing_endpoint() { // Arrange: observations → 200, ignored → 503 every time. let tmp = tempfile::tempdir().unwrap(); let mock = MockServer::start().await; let mission_id = "M-partial"; Mock::given(method("POST")) .and(path(format!("/missions/{mission_id}/mapobjects"))) .respond_with(ResponseTemplate::new(200).set_body_string("{}")) .mount(&mock) .await; Mock::given(method("POST")) .and(path(format!("/missions/{mission_id}/mapobjects/ignored"))) .respond_with(ResponseTemplate::new(503)) .mount(&mock) .await; let client = MissionClient::new(options_for(&mock.uri(), 3, tmp.path())).expect("client builds"); let h = client.handle(); let diff = MapObjectsDiff { observations: vec![obs(mission_id, 10), obs(mission_id, 11)], ignored_items: vec![ignored(mission_id, 10)], }; // Act let report = h.push_mapobjects_diff(mission_id, diff).await; // Assert assert!(matches!(report.observations, PerEndpointStatus::Success)); assert!(matches!( report.ignored, PerEndpointStatus::MaxRetriesExceeded { .. } )); assert_eq!(report.sync_state(), SyncState::Degraded); // Disk file should hold ONLY the ignored portion. let disk_file = tmp .path() .join("mapobjects_push") .join(format!("{mission_id}.json")); assert!(disk_file.exists(), "disk file should remain for retry"); let body: serde_json::Value = serde_json::from_slice(&std::fs::read(&disk_file).unwrap()).unwrap(); assert!( body["observations"].as_array().unwrap().is_empty(), "observations should be cleared from disk on partial success" ); assert_eq!( body["ignored_items"].as_array().unwrap().len(), 1, "ignored payload should remain on disk" ); } #[tokio::test] async fn ac3_persistent_failure_marks_degraded_and_keeps_file() { // Arrange: both endpoints return 503 forever. let tmp = tempfile::tempdir().unwrap(); let mock = MockServer::start().await; let mission_id = "M-degraded"; Mock::given(method("POST")) .and(path(format!("/missions/{mission_id}/mapobjects"))) .respond_with(ResponseTemplate::new(503)) .mount(&mock) .await; Mock::given(method("POST")) .and(path(format!("/missions/{mission_id}/mapobjects/ignored"))) .respond_with(ResponseTemplate::new(503)) .mount(&mock) .await; let client = MissionClient::new(options_for(&mock.uri(), 2, tmp.path())).expect("client builds"); let h = client.handle(); let diff = MapObjectsDiff { observations: vec![obs(mission_id, 20)], ignored_items: vec![ignored(mission_id, 20)], }; // Act let report = h.push_mapobjects_diff(mission_id, diff).await; // Assert assert!(matches!( report.observations, PerEndpointStatus::MaxRetriesExceeded { .. } )); assert!(matches!( report.ignored, PerEndpointStatus::MaxRetriesExceeded { .. } )); assert_eq!(report.sync_state(), SyncState::Degraded); let detail = h.health().detail.unwrap_or_default(); assert!( detail.contains("push_sync_state=degraded"), "health detail did not record degraded: {detail}" ); assert!( detail.contains("mapobjects_push_pending=true"), "health detail did not record pending: {detail}" ); let disk_file = tmp .path() .join("mapobjects_push") .join(format!("{mission_id}.json")); assert!( disk_file.exists(), "disk file MUST remain when both endpoints fail" ); } #[tokio::test] async fn ac4_crash_recovery_replays_pending_at_startup() { // Arrange: pre-seed a disk file for a previously terminated mission `M0`, // then start a fresh MissionClient pointing at a server that accepts both // endpoints for `M0`. let tmp = tempfile::tempdir().unwrap(); let old_mission = "M0"; let queue_dir = tmp.path().join("mapobjects_push"); std::fs::create_dir_all(&queue_dir).unwrap(); let pending_body = serde_json::json!({ "observations": [ { "id": "00000000-0000-0000-0000-000000000001", "h3_cell": 7, "class": "tank", "class_group": "armor", "mission_id": old_mission, "uav_id": "uav-test", "observed_at_monotonic_ns": 1, "observed_at_wallclock": "2026-05-19T12:00:00Z", "gps_lat": 49.0, "gps_lon": 31.0, "mgrs": "X", "size_width_m": 3.0, "size_length_m": 6.0, "confidence": 0.9, "diff_kind": "NEW" } ], "ignored_items": [] }); std::fs::write( queue_dir.join(format!("{old_mission}.json")), pending_body.to_string(), ) .unwrap(); let mock = MockServer::start().await; Mock::given(method("POST")) .and(path(format!("/missions/{old_mission}/mapobjects"))) .respond_with(ResponseTemplate::new(200).set_body_string("{}")) .expect(1) .mount(&mock) .await; Mock::given(method("POST")) .and(path(format!("/missions/{old_mission}/mapobjects/ignored"))) .respond_with(ResponseTemplate::new(200).set_body_string("{}")) .expect(1) .mount(&mock) .await; let client = MissionClient::new(options_for(&mock.uri(), 3, tmp.path())).expect("client builds"); let h = client.handle(); // Act let reports = h.recover_pending_pushes().await; // Assert assert_eq!(reports.len(), 1); assert_eq!(reports[0].mission_id, old_mission); assert_eq!(reports[0].sync_state(), SyncState::Synced); let disk_file = queue_dir.join(format!("{old_mission}.json")); assert!( !disk_file.exists(), "disk file should be deleted after successful crash-recovery replay" ); } #[tokio::test] async fn ac5_large_diff_push_within_budget() { // Arrange: 5 000 observations + 500 ignored items, both endpoints 200. let tmp = tempfile::tempdir().unwrap(); let mock = MockServer::start().await; let mission_id = "M-large"; Mock::given(method("POST")) .and(path(format!("/missions/{mission_id}/mapobjects"))) .respond_with(ResponseTemplate::new(200).set_body_string("{}")) .mount(&mock) .await; Mock::given(method("POST")) .and(path(format!("/missions/{mission_id}/mapobjects/ignored"))) .respond_with(ResponseTemplate::new(200).set_body_string("{}")) .mount(&mock) .await; let client = MissionClient::new(options_for(&mock.uri(), 3, tmp.path())).expect("client builds"); let h = client.handle(); let observations = (0..5_000u128).map(|i| obs(mission_id, i)).collect(); let ignored_items = (0..500u128).map(|i| ignored(mission_id, i)).collect(); let diff = MapObjectsDiff { observations, ignored_items, }; // Act let started = Instant::now(); let report = h.push_mapobjects_diff(mission_id, diff).await; let elapsed = started.elapsed(); // Assert assert_eq!(report.sync_state(), SyncState::Synced); assert!( elapsed < Duration::from_secs(120), "push took {elapsed:?}; budget is 2 min" ); }