//! AZ-644 integration tests driven by `wiremock`. //! //! Coverage: //! - AC-1: happy-path fetch returns `Ok(Mission)` + health reflects connection_state="ok" //! - AC-2: schema-invalid response returns `Err(SchemaInvalid)` with a sample //! - AC-3: transient 503 → 200 sequence retries within budget //! - AC-4: 5 consecutive 503s → `Err(MaxRetriesExceeded)` and health red use std::time::Duration; use shared::health::HealthLevel; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use mission_client::{FetchError, MissionClient, MissionClientOptions}; fn good_mission_body(mission_id: &str) -> String { serde_json::json!({ "mission_id": mission_id, "schema_version": "1.0.0", "items": [ { "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "kind": "waypoint", "at": { "latitude": 49.1, "longitude": 31.2, "altitude_m": 100.0 } } ], "geofences": [], "return_point": { "latitude": 49.0, "longitude": 31.0, "altitude_m": 0.0 } }) .to_string() } fn options_for(mock: &MockServer, attempts: u32) -> MissionClientOptions { let mut o = MissionClientOptions::new(mock.uri()); o.max_attempts = 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 } #[tokio::test] async fn ac1_happy_path_fetch() { // Arrange let mock = MockServer::start().await; let mission_id = "11111111-2222-3333-4444-555555555555"; Mock::given(method("GET")) .and(path(format!("/missions/{mission_id}"))) .respond_with(ResponseTemplate::new(200).set_body_string(good_mission_body(mission_id))) .mount(&mock) .await; let client = MissionClient::new(options_for(&mock, 5)).expect("client builds"); let h = client.handle(); // Act let mission = h.pull_mission(mission_id).await.expect("happy fetch"); // Assert assert_eq!(mission.mission_id.to_string(), mission_id); assert_eq!(mission.schema_version, "1.0.0"); let health = h.health(); assert_eq!(health.level, HealthLevel::Green); } #[tokio::test] async fn ac2_schema_invalid_is_rejected() { // Arrange: HTTP 200 but the body is missing the required `mission_id`. let mock = MockServer::start().await; let bad_body = serde_json::json!({ "schema_version": "1.0.0", "items": [ { "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "kind": "waypoint", "at": { "latitude": 49.1, "longitude": 31.2, "altitude_m": 100.0 } } ], "geofences": [], "return_point": { "latitude": 49.0, "longitude": 31.0, "altitude_m": 0.0 } }) .to_string(); Mock::given(method("GET")) .and(path("/missions/M1")) .respond_with(ResponseTemplate::new(200).set_body_string(bad_body.clone())) .mount(&mock) .await; let client = MissionClient::new(options_for(&mock, 5)).expect("client builds"); let h = client.handle(); // Act let err = h.pull_mission("M1").await.unwrap_err(); // Assert match err { FetchError::SchemaInvalid { messages, sample } => { assert!(messages.iter().any(|m| m.contains("mission_id"))); assert!(!sample.is_empty()); } other => panic!("expected SchemaInvalid, got {other:?}"), } let health = h.health(); assert_eq!(health.level, HealthLevel::Red); } #[tokio::test] async fn ac3_transient_failure_retries_within_budget() { // Arrange: first two requests return 503, third returns 200. let mock = MockServer::start().await; let mission_id = "22222222-3333-4444-5555-666666666666"; Mock::given(method("GET")) .and(path(format!("/missions/{mission_id}"))) .respond_with(ResponseTemplate::new(503)) .up_to_n_times(2) .mount(&mock) .await; Mock::given(method("GET")) .and(path(format!("/missions/{mission_id}"))) .respond_with(ResponseTemplate::new(200).set_body_string(good_mission_body(mission_id))) .mount(&mock) .await; let client = MissionClient::new(options_for(&mock, 5)).expect("client builds"); let h = client.handle(); // Act let mission = h.pull_mission(mission_id).await.expect("retry succeeds"); // Assert assert_eq!(mission.mission_id.to_string(), mission_id); } #[tokio::test] async fn ac4_cap_exhaustion_returns_max_retries() { // Arrange: every request returns 503; we configure 3 attempts to keep the test fast. let mock = MockServer::start().await; Mock::given(method("GET")) .and(path("/missions/M-cap")) .respond_with(ResponseTemplate::new(503)) .mount(&mock) .await; let client = MissionClient::new(options_for(&mock, 3)).expect("client builds"); let h = client.handle(); // Act let err = h.pull_mission("M-cap").await.unwrap_err(); // Assert match err { FetchError::MaxRetriesExceeded { attempts } => assert_eq!(attempts, 3), other => panic!("expected MaxRetriesExceeded, got {other:?}"), } let health = h.health(); assert_eq!(health.level, HealthLevel::Red); } #[tokio::test] async fn permanent_client_error_does_not_retry() { // Arrange: 404 should be permanent (no retry). let mock = MockServer::start().await; let scoped_mock = Mock::given(method("GET")) .and(path("/missions/M-perm")) .respond_with(ResponseTemplate::new(404).set_body_string("not found")) .expect(1) .mount_as_scoped(&mock) .await; let client = MissionClient::new(options_for(&mock, 5)).expect("client builds"); let h = client.handle(); // Act let err = h.pull_mission("M-perm").await.unwrap_err(); // Assert assert!(matches!(err, FetchError::Permanent(_))); drop(scoped_mock); // sanity-asserts the `.expect(1)` count was honored }