[AZ-645] [AZ-646] [AZ-647] mission_client: middle-waypoint POST + mapobjects pull/push
ci/woodpecker/push/build-arm Pipeline failed

Batch 3 of greenfield Step 7 — mission_client epic AZ-638 close-out.

AZ-645 (Middle-waypoint POST)
- post_middle_waypoint(mission_id, &Mission) -> Result<MissionUpdateAck, PostError>
- Bounded retry (default 3 attempts) shared with the rest of missions_api
- Health: last_middle_waypoint_post_status (ok/error)

AZ-646 (Pre-flight MapObjects pull)
- pull_mapobjects(mission_id) -> Result<MapObjectsBundle, PullError>
- Schema-validated against bundled shared/contracts/mapobjects-bundle.json
- Typed errors: Unreachable / SchemaInvalid / MaxRetriesExceeded / Internal
- Health: mapobjects_pull_state, last_mapobjects_pull_ts

AZ-647 (Post-flight push + durable disk queue)
- push_mapobjects_diff(mission_id, MapObjectsDiff) -> PushReport
- recover_pending_pushes() -> Vec<PushReport> for crash recovery
- Write-ahead atomic-rename persistence under ${state_dir}/mapobjects_push/
- Per-endpoint independent retry: observations + ignored_items
- Partial success rewrites the disk file with only the failing portion
- Health: mapobjects_push_pending, last_push_ts, per-endpoint last error

Infrastructure
- Schemas: shared/contracts/mapobjects-{bundle,observations,ignored}.json
- Restructured schema/ into mission.rs + mapobjects.rs sub-modules
- New mapobjects_sync/ (pull, push, queue)
- workspace dep tempfile=3; mission_client dev-deps add tempfile + chrono

Tests
- 12/12 ACs verified locally (4 AZ-645 + 4 AZ-646 + 5 AZ-647)
- mission_client suite: 15 unit + 18 integration = 33 tests pass
- AZ-646 AC-4 proxy: 1000-object + 1000-ignored bundle within 30s
- AZ-647 AC-5 proxy: 5000-obs + 500-ignored push within 2min

Code review verdict: PASS_WITH_WARNINGS (inline). Cumulative review
(K=3 trigger) PASS_WITH_WARNINGS — full report in
_docs/03_implementation/cumulative_review_batches_01-03_cycle1_report.md.

Open follow-ups (non-blocking):
- module-layout.md: rename push_mapobjects -> push_mapobjects_diff (Step 13)
- ExponentialBackoff still duplicated across crates; promote to shared::retry
  when the third caller lands (likely detection_client AZ-660/661)
