mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 22:11:09 +00:00
0a87c0f716
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>
345 lines
12 KiB
Rust
345 lines
12 KiB
Rust
//! 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"
|
|
);
|
|
}
|