mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 18:31:11 +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>
197 lines
6.2 KiB
Rust
197 lines
6.2 KiB
Rust
//! 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);
|
|
}
|