//! AZ-645 integration tests driven by `wiremock`. //! //! Coverage: //! - AC-1: happy-path POST returns `Ok(MissionUpdateAck)` and the call is //! observable on the server side; health `last_middle_waypoint_post_status` //! is "ok". //! - AC-2: a single 503 followed by a 200 succeeds on the second attempt //! without surfacing the transient failure. //! - AC-3: a 500-only run exhausts the bounded budget and returns //! `Err(MaxRetriesExceeded)`; the error is surfaced — not swallowed — and //! health goes Red. use std::time::Duration; use mission_client::{MissionClient, MissionClientOptions, PostError}; use shared::health::HealthLevel; use shared::models::mission::{Coordinate, MissionItem, MissionItemKind}; use uuid::Uuid; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn patched_mission(mission_id: Uuid) -> mission_client::Mission { mission_client::Mission { mission_id, schema_version: "1.0.0".into(), items: vec![MissionItem { id: Uuid::from_u128(0xaaaa_aaaa), kind: MissionItemKind::Waypoint, at: Some(Coordinate { latitude: 49.1, longitude: 31.2, altitude_m: 100.0, }), region: vec![], cruise_speed_mps: None, target_classes: vec![], }], geofences: vec![], return_point: Coordinate { latitude: 49.0, longitude: 31.0, altitude_m: 0.0, }, } } fn options_for( mock: &MockServer, post_attempts: u32, tmp_dir: &std::path::Path, ) -> MissionClientOptions { let mut o = MissionClientOptions::new(mock.uri()); o.max_attempts = 3; o.post_max_attempts = post_attempts; o.backoff_base = Duration::from_millis(10); o.backoff_cap = Duration::from_millis(50); o.request_timeout = Duration::from_secs(2); o.connect_timeout = Duration::from_secs(1); o.state_dir = tmp_dir.to_path_buf(); o } #[tokio::test] async fn ac1_happy_path_post() { // Arrange let tmp = tempfile::tempdir().unwrap(); let mock = MockServer::start().await; let mission_id = Uuid::from_u128(0x1111_1111); Mock::given(method("POST")) .and(path(format!("/missions/{mission_id}/middle-waypoint"))) .respond_with( ResponseTemplate::new(200).set_body_string( serde_json::json!({ "mission_id": mission_id.to_string(), "revision": 7 }) .to_string(), ), ) .expect(1) .mount(&mock) .await; let client = MissionClient::new(options_for(&mock, 3, tmp.path())).expect("client builds"); let h = client.handle(); let patched = patched_mission(mission_id); // Act let ack = h .post_middle_waypoint(&mission_id.to_string(), &patched) .await .expect("happy POST"); // Assert assert_eq!(ack.mission_id, mission_id.to_string()); assert_eq!(ack.revision, Some(7)); let detail = h.health().detail.unwrap_or_default(); assert!( detail.contains("last_middle_waypoint_post_status=ok"), "health detail did not record OK: {detail}" ); } #[tokio::test] async fn ac2_transient_failure_retries() { // Arrange let tmp = tempfile::tempdir().unwrap(); let mock = MockServer::start().await; let mission_id = Uuid::from_u128(0x2222_2222); Mock::given(method("POST")) .and(path(format!("/missions/{mission_id}/middle-waypoint"))) .respond_with(ResponseTemplate::new(503)) .up_to_n_times(1) .mount(&mock) .await; Mock::given(method("POST")) .and(path(format!("/missions/{mission_id}/middle-waypoint"))) .respond_with(ResponseTemplate::new(200).set_body_string( serde_json::json!({ "mission_id": mission_id.to_string() }).to_string(), )) .mount(&mock) .await; let client = MissionClient::new(options_for(&mock, 3, tmp.path())).expect("client builds"); let h = client.handle(); // Act let ack = h .post_middle_waypoint(&mission_id.to_string(), &patched_mission(mission_id)) .await .expect("retry succeeds"); // Assert assert_eq!(ack.mission_id, mission_id.to_string()); } #[tokio::test] async fn ac3_cap_exhaustion_bubbles_error() { // Arrange let tmp = tempfile::tempdir().unwrap(); let mock = MockServer::start().await; let mission_id = Uuid::from_u128(0x3333_3333); Mock::given(method("POST")) .and(path(format!("/missions/{mission_id}/middle-waypoint"))) .respond_with(ResponseTemplate::new(500)) .mount(&mock) .await; let client = MissionClient::new(options_for(&mock, 3, tmp.path())).expect("client builds"); let h = client.handle(); // Act let err = h .post_middle_waypoint(&mission_id.to_string(), &patched_mission(mission_id)) .await .unwrap_err(); // Assert match err { PostError::MaxRetriesExceeded { attempts, last_reason, } => { assert_eq!(attempts, 3); assert!( last_reason.contains("500"), "expected 500 in last_reason, got {last_reason}" ); } other => panic!("expected MaxRetriesExceeded, got {other:?}"), } assert_eq!(h.health().level, HealthLevel::Red); } #[tokio::test] async fn permanent_4xx_does_not_retry() { // Arrange: a 400 should not trigger retries. let tmp = tempfile::tempdir().unwrap(); let mock = MockServer::start().await; let mission_id = Uuid::from_u128(0x4444_4444); let scoped = Mock::given(method("POST")) .and(path(format!("/missions/{mission_id}/middle-waypoint"))) .respond_with(ResponseTemplate::new(400).set_body_string("bad request")) .expect(1) .mount_as_scoped(&mock) .await; let client = MissionClient::new(options_for(&mock, 3, tmp.path())).expect("client builds"); let h = client.handle(); // Act let err = h .post_middle_waypoint(&mission_id.to_string(), &patched_mission(mission_id)) .await .unwrap_err(); // Assert assert!(matches!(err, PostError::Permanent(_))); drop(scoped); }