- state_dir default is relative; composition root must override

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-19 12:54:15 +03:00
parent 1c993d86b3
commit 0a87c0f716
25 changed files with 2911 additions and 233 deletions
@@ -0,0 +1,217 @@
//! AZ-646 integration tests driven by `wiremock`.
//!
//! Coverage:
//! - AC-1: happy-path GET returns `Ok(MapObjectsBundle)`; health
//! `mapobjects_pull_state` is `synced`.
//! - AC-2: schema-invalid bundle (missing required field) returns
//! `Err(SchemaInvalid)` — no silent acceptance.
//! - AC-3: unreachable server (a port that refuses connections) returns
//! `Err(Unreachable)` / `MaxRetriesExceeded`; health goes Red.
//! - AC-4: a fixture sized for a 30 km × 30 km mission area validates and
//! deserialises within the 30 s budget on loopback. We use 1 000 objects +
//! 1 000 ignored items as the proxy — a real 30×30 km mission is bounded by
//! this order of magnitude per the AZ-666 ignored-cap NFR.
use std::time::{Duration, Instant};
use chrono::{TimeZone, Utc};
use mission_client::{MissionClient, MissionClientOptions, PullError};
use shared::health::HealthLevel;
use uuid::Uuid;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn options_for(
endpoint: &str,
max_attempts: u32,
tmp_dir: &std::path::Path,
) -> MissionClientOptions {
let mut o = MissionClientOptions::new(endpoint);
o.max_attempts = max_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
}
fn good_bundle(mission_id: &str, objects: usize, ignored: usize) -> serde_json::Value {
let map_objects: Vec<_> = (0..objects)
.map(|i| {
serde_json::json!({
"h3_cell": 10_000 + i as u64,
"mgrs_key": format!("MGRS-{i}"),
"class": "tank",
"class_group": "armor",
"gps_lat": 49.0 + (i as f64) * 0.001,
"gps_lon": 31.0 + (i as f64) * 0.001,
"size_width_m": 3.5,
"size_length_m": 7.0,
"confidence": 0.85,
"first_seen": "2026-05-19T11:00:00Z",
"last_seen": "2026-05-19T11:30:00Z",
"mission_id": mission_id,
"source": "central_pulled",
"pending_upload": false
})
})
.collect();
let ignored_items: Vec<_> = (0..ignored)
.map(|i| {
serde_json::json!({
"id": Uuid::from_u128(0xdead_0000 + i as u128).to_string(),
"mgrs": format!("MGRS-IG-{i}"),
"h3_cell": 99_000 + i as u64,
"class_group": "civilian",
"decline_time": "2026-05-19T11:15:00Z",
"mission_id": mission_id,
"retention_scope": "mission",
"source": "central_pulled",
"pending_upload": false
})
})
.collect();
serde_json::json!({
"schema_version": "1.0.0",
"mission_id": mission_id,
"bbox": [
{ "latitude": 49.5, "longitude": 31.0, "altitude_m": 0.0 },
{ "latitude": 49.0, "longitude": 31.5, "altitude_m": 0.0 }
],
"map_objects": map_objects,
"observations": [],
"ignored_items": ignored_items,
"as_of": "2026-05-19T12:00:00Z",
"freshness": "fresh"
})
}
#[tokio::test]
async fn ac1_happy_path_pull() {
// Arrange
let tmp = tempfile::tempdir().unwrap();
let mock = MockServer::start().await;
let mission_id = "M-mo-1";
Mock::given(method("GET"))
.and(path(format!("/missions/{mission_id}/mapobjects")))
.respond_with(
ResponseTemplate::new(200).set_body_string(good_bundle(mission_id, 3, 1).to_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 bundle = h.pull_mapobjects(mission_id).await.expect("happy pull");
// Assert
assert_eq!(bundle.mission_id, mission_id);
assert_eq!(bundle.map_objects.len(), 3);
assert_eq!(bundle.ignored_items.len(), 1);
let detail = h.health().detail.unwrap_or_default();
assert!(
detail.contains("mapobjects_pull_state=synced"),
"health detail did not record synced: {detail}"
);
}
#[tokio::test]
async fn ac2_schema_invalid_is_rejected() {
// Arrange: 200 OK but the bundle is missing the required `mission_id`.
let tmp = tempfile::tempdir().unwrap();
let mock = MockServer::start().await;
let mut bad = good_bundle("M-bad", 1, 0);
let obj = bad.as_object_mut().unwrap();
obj.remove("mission_id");
Mock::given(method("GET"))
.and(path("/missions/M-bad/mapobjects"))
.respond_with(ResponseTemplate::new(200).set_body_string(bad.to_string()))
.mount(&mock)
.await;
let client =
MissionClient::new(options_for(&mock.uri(), 3, tmp.path())).expect("client builds");
let h = client.handle();
// Act
let err = h.pull_mapobjects("M-bad").await.unwrap_err();
// Assert
match err {
PullError::SchemaInvalid { messages, sample } => {
assert!(messages.iter().any(|m| m.contains("mission_id")));
assert!(!sample.is_empty());
}
other => panic!("expected SchemaInvalid, got {other:?}"),
}
assert_eq!(h.health().level, HealthLevel::Red);
}
#[tokio::test]
async fn ac3_unreachable_surfaces_failure() {
// Arrange: a port the OS will refuse to connect to.
let tmp = tempfile::tempdir().unwrap();
// Bind a TcpListener to discover a free port, then immediately drop it so
// connect() refuses.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let endpoint = format!("http://{addr}");
let client = MissionClient::new(options_for(&endpoint, 2, tmp.path())).expect("client builds");
let h = client.handle();
// Act
let err = h.pull_mapobjects("M-unreach").await.unwrap_err();
// Assert
match err {
PullError::Unreachable(reason) => {
assert!(!reason.is_empty(), "Unreachable reason should not be empty");
}
PullError::MaxRetriesExceeded { attempts, .. } => {
assert_eq!(attempts, 2);
}
other => panic!("expected Unreachable or MaxRetriesExceeded, got {other:?}"),
}
assert_eq!(h.health().level, HealthLevel::Red);
}
#[tokio::test]
async fn ac4_large_bundle_within_budget() {
// Arrange: 1 000 map objects + 1 000 ignored items as the proxy for a
// 30 km × 30 km mission area on loopback.
let tmp = tempfile::tempdir().unwrap();
let mock = MockServer::start().await;
let mission_id = "M-large";
Mock::given(method("GET"))
.and(path(format!("/missions/{mission_id}/mapobjects")))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(good_bundle(mission_id, 1_000, 1_000).to_string()),
)
.mount(&mock)
.await;
let client =
MissionClient::new(options_for(&mock.uri(), 3, tmp.path())).expect("client builds");
let h = client.handle();
// Act
let started = Instant::now();
let bundle = h.pull_mapobjects(mission_id).await.expect("happy pull");
let elapsed = started.elapsed();
// Assert
assert_eq!(bundle.map_objects.len(), 1000);
assert_eq!(bundle.ignored_items.len(), 1000);
assert!(
elapsed < Duration::from_secs(30),
"pull took {elapsed:?}; budget is 30 s"
);
// Sanity-touch chrono to silence dead_code on the import; the wallclock
// dates inside the bundle are validated by the bundle schema's
// `format: date-time` constraint.
let _ = Utc.with_ymd_and_hms(2026, 5, 19, 12, 0, 0).unwrap();
}
@@ -0,0 +1,344 @@
//! 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"
);
}
@@ -0,0 +1,196 @@
//! 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);
}