mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-22 15:41:09 +00:00
[AZ-645] [AZ-646] [AZ-647] mission_client: middle-waypoint POST + mapobjects pull/push
ci/woodpecker/push/build-arm Pipeline failed
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:
@@ -20,4 +20,6 @@ uuid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "net", "signal", "test-util"] }
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Pre-flight pull (AZ-646) and post-flight push (AZ-647) of MapObjects.
|
||||
//!
|
||||
//! - `pull` — schema-validated GET wrapper
|
||||
//! - `push` — write-ahead disk queue + per-endpoint bounded retry
|
||||
//! - `queue` — atomic-rename disk persistence shared by `push` and the
|
||||
//! startup crash-recovery sweep
|
||||
|
||||
pub mod pull;
|
||||
pub mod push;
|
||||
pub mod queue;
|
||||
@@ -0,0 +1,45 @@
|
||||
//! AZ-646 — schema-validated MapObjects bundle fetch.
|
||||
|
||||
use shared::models::mapobject::MapObjectsBundle;
|
||||
|
||||
use crate::internal::missions_api::{HttpClient, RawHttpError};
|
||||
use crate::internal::schema::mapobjects::{validate_bundle, MapObjectsSchemaError};
|
||||
use crate::{MissionClientOptions, PullError};
|
||||
|
||||
/// Fetch + schema-validate the MapObjects bundle. Returns the typed bundle on
|
||||
/// success, or a [`PullError`] surface that lets `mission_executor`'s F9 BIT
|
||||
/// path distinguish a transient outage from a schema mismatch.
|
||||
pub async fn pull(
|
||||
http: &HttpClient,
|
||||
mission_id: &str,
|
||||
opts: &MissionClientOptions,
|
||||
) -> Result<MapObjectsBundle, PullError> {
|
||||
match http.pull_mapobjects_raw(mission_id, opts).await {
|
||||
Ok(body) => {
|
||||
// Schema-validate before typed deserialise so the caller can show
|
||||
// the offending body excerpt to the operator instead of a generic
|
||||
// serde error.
|
||||
validate_bundle(&body).map_err(|e| match e {
|
||||
MapObjectsSchemaError::Invalid { messages, sample } => {
|
||||
PullError::SchemaInvalid { messages, sample }
|
||||
}
|
||||
MapObjectsSchemaError::ParseJson { message, sample } => PullError::SchemaInvalid {
|
||||
messages: vec![message],
|
||||
sample,
|
||||
},
|
||||
})?;
|
||||
let bundle: MapObjectsBundle = serde_json::from_str(&body)
|
||||
.map_err(|e| PullError::Internal(format!("deserialise bundle: {e}")))?;
|
||||
Ok(bundle)
|
||||
}
|
||||
Err(RawHttpError::Permanent(reason)) => Err(PullError::Unreachable(reason)),
|
||||
Err(RawHttpError::MaxRetries {
|
||||
attempts,
|
||||
last_reason,
|
||||
}) => Err(PullError::MaxRetriesExceeded {
|
||||
attempts,
|
||||
last_reason,
|
||||
}),
|
||||
Err(RawHttpError::Transient(reason)) => Err(PullError::Unreachable(reason)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! AZ-647 — post-flight MapObjects push with durable disk queue.
|
||||
//!
|
||||
//! Invariants (per the AZ-647 task spec):
|
||||
//!
|
||||
//! - **Write-ahead**: the pending diff is persisted to disk BEFORE the network
|
||||
//! call. A crash mid-push leaves the disk file intact for replay.
|
||||
//! - **Per-endpoint independent retry**: observations and ignored_items are
|
||||
//! POSTed to two different endpoints. A 503 on one MUST NOT roll back a 200
|
||||
//! on the other; the disk file is rewritten to hold only the residual
|
||||
//! portion after each successful endpoint.
|
||||
//! - **Bounded retry window**: each endpoint retries up to
|
||||
//! `push_max_attempts` times with exponential backoff (default budget ≈ 24 h
|
||||
//! per the task NFR; tests override this to keep runs fast).
|
||||
//! - **Crash recovery**: at startup, [`recover_pending`] sweeps every file in
|
||||
//! the queue dir and runs `push` for each before the caller proceeds with a
|
||||
//! new mission. The order is logged for observability.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::json;
|
||||
use shared::models::mapobject::{IgnoredItem, MapObjectObservation};
|
||||
use tracing::info;
|
||||
|
||||
use crate::internal::mapobjects_sync::queue::{
|
||||
self, ensure_dir, list_pending, write_atomic, PendingDiff,
|
||||
};
|
||||
use crate::internal::missions_api::{HttpClient, RawHttpError};
|
||||
use crate::internal::schema::mapobjects::{
|
||||
validate_ignored_push, validate_observations_push, MapObjectsSchemaError,
|
||||
};
|
||||
use crate::{MapObjectsDiff, MissionClientOptions, PerEndpointStatus, PushReport};
|
||||
|
||||
/// Push a diff with full write-ahead + per-endpoint retry semantics. Returns
|
||||
/// a [`PushReport`] describing the outcome of EACH endpoint independently.
|
||||
///
|
||||
/// On entry the diff is persisted under
|
||||
/// `${state_dir}/mapobjects_push/<mission_id>.json`. On exit the disk file is
|
||||
/// either deleted (both endpoints succeeded) or rewritten with the residual
|
||||
/// portion (one or both endpoints failed).
|
||||
pub async fn push(
|
||||
http: &HttpClient,
|
||||
state_dir: &Path,
|
||||
mission_id: &str,
|
||||
diff: MapObjectsDiff,
|
||||
opts: &MissionClientOptions,
|
||||
) -> PushReport {
|
||||
let attempts = opts.push_max_attempts;
|
||||
|
||||
// Best-effort dir ensure. If this fails (permissions, full disk, ...) we
|
||||
// still attempt the POSTs — the on-disk durability promise is broken but
|
||||
// we should NOT swallow the network call too.
|
||||
if let Err(e) = ensure_dir(state_dir) {
|
||||
tracing::warn!(component = "mission_client", error = %e, "could not create state_dir/mapobjects_push");
|
||||
}
|
||||
|
||||
// Validate locally BEFORE the write-ahead so a malformed diff fails fast
|
||||
// and does not leave a poisonous file on disk.
|
||||
let obs_body = build_observations_body(mission_id, &diff.observations);
|
||||
let ignored_body = build_ignored_body(mission_id, &diff.ignored_items);
|
||||
if let Err(e) = validate_observations_push(&obs_body.to_string()) {
|
||||
return PushReport::local_invalid(observations_error(e));
|
||||
}
|
||||
if let Err(e) = validate_ignored_push(&ignored_body.to_string()) {
|
||||
return PushReport::local_invalid(ignored_error(e));
|
||||
}
|
||||
|
||||
// Write-ahead.
|
||||
let path = queue::diff_path(state_dir, mission_id);
|
||||
let mut residual = PendingDiff {
|
||||
observations: diff.observations,
|
||||
ignored_items: diff.ignored_items,
|
||||
};
|
||||
if let Err(e) = write_atomic(&path, &residual) {
|
||||
tracing::error!(component = "mission_client", error = %e, mission = mission_id, "write-ahead persistence failed; aborting push");
|
||||
return PushReport::disk_failure(e.to_string());
|
||||
}
|
||||
|
||||
// Endpoint 1: observations
|
||||
let obs_status = match http
|
||||
.post_mapobjects_raw(mission_id, &obs_body, opts, attempts)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
residual.observations.clear();
|
||||
persist_or_delete(&path, &residual);
|
||||
PerEndpointStatus::Success
|
||||
}
|
||||
Err(e) => to_endpoint_failure(e),
|
||||
};
|
||||
|
||||
// Endpoint 2: ignored_items
|
||||
let ignored_status = match http
|
||||
.post_mapobjects_ignored_raw(mission_id, &ignored_body, opts, attempts)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
residual.ignored_items.clear();
|
||||
persist_or_delete(&path, &residual);
|
||||
PerEndpointStatus::Success
|
||||
}
|
||||
Err(e) => to_endpoint_failure(e),
|
||||
};
|
||||
|
||||
PushReport {
|
||||
observations: obs_status,
|
||||
ignored: ignored_status,
|
||||
mission_id: mission_id.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Crash-recovery sweep: replay every pending diff under the queue dir.
|
||||
/// Returns one report per replayed mission, in lexicographic mission-id order.
|
||||
pub async fn recover_pending(
|
||||
http: &HttpClient,
|
||||
state_dir: &Path,
|
||||
opts: &MissionClientOptions,
|
||||
) -> Vec<PushReport> {
|
||||
let pending = match list_pending(state_dir) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!(component = "mission_client", error = %e, "could not enumerate pending pushes");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let mut out = Vec::with_capacity(pending.len());
|
||||
for (mission_id, path) in pending {
|
||||
let disk = match queue::read(&path) {
|
||||
Ok(Some(d)) => d,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
tracing::warn!(component = "mission_client", error = %e, mission = %mission_id, "could not read pending diff");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if disk.is_empty() {
|
||||
queue::delete(&path).ok();
|
||||
continue;
|
||||
}
|
||||
info!(
|
||||
component = "mission_client",
|
||||
mission = %mission_id,
|
||||
obs = disk.observations.len(),
|
||||
ignored = disk.ignored_items.len(),
|
||||
"crash-recovery replay starting"
|
||||
);
|
||||
let diff = MapObjectsDiff {
|
||||
observations: disk.observations,
|
||||
ignored_items: disk.ignored_items,
|
||||
};
|
||||
let report = push(http, state_dir, &mission_id, diff, opts).await;
|
||||
out.push(report);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn persist_or_delete(path: &Path, residual: &PendingDiff) {
|
||||
if residual.is_empty() {
|
||||
if let Err(e) = queue::delete(path) {
|
||||
tracing::warn!(component = "mission_client", error = %e, "could not delete drained queue file");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Err(e) = write_atomic(path, residual) {
|
||||
tracing::warn!(component = "mission_client", error = %e, "could not rewrite residual queue file");
|
||||
}
|
||||
}
|
||||
|
||||
fn build_observations_body(mission_id: &str, items: &[MapObjectObservation]) -> serde_json::Value {
|
||||
json!({
|
||||
"mission_id": mission_id,
|
||||
"observations": items,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_ignored_body(mission_id: &str, items: &[IgnoredItem]) -> serde_json::Value {
|
||||
json!({
|
||||
"mission_id": mission_id,
|
||||
"ignored_items": items,
|
||||
})
|
||||
}
|
||||
|
||||
fn observations_error(e: MapObjectsSchemaError) -> String {
|
||||
match e {
|
||||
MapObjectsSchemaError::Invalid { messages, .. } => {
|
||||
format!("observations payload invalid: {}", messages.join("; "))
|
||||
}
|
||||
MapObjectsSchemaError::ParseJson { message, .. } => {
|
||||
format!("observations payload not JSON: {message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ignored_error(e: MapObjectsSchemaError) -> String {
|
||||
match e {
|
||||
MapObjectsSchemaError::Invalid { messages, .. } => {
|
||||
format!("ignored payload invalid: {}", messages.join("; "))
|
||||
}
|
||||
MapObjectsSchemaError::ParseJson { message, .. } => {
|
||||
format!("ignored payload not JSON: {message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_endpoint_failure(e: RawHttpError) -> PerEndpointStatus {
|
||||
match e {
|
||||
RawHttpError::Permanent(reason) => PerEndpointStatus::Permanent { reason },
|
||||
RawHttpError::MaxRetries {
|
||||
attempts,
|
||||
last_reason,
|
||||
} => PerEndpointStatus::MaxRetriesExceeded {
|
||||
attempts,
|
||||
last_reason,
|
||||
},
|
||||
RawHttpError::Transient(reason) => PerEndpointStatus::Permanent { reason },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Write-ahead disk queue for AZ-647.
|
||||
//!
|
||||
//! Layout (one file per pending mission diff):
|
||||
//!
|
||||
//! ```text
|
||||
//! ${state_dir}/mapobjects_push/
|
||||
//! ├─ <mission_id>.json ← canonical pending diff
|
||||
//! └─ <mission_id>.json.tmp ← in-flight write (renamed on success)
|
||||
//! ```
|
||||
//!
|
||||
//! All writes are atomic (write to `.tmp`, fsync, rename), so a crash mid-push
|
||||
//! never leaves a half-written diff that would corrupt the next replay.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::models::mapobject::{IgnoredItem, MapObjectObservation};
|
||||
|
||||
/// Pending diff stored on disk under `${state_dir}/mapobjects_push/<id>.json`.
|
||||
/// The filename carries `mission_id`; the body is just the residual diff that
|
||||
/// still needs to be pushed (observations + ignored_items).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PendingDiff {
|
||||
#[serde(default)]
|
||||
pub observations: Vec<MapObjectObservation>,
|
||||
#[serde(default)]
|
||||
pub ignored_items: Vec<IgnoredItem>,
|
||||
}
|
||||
|
||||
impl PendingDiff {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.observations.is_empty() && self.ignored_items.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sub-folder name within `state_dir` that holds the push queue.
|
||||
const SUBDIR: &str = "mapobjects_push";
|
||||
|
||||
/// Build the per-mission file path.
|
||||
pub fn diff_path(state_dir: &Path, mission_id: &str) -> PathBuf {
|
||||
state_dir.join(SUBDIR).join(format!("{mission_id}.json"))
|
||||
}
|
||||
|
||||
/// Ensure `${state_dir}/mapobjects_push/` exists so subsequent writes don't
|
||||
/// race against a missing directory.
|
||||
pub fn ensure_dir(state_dir: &Path) -> io::Result<PathBuf> {
|
||||
let dir = state_dir.join(SUBDIR);
|
||||
fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Atomic write: serialise `diff`, write to `<path>.tmp`, fsync, then rename
|
||||
/// on top of `<path>`. The temp file is `O_TRUNC`-style each time so partial
|
||||
/// state from a previous failed write does not leak.
|
||||
pub fn write_atomic(path: &Path, diff: &PendingDiff) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = with_tmp_suffix(path);
|
||||
{
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&tmp)?;
|
||||
let body = serde_json::to_vec(diff).map_err(io::Error::other)?;
|
||||
f.write_all(&body)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a pending diff. Returns `Ok(None)` if the file does not exist (so the
|
||||
/// caller can treat "no pending push" as a routine state, not an error).
|
||||
pub fn read(path: &Path) -> io::Result<Option<PendingDiff>> {
|
||||
match fs::read(path) {
|
||||
Ok(bytes) => {
|
||||
let diff: PendingDiff = serde_json::from_slice(&bytes).map_err(io::Error::other)?;
|
||||
Ok(Some(diff))
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a pending diff file. Idempotent — `NotFound` is treated as success
|
||||
/// because the only reason to call this is "no work remaining for this id".
|
||||
pub fn delete(path: &Path) -> io::Result<()> {
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// List every pending `<mission_id>.json` under the queue dir. Used by the
|
||||
/// crash-recovery sweep at startup.
|
||||
pub fn list_pending(state_dir: &Path) -> io::Result<Vec<(String, PathBuf)>> {
|
||||
let dir = state_dir.join(SUBDIR);
|
||||
let read = match fs::read_dir(&dir) {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in read {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
out.push((stem.to_owned(), path));
|
||||
}
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn with_tmp_suffix(path: &Path) -> PathBuf {
|
||||
let mut s = path.as_os_str().to_owned();
|
||||
s.push(".tmp");
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use shared::models::mapobject::{
|
||||
DiffKind, IgnoredItemSource, MapObjectObservation, RetentionScope,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn obs() -> MapObjectObservation {
|
||||
MapObjectObservation {
|
||||
id: Uuid::nil(),
|
||||
h3_cell: 1,
|
||||
class: "tank".into(),
|
||||
class_group: "armor".into(),
|
||||
mission_id: "M1".into(),
|
||||
uav_id: "uav-1".into(),
|
||||
observed_at_monotonic_ns: 1,
|
||||
observed_at_wallclock: Utc.with_ymd_and_hms(2026, 5, 19, 12, 0, 0).unwrap(),
|
||||
gps_lat: 49.0,
|
||||
gps_lon: 31.0,
|
||||
mgrs: "X".into(),
|
||||
size_width_m: 3.0,
|
||||
size_length_m: 6.0,
|
||||
confidence: 0.9,
|
||||
diff_kind: DiffKind::New,
|
||||
photo_ref: None,
|
||||
raw_evidence: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ignored() -> IgnoredItem {
|
||||
IgnoredItem {
|
||||
id: Uuid::nil(),
|
||||
mgrs: "X".into(),
|
||||
h3_cell: 1,
|
||||
class_group: "armor".into(),
|
||||
decline_time: Utc.with_ymd_and_hms(2026, 5, 19, 12, 0, 0).unwrap(),
|
||||
operator_id: None,
|
||||
mission_id: "M1".into(),
|
||||
retention_scope: RetentionScope::Mission,
|
||||
expires_at: None,
|
||||
source: IgnoredItemSource::LocalAppended,
|
||||
pending_upload: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_read_round_trips() {
|
||||
// Arrange
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = diff_path(dir.path(), "M1");
|
||||
let diff = PendingDiff {
|
||||
observations: vec![obs()],
|
||||
ignored_items: vec![ignored()],
|
||||
};
|
||||
|
||||
// Act
|
||||
write_atomic(&path, &diff).unwrap();
|
||||
let loaded = read(&path).unwrap().unwrap();
|
||||
|
||||
// Assert
|
||||
assert_eq!(loaded.observations.len(), 1);
|
||||
assert_eq!(loaded.ignored_items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_missing_returns_none() {
|
||||
// Arrange
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = diff_path(dir.path(), "M-absent");
|
||||
|
||||
// Act
|
||||
let r = read(&path).unwrap();
|
||||
|
||||
// Assert
|
||||
assert!(r.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_is_idempotent() {
|
||||
// Arrange
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = diff_path(dir.path(), "M1");
|
||||
let diff = PendingDiff::default();
|
||||
write_atomic(&path, &diff).unwrap();
|
||||
|
||||
// Act
|
||||
delete(&path).unwrap();
|
||||
delete(&path).unwrap();
|
||||
|
||||
// Assert
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_pending_returns_sorted_mission_ids() {
|
||||
// Arrange
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
ensure_dir(dir.path()).unwrap();
|
||||
write_atomic(&diff_path(dir.path(), "M-b"), &PendingDiff::default()).unwrap();
|
||||
write_atomic(&diff_path(dir.path(), "M-a"), &PendingDiff::default()).unwrap();
|
||||
|
||||
// Act
|
||||
let r = list_pending(dir.path()).unwrap();
|
||||
|
||||
// Assert
|
||||
assert_eq!(
|
||||
r.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["M-a", "M-b"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,26 @@
|
||||
//! REST client to the external `missions` API.
|
||||
//!
|
||||
//! One [`HttpClient`] per [`crate::MissionClient`]. The client exposes
|
||||
//! per-endpoint methods that wrap a single HTTP call ([`HttpClient::get_once`],
|
||||
//! [`HttpClient::post_once_json`]) with bounded exponential backoff. Transient
|
||||
//! failures (timeouts, connect errors, 5xx, 429) are retried; permanent
|
||||
//! failures (4xx except 429, malformed URLs) abort the call immediately. The
|
||||
//! caller chooses how to deserialise / validate the success body.
|
||||
//!
|
||||
//! Used by:
|
||||
//! - AZ-644 — `pull_mission_raw` (GET `/missions/{id}`)
|
||||
//! - AZ-645 — `post_middle_waypoint_raw` (POST `/missions/{id}/middle-waypoint`)
|
||||
//! - AZ-646 — `pull_mapobjects_raw` (GET `/missions/{id}/mapobjects`)
|
||||
//! - AZ-647 — `post_mapobjects_raw` + `post_mapobjects_ignored_raw`
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::{header, Client, StatusCode};
|
||||
use reqwest::{header, Client, Method, StatusCode};
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::internal::retry::ExponentialBackoff;
|
||||
use crate::internal::schema::{validate, SchemaError};
|
||||
use crate::internal::schema::mission::{validate as validate_mission, SchemaError};
|
||||
use crate::{FetchError, MissionClientOptions};
|
||||
|
||||
/// HTTPS client wrapper. One instance per `MissionClient`.
|
||||
@@ -33,104 +46,243 @@ impl HttpClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Single HTTP call — no retry. The caller (with backoff) decides what to do.
|
||||
async fn get_once(&self, mission_id: &str) -> Result<String, RawFetchError> {
|
||||
let url = format!(
|
||||
"{}/missions/{}",
|
||||
self.endpoint.trim_end_matches('/'),
|
||||
mission_id
|
||||
);
|
||||
let mut req = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header(header::ACCEPT, "application/json");
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}{}", self.endpoint.trim_end_matches('/'), path)
|
||||
}
|
||||
|
||||
fn apply_auth(&self, mut req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if let Some(tok) = &self.bearer {
|
||||
req = req.bearer_auth(tok);
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| {
|
||||
if e.is_timeout() || e.is_connect() {
|
||||
RawFetchError::Transient(e.to_string())
|
||||
} else if e.is_request() || e.is_builder() {
|
||||
RawFetchError::Permanent(e.to_string())
|
||||
} else {
|
||||
RawFetchError::Transient(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| RawFetchError::Transient(format!("read body: {e}")))?;
|
||||
|
||||
if status.is_success() {
|
||||
return Ok(body);
|
||||
}
|
||||
// Retry on 5xx (and treat 429 as transient too).
|
||||
if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
|
||||
return Err(RawFetchError::Transient(format!(
|
||||
"http {status}: {}",
|
||||
preview(&body)
|
||||
)));
|
||||
}
|
||||
Err(RawFetchError::Permanent(format!(
|
||||
"http {status}: {}",
|
||||
preview(&body)
|
||||
)))
|
||||
req
|
||||
}
|
||||
|
||||
/// Fetch + validate + return the typed JSON value (caller deserialises into
|
||||
/// the typed model). Implements bounded exponential backoff on transient
|
||||
/// failures only; permanent failures abort immediately.
|
||||
pub async fn pull_mission_raw(
|
||||
&self,
|
||||
mission_id: &str,
|
||||
opts: &MissionClientOptions,
|
||||
) -> Result<Value, FetchError> {
|
||||
let mut backoff = ExponentialBackoff::new(opts.backoff_base, opts.backoff_cap);
|
||||
/// Single GET — no retry. Returns the raw body on success or a typed
|
||||
/// `RawHttpError` on failure (caller decides whether to retry).
|
||||
async fn get_once(&self, path: &str) -> Result<String, RawHttpError> {
|
||||
let url = self.url(path);
|
||||
let req = self
|
||||
.apply_auth(self.client.request(Method::GET, &url))
|
||||
.header(header::ACCEPT, "application/json");
|
||||
send_and_collect(req).await
|
||||
}
|
||||
|
||||
/// Single POST — no retry. Body is sent as `application/json`.
|
||||
async fn post_once_json(&self, path: &str, body: &Value) -> Result<String, RawHttpError> {
|
||||
let url = self.url(path);
|
||||
let req = self
|
||||
.apply_auth(self.client.request(Method::POST, &url))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.json(body);
|
||||
send_and_collect(req).await
|
||||
}
|
||||
|
||||
/// Bounded-retry GET; returns the response body as a `String` on success.
|
||||
/// Transient failures (timeout / connect / 5xx / 429) trigger backoff;
|
||||
/// permanent failures (4xx non-429, builder errors) abort immediately.
|
||||
async fn get_with_retry(
|
||||
&self,
|
||||
path: &str,
|
||||
opts: &MissionClientOptions,
|
||||
) -> Result<String, RawHttpError> {
|
||||
let mut backoff = ExponentialBackoff::new(opts.backoff_base, opts.backoff_cap);
|
||||
for attempt in 1..=opts.max_attempts {
|
||||
match self.get_once(mission_id).await {
|
||||
Ok(body) => {
|
||||
let value = validate(&body).map_err(|e| match e {
|
||||
SchemaError::Invalid { messages, sample } => {
|
||||
FetchError::SchemaInvalid { messages, sample }
|
||||
}
|
||||
SchemaError::ParseJson { message, sample } => FetchError::SchemaInvalid {
|
||||
messages: vec![message],
|
||||
sample,
|
||||
},
|
||||
})?;
|
||||
return Ok(value);
|
||||
}
|
||||
Err(RawFetchError::Permanent(reason)) => {
|
||||
return Err(FetchError::Permanent(reason));
|
||||
}
|
||||
Err(RawFetchError::Transient(reason)) => {
|
||||
match self.get_once(path).await {
|
||||
Ok(body) => return Ok(body),
|
||||
Err(RawHttpError::Permanent(p)) => return Err(RawHttpError::Permanent(p)),
|
||||
Err(RawHttpError::Transient(reason)) => {
|
||||
warn!(
|
||||
component = "mission_client",
|
||||
attempt,
|
||||
max = opts.max_attempts,
|
||||
endpoint = %path,
|
||||
reason = %reason,
|
||||
"transient fetch failure"
|
||||
"transient GET failure"
|
||||
);
|
||||
if attempt < opts.max_attempts {
|
||||
tokio::time::sleep(backoff.next_delay()).await;
|
||||
continue;
|
||||
}
|
||||
return Err(RawHttpError::MaxRetries {
|
||||
attempts: opts.max_attempts,
|
||||
last_reason: reason,
|
||||
});
|
||||
}
|
||||
Err(other @ RawHttpError::MaxRetries { .. }) => return Err(other),
|
||||
}
|
||||
}
|
||||
Err(FetchError::MaxRetriesExceeded {
|
||||
// Unreachable for max_attempts > 0; defensive.
|
||||
Err(RawHttpError::MaxRetries {
|
||||
attempts: opts.max_attempts,
|
||||
last_reason: "no attempts performed".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Bounded-retry POST with a configurable attempt cap (so different
|
||||
/// callers can use different policies — e.g. AZ-645 short, AZ-647 long).
|
||||
async fn post_with_retry(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &Value,
|
||||
opts: &MissionClientOptions,
|
||||
max_attempts: u32,
|
||||
) -> Result<String, RawHttpError> {
|
||||
let mut backoff = ExponentialBackoff::new(opts.backoff_base, opts.backoff_cap);
|
||||
for attempt in 1..=max_attempts {
|
||||
match self.post_once_json(path, body).await {
|
||||
Ok(body_text) => return Ok(body_text),
|
||||
Err(RawHttpError::Permanent(p)) => return Err(RawHttpError::Permanent(p)),
|
||||
Err(RawHttpError::Transient(reason)) => {
|
||||
warn!(
|
||||
component = "mission_client",
|
||||
attempt,
|
||||
max = max_attempts,
|
||||
endpoint = %path,
|
||||
reason = %reason,
|
||||
"transient POST failure"
|
||||
);
|
||||
if attempt < max_attempts {
|
||||
tokio::time::sleep(backoff.next_delay()).await;
|
||||
continue;
|
||||
}
|
||||
return Err(RawHttpError::MaxRetries {
|
||||
attempts: max_attempts,
|
||||
last_reason: reason,
|
||||
});
|
||||
}
|
||||
Err(other @ RawHttpError::MaxRetries { .. }) => return Err(other),
|
||||
}
|
||||
}
|
||||
Err(RawHttpError::MaxRetries {
|
||||
attempts: max_attempts,
|
||||
last_reason: "no attempts performed".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- AZ-644 ----
|
||||
/// Fetch + schema-validate the mission JSON. Returns the parsed JSON
|
||||
/// `Value` so the caller can re-deserialise into the typed model.
|
||||
pub async fn pull_mission_raw(
|
||||
&self,
|
||||
mission_id: &str,
|
||||
opts: &MissionClientOptions,
|
||||
) -> Result<Value, FetchError> {
|
||||
let path = format!("/missions/{mission_id}");
|
||||
match self.get_with_retry(&path, opts).await {
|
||||
Ok(body) => validate_mission(&body).map_err(|e| match e {
|
||||
SchemaError::Invalid { messages, sample } => {
|
||||
FetchError::SchemaInvalid { messages, sample }
|
||||
}
|
||||
SchemaError::ParseJson { message, sample } => FetchError::SchemaInvalid {
|
||||
messages: vec![message],
|
||||
sample,
|
||||
},
|
||||
}),
|
||||
Err(RawHttpError::Permanent(reason)) => Err(FetchError::Permanent(reason)),
|
||||
Err(RawHttpError::MaxRetries { attempts, .. }) => {
|
||||
Err(FetchError::MaxRetriesExceeded { attempts })
|
||||
}
|
||||
Err(RawHttpError::Transient(reason)) => Err(FetchError::Permanent(reason)),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- AZ-645 ----
|
||||
/// POST a patched mission to the middle-waypoint endpoint. Returns the raw
|
||||
/// response body so the caller can deserialise into [`crate::MissionUpdateAck`].
|
||||
pub async fn post_middle_waypoint_raw(
|
||||
&self,
|
||||
mission_id: &str,
|
||||
body: &Value,
|
||||
opts: &MissionClientOptions,
|
||||
) -> Result<String, RawHttpError> {
|
||||
let path = format!("/missions/{mission_id}/middle-waypoint");
|
||||
self.post_with_retry(&path, body, opts, opts.post_max_attempts)
|
||||
.await
|
||||
}
|
||||
|
||||
// ---- AZ-646 ----
|
||||
/// Fetch the MapObjects bundle for a mission. Returns the raw body so the
|
||||
/// caller can validate against the bundle schema.
|
||||
pub async fn pull_mapobjects_raw(
|
||||
&self,
|
||||
mission_id: &str,
|
||||
opts: &MissionClientOptions,
|
||||
) -> Result<String, RawHttpError> {
|
||||
let path = format!("/missions/{mission_id}/mapobjects");
|
||||
self.get_with_retry(&path, opts).await
|
||||
}
|
||||
|
||||
// ---- AZ-647 ----
|
||||
/// POST observations to `/missions/{id}/mapobjects`.
|
||||
pub async fn post_mapobjects_raw(
|
||||
&self,
|
||||
mission_id: &str,
|
||||
body: &Value,
|
||||
opts: &MissionClientOptions,
|
||||
max_attempts: u32,
|
||||
) -> Result<String, RawHttpError> {
|
||||
let path = format!("/missions/{mission_id}/mapobjects");
|
||||
self.post_with_retry(&path, body, opts, max_attempts).await
|
||||
}
|
||||
|
||||
/// POST ignored items to `/missions/{id}/mapobjects/ignored`.
|
||||
pub async fn post_mapobjects_ignored_raw(
|
||||
&self,
|
||||
mission_id: &str,
|
||||
body: &Value,
|
||||
opts: &MissionClientOptions,
|
||||
max_attempts: u32,
|
||||
) -> Result<String, RawHttpError> {
|
||||
let path = format!("/missions/{mission_id}/mapobjects/ignored");
|
||||
self.post_with_retry(&path, body, opts, max_attempts).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RawFetchError {
|
||||
async fn send_and_collect(req: reqwest::RequestBuilder) -> Result<String, RawHttpError> {
|
||||
let resp = req.send().await.map_err(|e| {
|
||||
if e.is_timeout() || e.is_connect() {
|
||||
RawHttpError::Transient(e.to_string())
|
||||
} else if e.is_request() || e.is_builder() {
|
||||
RawHttpError::Permanent(e.to_string())
|
||||
} else {
|
||||
RawHttpError::Transient(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| RawHttpError::Transient(format!("read body: {e}")))?;
|
||||
|
||||
if status.is_success() {
|
||||
return Ok(body);
|
||||
}
|
||||
// 5xx and 429 are transient and trigger backoff.
|
||||
if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
|
||||
return Err(RawHttpError::Transient(format!(
|
||||
"http {status}: {}",
|
||||
preview(&body)
|
||||
)));
|
||||
}
|
||||
Err(RawHttpError::Permanent(format!(
|
||||
"http {status}: {}",
|
||||
preview(&body)
|
||||
)))
|
||||
}
|
||||
|
||||
/// Lower-level HTTP error surfaced to the per-endpoint wrappers. Each typed
|
||||
/// public error (`FetchError`, `PostError`, `PullError`, `PushReport`) maps
|
||||
/// from this set.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RawHttpError {
|
||||
#[error("transient http error: {0}")]
|
||||
Transient(String),
|
||||
#[error("permanent http error: {0}")]
|
||||
Permanent(String),
|
||||
#[error("max retries exceeded after {attempts} attempts: {last_reason}")]
|
||||
MaxRetries { attempts: u32, last_reason: String },
|
||||
}
|
||||
|
||||
fn preview(body: &str) -> String {
|
||||
@@ -142,7 +294,7 @@ fn preview(body: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Used for diagnostic output and by future health detail.
|
||||
#[allow(dead_code)]
|
||||
pub fn default_request_timeout() -> Duration {
|
||||
Duration::from_secs(5)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod mapobjects_sync;
|
||||
pub mod missions_api;
|
||||
pub mod retry;
|
||||
pub mod schema;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! MapObjects JSON-schema validation (AZ-646 + AZ-647).
|
||||
//!
|
||||
//! Three wire surfaces; each compiles once at first use:
|
||||
//! - `validate_bundle` — GET /missions/{id}/mapobjects response
|
||||
//! - `validate_observations_push` — POST /missions/{id}/mapobjects body
|
||||
//! - `validate_ignored_push` — POST /missions/{id}/mapobjects/ignored body
|
||||
//!
|
||||
//! Bundled copies of the shared JSON schemas are `include_str!`'d so the
|
||||
//! validator can never disagree with a stale copy on disk.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use jsonschema::JSONSchema;
|
||||
use serde_json::Value;
|
||||
|
||||
pub const BUNDLE_SCHEMA_BYTES: &str =
|
||||
include_str!("../../../../shared/contracts/mapobjects-bundle.json");
|
||||
pub const OBSERVATIONS_SCHEMA_BYTES: &str =
|
||||
include_str!("../../../../shared/contracts/mapobjects-observations.json");
|
||||
pub const IGNORED_SCHEMA_BYTES: &str =
|
||||
include_str!("../../../../shared/contracts/mapobjects-ignored.json");
|
||||
|
||||
fn compile(name: &str, raw: &str) -> JSONSchema {
|
||||
let schema_value: Value = serde_json::from_str(raw)
|
||||
.unwrap_or_else(|e| panic!("bundled {name} must be valid JSON at compile time: {e}"));
|
||||
JSONSchema::options()
|
||||
.compile(&schema_value)
|
||||
.unwrap_or_else(|e| panic!("bundled {name} must compile as JSON Schema: {e}"))
|
||||
}
|
||||
|
||||
fn bundle_compiled() -> &'static JSONSchema {
|
||||
static C: OnceLock<JSONSchema> = OnceLock::new();
|
||||
C.get_or_init(|| compile("mapobjects-bundle.json", BUNDLE_SCHEMA_BYTES))
|
||||
}
|
||||
|
||||
fn observations_compiled() -> &'static JSONSchema {
|
||||
static C: OnceLock<JSONSchema> = OnceLock::new();
|
||||
C.get_or_init(|| compile("mapobjects-observations.json", OBSERVATIONS_SCHEMA_BYTES))
|
||||
}
|
||||
|
||||
fn ignored_compiled() -> &'static JSONSchema {
|
||||
static C: OnceLock<JSONSchema> = OnceLock::new();
|
||||
C.get_or_init(|| compile("mapobjects-ignored.json", IGNORED_SCHEMA_BYTES))
|
||||
}
|
||||
|
||||
/// Validate a MapObjects bundle response (AZ-646). Returns the parsed `Value`
|
||||
/// so the caller can re-deserialise without re-parsing.
|
||||
pub fn validate_bundle(raw: &str) -> Result<Value, MapObjectsSchemaError> {
|
||||
validate_with(bundle_compiled(), raw)
|
||||
}
|
||||
|
||||
/// Validate an observations-push body before send (AZ-647). Catches local
|
||||
/// construction bugs (missing required fields, range violations) so we do not
|
||||
/// POST garbage and then count it as a transient failure.
|
||||
pub fn validate_observations_push(raw: &str) -> Result<Value, MapObjectsSchemaError> {
|
||||
validate_with(observations_compiled(), raw)
|
||||
}
|
||||
|
||||
/// Validate an ignored-push body before send (AZ-647).
|
||||
pub fn validate_ignored_push(raw: &str) -> Result<Value, MapObjectsSchemaError> {
|
||||
validate_with(ignored_compiled(), raw)
|
||||
}
|
||||
|
||||
fn validate_with(schema: &JSONSchema, raw: &str) -> Result<Value, MapObjectsSchemaError> {
|
||||
let value: Value = serde_json::from_str(raw).map_err(|e| MapObjectsSchemaError::ParseJson {
|
||||
message: e.to_string(),
|
||||
sample: sample_of(raw),
|
||||
})?;
|
||||
|
||||
let messages: Option<Vec<String>> = {
|
||||
let result = schema.validate(&value);
|
||||
result
|
||||
.err()
|
||||
.map(|errors| errors.map(|e| format!("{e}")).collect())
|
||||
};
|
||||
|
||||
if let Some(messages) = messages {
|
||||
return Err(MapObjectsSchemaError::Invalid {
|
||||
messages,
|
||||
sample: sample_of(raw),
|
||||
});
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
const SAMPLE_CAP: usize = 1024;
|
||||
|
||||
fn sample_of(raw: &str) -> String {
|
||||
if raw.len() <= SAMPLE_CAP {
|
||||
raw.to_owned()
|
||||
} else {
|
||||
let mut s = raw[..SAMPLE_CAP].to_owned();
|
||||
s.push_str(" …<truncated>");
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MapObjectsSchemaError {
|
||||
#[error("response was not valid JSON: {message}")]
|
||||
ParseJson { message: String, sample: String },
|
||||
#[error("schema validation failed: {}", messages.join("; "))]
|
||||
Invalid {
|
||||
messages: Vec<String>,
|
||||
sample: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn good_bundle() -> String {
|
||||
serde_json::json!({
|
||||
"schema_version": "1.0.0",
|
||||
"mission_id": "M-bundle",
|
||||
"bbox": [
|
||||
{ "latitude": 49.5, "longitude": 31.0, "altitude_m": 0.0 },
|
||||
{ "latitude": 49.0, "longitude": 31.5, "altitude_m": 0.0 }
|
||||
],
|
||||
"map_objects": [],
|
||||
"observations": [],
|
||||
"ignored_items": [],
|
||||
"as_of": "2026-05-19T12:00:00Z"
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn good_bundle_validates() {
|
||||
// Act
|
||||
let r = validate_bundle(&good_bundle());
|
||||
|
||||
// Assert
|
||||
assert!(r.is_ok(), "validation failed: {:?}", r.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_missing_required_field_fails() {
|
||||
// Arrange
|
||||
let bad = good_bundle().replace("\"mission_id\"", "\"mission_oops\"");
|
||||
|
||||
// Act
|
||||
let r = validate_bundle(&bad);
|
||||
|
||||
// Assert
|
||||
assert!(matches!(r, Err(MapObjectsSchemaError::Invalid { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observations_push_validates() {
|
||||
// Arrange
|
||||
let body = serde_json::json!({
|
||||
"mission_id": "M1",
|
||||
"observations": []
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// Act
|
||||
let r = validate_observations_push(&body);
|
||||
|
||||
// Assert
|
||||
assert!(r.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observations_push_requires_mission_id() {
|
||||
// Arrange
|
||||
let body = serde_json::json!({ "observations": [] }).to_string();
|
||||
|
||||
// Act
|
||||
let r = validate_observations_push(&body);
|
||||
|
||||
// Assert
|
||||
assert!(matches!(r, Err(MapObjectsSchemaError::Invalid { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_push_validates() {
|
||||
// Arrange
|
||||
let body = serde_json::json!({
|
||||
"mission_id": "M1",
|
||||
"ignored_items": []
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// Act
|
||||
let r = validate_ignored_push(&body);
|
||||
|
||||
// Assert
|
||||
assert!(r.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Mission JSON-schema validation (AZ-644).
|
||||
//!
|
||||
//! Bundled copy of `shared/contracts/mission-schema.json` is compiled into the
|
||||
//! binary via `include_str!`. The shared file is the wire contract co-owned
|
||||
//! with the external `missions` repo (see `architecture.md §8 Q5`).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use jsonschema::JSONSchema;
|
||||
use serde_json::Value;
|
||||
|
||||
pub const SCHEMA_BYTES: &str = include_str!("../../../../shared/contracts/mission-schema.json");
|
||||
|
||||
fn compiled() -> &'static JSONSchema {
|
||||
static SCHEMA: OnceLock<JSONSchema> = OnceLock::new();
|
||||
SCHEMA.get_or_init(|| {
|
||||
let schema_value: Value = serde_json::from_str(SCHEMA_BYTES)
|
||||
.expect("bundled mission-schema.json must be valid JSON at compile time");
|
||||
JSONSchema::options()
|
||||
.compile(&schema_value)
|
||||
.expect("bundled mission-schema.json must compile as JSON Schema")
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate raw JSON bytes against the bundled mission schema. Returns the
|
||||
/// parsed `Value` on success so callers can re-deserialise without re-parsing.
|
||||
pub fn validate(raw: &str) -> Result<Value, SchemaError> {
|
||||
let value: Value = serde_json::from_str(raw).map_err(|e| SchemaError::ParseJson {
|
||||
message: e.to_string(),
|
||||
sample: sample_of(raw),
|
||||
})?;
|
||||
|
||||
let messages: Option<Vec<String>> = {
|
||||
let result = compiled().validate(&value);
|
||||
result
|
||||
.err()
|
||||
.map(|errors| errors.map(|e| format!("{e}")).collect())
|
||||
};
|
||||
|
||||
if let Some(messages) = messages {
|
||||
return Err(SchemaError::Invalid {
|
||||
messages,
|
||||
sample: sample_of(raw),
|
||||
});
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
const SAMPLE_CAP: usize = 1024;
|
||||
|
||||
fn sample_of(raw: &str) -> String {
|
||||
if raw.len() <= SAMPLE_CAP {
|
||||
raw.to_owned()
|
||||
} else {
|
||||
let mut s = raw[..SAMPLE_CAP].to_owned();
|
||||
s.push_str(" …<truncated>");
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SchemaError {
|
||||
#[error("response was not valid JSON: {message}")]
|
||||
ParseJson { message: String, sample: String },
|
||||
#[error("response failed schema validation: {}", messages.join("; "))]
|
||||
Invalid {
|
||||
messages: Vec<String>,
|
||||
sample: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GOOD: &str = r#"{
|
||||
"mission_id": "11111111-2222-3333-4444-555555555555",
|
||||
"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 }
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn good_mission_validates() {
|
||||
// Act
|
||||
let r = validate(GOOD);
|
||||
|
||||
// Assert
|
||||
assert!(r.is_ok(), "validation failed: {:?}", r.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_field_fails() {
|
||||
// Arrange
|
||||
let bad = GOOD.replace("\"mission_id\"", "\"mission_oops\"");
|
||||
|
||||
// Act
|
||||
let r = validate(&bad);
|
||||
|
||||
// Assert
|
||||
assert!(matches!(r, Err(SchemaError::Invalid { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_fails() {
|
||||
// Act
|
||||
let r = validate("{ not json");
|
||||
|
||||
// Assert
|
||||
assert!(matches!(r, Err(SchemaError::ParseJson { .. })));
|
||||
}
|
||||
}
|
||||
@@ -1,119 +1,8 @@
|
||||
//! Mission JSON-schema validation.
|
||||
//! JSON-schema validators for every wire surface the `mission_client` speaks.
|
||||
//!
|
||||
//! Bundled copy of `shared/contracts/mission-schema.json` is compiled into the
|
||||
//! binary via `include_str!`. The shared file is the wire contract co-owned
|
||||
//! with the external `missions` repo (see `architecture.md §8 Q5`).
|
||||
//! Each sub-module owns one schema and its `validate` entry point. The schema
|
||||
//! bytes are compiled into the binary via `include_str!` so the validator can
|
||||
//! never disagree with a stale copy on disk.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use jsonschema::JSONSchema;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Bundled schema content (canonical wire contract).
|
||||
pub const SCHEMA_BYTES: &str = include_str!("../../../../shared/contracts/mission-schema.json");
|
||||
|
||||
fn compiled() -> &'static JSONSchema {
|
||||
static SCHEMA: OnceLock<JSONSchema> = OnceLock::new();
|
||||
SCHEMA.get_or_init(|| {
|
||||
let schema_value: Value = serde_json::from_str(SCHEMA_BYTES)
|
||||
.expect("bundled mission-schema.json must be valid JSON at compile time");
|
||||
JSONSchema::options()
|
||||
.compile(&schema_value)
|
||||
.expect("bundled mission-schema.json must compile as JSON Schema")
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate raw JSON bytes against the bundled schema.
|
||||
///
|
||||
/// Returns the parsed JSON `Value` on success so callers can re-deserialise
|
||||
/// it into the typed `Mission` without re-parsing.
|
||||
pub fn validate(raw: &str) -> Result<Value, SchemaError> {
|
||||
let value: Value = serde_json::from_str(raw).map_err(|e| SchemaError::ParseJson {
|
||||
message: e.to_string(),
|
||||
sample: sample_of(raw),
|
||||
})?;
|
||||
|
||||
let messages: Option<Vec<String>> = {
|
||||
let result = compiled().validate(&value);
|
||||
result
|
||||
.err()
|
||||
.map(|errors| errors.map(|e| format!("{e}")).collect())
|
||||
};
|
||||
|
||||
if let Some(messages) = messages {
|
||||
return Err(SchemaError::Invalid {
|
||||
messages,
|
||||
sample: sample_of(raw),
|
||||
});
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
const SAMPLE_CAP: usize = 1024;
|
||||
|
||||
fn sample_of(raw: &str) -> String {
|
||||
if raw.len() <= SAMPLE_CAP {
|
||||
raw.to_owned()
|
||||
} else {
|
||||
let mut s = raw[..SAMPLE_CAP].to_owned();
|
||||
s.push_str(" …<truncated>");
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SchemaError {
|
||||
#[error("response was not valid JSON: {message}")]
|
||||
ParseJson { message: String, sample: String },
|
||||
#[error("response failed schema validation: {}", messages.join("; "))]
|
||||
Invalid {
|
||||
messages: Vec<String>,
|
||||
sample: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GOOD: &str = r#"{
|
||||
"mission_id": "11111111-2222-3333-4444-555555555555",
|
||||
"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 }
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn good_mission_validates() {
|
||||
// Act
|
||||
let r = validate(GOOD);
|
||||
|
||||
// Assert
|
||||
assert!(r.is_ok(), "validation failed: {:?}", r.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_field_fails() {
|
||||
// Arrange
|
||||
let bad = GOOD.replace("\"mission_id\"", "\"mission_oops\"");
|
||||
|
||||
// Act
|
||||
let r = validate(&bad);
|
||||
|
||||
// Assert
|
||||
assert!(matches!(r, Err(SchemaError::Invalid { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_fails() {
|
||||
// Act
|
||||
let r = validate("{ not json");
|
||||
|
||||
// Assert
|
||||
assert!(matches!(r, Err(SchemaError::ParseJson { .. })));
|
||||
}
|
||||
}
|
||||
pub mod mapobjects;
|
||||
pub mod mission;
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
//! `mission_client` — REST client to the external `missions` API.
|
||||
//!
|
||||
//! Public surface (per `module-layout.md`): [`MissionClient`],
|
||||
//! [`MissionClientHandle`], the typed [`Mission`] DTO, [`FetchError`], and
|
||||
//! [`MissionClientOptions`].
|
||||
//! [`MissionClientHandle`], the typed [`Mission`] DTO, [`FetchError`],
|
||||
//! [`PostError`], [`PullError`], [`PushReport`], [`PerEndpointStatus`],
|
||||
//! [`MapObjectsDiff`], [`MissionUpdateAck`], and [`MissionClientOptions`].
|
||||
//!
|
||||
//! Real implementation tasks: AZ-644 (pull + schema, this file), AZ-645
|
||||
//! (middle-waypoint POST), AZ-646 (mapobjects pull), AZ-647 (mapobjects push).
|
||||
//! Real implementation tasks:
|
||||
//! - AZ-644 — pull + schema (initial scaffold + GET /missions/{id})
|
||||
//! - AZ-645 — POST /missions/{id}/middle-waypoint
|
||||
//! - AZ-646 — GET /missions/{id}/mapobjects (bundle pre-flight pull)
|
||||
//! - AZ-647 — POST observations + ignored with durable disk queue and
|
||||
//! crash-recovery replay.
|
||||
|
||||
mod internal;
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::error::{AutopilotError, Result};
|
||||
use shared::health::ComponentHealth;
|
||||
use shared::models::mapobject::MapObjectsBundle;
|
||||
use shared::error::AutopilotError;
|
||||
use shared::health::{ComponentHealth, HealthLevel};
|
||||
use shared::models::mapobject::{IgnoredItem, MapObjectObservation, MapObjectsBundle};
|
||||
use shared::models::mission::{Coordinate, Geofence, MissionItem};
|
||||
use uuid::Uuid;
|
||||
|
||||
use internal::missions_api::HttpClient;
|
||||
use internal::mapobjects_sync::{pull as mapobjects_pull, push as mapobjects_push};
|
||||
use internal::missions_api::{HttpClient, RawHttpError};
|
||||
|
||||
const NAME: &str = "mission_client";
|
||||
|
||||
// ─── Public DTOs ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Mission DTO returned by `pull_mission`. Shape matches the JSON wire schema
|
||||
/// in `shared/contracts/mission-schema.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -35,7 +44,32 @@ pub struct Mission {
|
||||
pub return_point: Coordinate,
|
||||
}
|
||||
|
||||
/// Errors surfaced by `MissionClientHandle::pull_mission`.
|
||||
/// Ack returned by [`MissionClientHandle::post_middle_waypoint`] on a
|
||||
/// successful POST. The shape is intentionally permissive — only `mission_id`
|
||||
/// is required; servers may carry additional bookkeeping like `revision` or
|
||||
/// `accepted_at` without breaking the client.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MissionUpdateAck {
|
||||
pub mission_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub revision: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub accepted_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Pass diff handed to [`MissionClientHandle::push_mapobjects_diff`].
|
||||
/// `mapobjects_store` (AZ-665/AZ-666/AZ-667) owns the construction of this
|
||||
/// struct from `pending_observations` + `pending_ignored`; the client owns
|
||||
/// the wire format and the durable queue.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MapObjectsDiff {
|
||||
pub observations: Vec<MapObjectObservation>,
|
||||
pub ignored_items: Vec<IgnoredItem>,
|
||||
}
|
||||
|
||||
// ─── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Errors surfaced by [`MissionClientHandle::pull_mission`].
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FetchError {
|
||||
/// JSON body did not match the bundled `mission-schema`. Includes a
|
||||
@@ -71,17 +105,166 @@ impl From<FetchError> for AutopilotError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tunables for the missions-API client. AZ-644 §NFR defaults: 5 attempts,
|
||||
/// 200 ms base / 5 s cap, 5 s startup-fetch budget.
|
||||
/// Errors surfaced by [`MissionClientHandle::post_middle_waypoint`].
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PostError {
|
||||
/// 4xx (excluding 429) — the server rejected the payload; retrying will
|
||||
/// not help.
|
||||
#[error("permanent post failure: {0}")]
|
||||
Permanent(String),
|
||||
/// 5xx / timeout / connect-error budget exhausted.
|
||||
#[error("max retries exceeded after {attempts} attempts: {last_reason}")]
|
||||
MaxRetriesExceeded { attempts: u32, last_reason: String },
|
||||
/// Ack body did not deserialise into [`MissionUpdateAck`].
|
||||
#[error("malformed ack: {0}")]
|
||||
MalformedAck(String),
|
||||
/// Local bug.
|
||||
#[error("internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl From<PostError> for AutopilotError {
|
||||
fn from(e: PostError) -> Self {
|
||||
match e {
|
||||
PostError::Permanent(s) => AutopilotError::Network(s),
|
||||
PostError::MaxRetriesExceeded {
|
||||
attempts,
|
||||
last_reason,
|
||||
} => AutopilotError::Network(format!(
|
||||
"max retries exceeded after {attempts} attempts: {last_reason}"
|
||||
)),
|
||||
PostError::MalformedAck(s) => AutopilotError::Protocol(s),
|
||||
PostError::Internal(s) => AutopilotError::Internal(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced by [`MissionClientHandle::pull_mapobjects`].
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PullError {
|
||||
/// API is unreachable (timeout, connect-refused, transient outage) AND
|
||||
/// retries did not recover — but the failure could not be classified as
|
||||
/// strictly permanent.
|
||||
#[error("mapobjects api unreachable: {0}")]
|
||||
Unreachable(String),
|
||||
/// Response was 200 but the body did not validate against
|
||||
/// `shared/contracts/mapobjects-bundle.json`.
|
||||
#[error("mapobjects bundle schema invalid: {}", messages.join("; "))]
|
||||
SchemaInvalid {
|
||||
messages: Vec<String>,
|
||||
sample: String,
|
||||
},
|
||||
/// Retried up to `max_attempts` without success.
|
||||
#[error("max retries exceeded after {attempts} attempts: {last_reason}")]
|
||||
MaxRetriesExceeded { attempts: u32, last_reason: String },
|
||||
/// Local bug.
|
||||
#[error("internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Per-endpoint outcome in a [`PushReport`]. The push function always returns
|
||||
/// a complete report — never `Result` — because partial success is a
|
||||
/// first-class outcome (per AC-2 of AZ-647).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PerEndpointStatus {
|
||||
/// 2xx within the retry budget. Disk file has been updated to remove this
|
||||
/// endpoint's payload.
|
||||
Success,
|
||||
/// 4xx (excluding 429). Retrying will not help.
|
||||
Permanent { reason: String },
|
||||
/// Retry budget exhausted; the payload remains on disk for manual replay.
|
||||
MaxRetriesExceeded { attempts: u32, last_reason: String },
|
||||
}
|
||||
|
||||
impl PerEndpointStatus {
|
||||
pub fn is_success(&self) -> bool {
|
||||
matches!(self, Self::Success)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a single [`MissionClientHandle::push_mapobjects_diff`] call —
|
||||
/// per-endpoint outcomes plus the mission id for cross-referencing with the
|
||||
/// disk queue.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PushReport {
|
||||
pub mission_id: String,
|
||||
pub observations: PerEndpointStatus,
|
||||
pub ignored: PerEndpointStatus,
|
||||
}
|
||||
|
||||
impl PushReport {
|
||||
/// Overall sync state — `Synced` only when BOTH endpoints succeeded.
|
||||
pub fn sync_state(&self) -> SyncState {
|
||||
match (&self.observations, &self.ignored) {
|
||||
(PerEndpointStatus::Success, PerEndpointStatus::Success) => SyncState::Synced,
|
||||
_ => SyncState::Degraded,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructed before the network call ever happens — the local diff did
|
||||
/// not pass schema validation. Both endpoints share the same reason.
|
||||
fn local_invalid(reason: String) -> Self {
|
||||
Self {
|
||||
mission_id: String::new(),
|
||||
observations: PerEndpointStatus::Permanent {
|
||||
reason: reason.clone(),
|
||||
},
|
||||
ignored: PerEndpointStatus::Permanent { reason },
|
||||
}
|
||||
}
|
||||
|
||||
/// Write-ahead disk write failed — there is no point attempting the
|
||||
/// network call because crash recovery would be broken anyway.
|
||||
fn disk_failure(reason: String) -> Self {
|
||||
let r = format!("write-ahead persistence failed: {reason}");
|
||||
Self {
|
||||
mission_id: String::new(),
|
||||
observations: PerEndpointStatus::Permanent { reason: r.clone() },
|
||||
ignored: PerEndpointStatus::Permanent { reason: r },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncState {
|
||||
Synced,
|
||||
Degraded,
|
||||
}
|
||||
|
||||
impl SyncState {
|
||||
fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Synced => "synced",
|
||||
Self::Degraded => "degraded",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Options ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Tunables for the missions-API client. Defaults are tuned for the
|
||||
/// production single-airframe deployment per the AZ-644/645/646/647 NFRs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MissionClientOptions {
|
||||
pub endpoint: String,
|
||||
pub bearer_token: Option<String>,
|
||||
/// GET retry budget (AZ-644 pull, AZ-646 mapobjects pull). 5 attempts /
|
||||
/// 200 ms base / 5 s cap per the AZ-644 §NFR table.
|
||||
pub max_attempts: u32,
|
||||
/// POST retry budget for the middle-waypoint endpoint (AZ-645). 3 attempts
|
||||
/// per the AZ-645 §Outcome bullet.
|
||||
pub post_max_attempts: u32,
|
||||
/// POST retry budget per endpoint for the post-flight push (AZ-647). High
|
||||
/// by default — at the 1 s base / 1 h cap below this is ≈ 24 h.
|
||||
pub push_max_attempts: u32,
|
||||
pub backoff_base: Duration,
|
||||
pub backoff_cap: Duration,
|
||||
pub request_timeout: Duration,
|
||||
pub connect_timeout: Duration,
|
||||
/// Where the durable mapobjects-push queue is rooted (AZ-647). The full
|
||||
/// path is `${state_dir}/mapobjects_push/<mission_id>.json`. Defaults to
|
||||
/// `./state` if the caller does not override.
|
||||
pub state_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl MissionClientOptions {
|
||||
@@ -90,20 +273,40 @@ impl MissionClientOptions {
|
||||
endpoint: endpoint.into(),
|
||||
bearer_token: None,
|
||||
max_attempts: 5,
|
||||
post_max_attempts: 3,
|
||||
push_max_attempts: 24,
|
||||
backoff_base: Duration::from_millis(200),
|
||||
backoff_cap: Duration::from_secs(5),
|
||||
request_timeout: Duration::from_secs(5),
|
||||
connect_timeout: Duration::from_secs(2),
|
||||
state_dir: PathBuf::from("state"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal state ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ClientState {
|
||||
// AZ-644
|
||||
last_fetch_unix_s: AtomicU64,
|
||||
fetch_errors_total: AtomicU64,
|
||||
last_schema_version: std::sync::Mutex<Option<String>>,
|
||||
last_connection_state: std::sync::Mutex<ConnectionState>,
|
||||
last_schema_version: Mutex<Option<String>>,
|
||||
last_connection_state: Mutex<ConnectionState>,
|
||||
|
||||
// AZ-645
|
||||
last_middle_waypoint_post_status: Mutex<EndpointHealth>,
|
||||
|
||||
// AZ-646
|
||||
mapobjects_pull_state: Mutex<PullHealth>,
|
||||
last_mapobjects_pull_unix_s: AtomicU64,
|
||||
|
||||
// AZ-647
|
||||
mapobjects_push_pending: AtomicBool,
|
||||
last_push_unix_s: AtomicU64,
|
||||
last_observations_push_error: Mutex<Option<String>>,
|
||||
last_ignored_push_error: Mutex<Option<String>>,
|
||||
last_push_sync_state: Mutex<Option<SyncState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -124,8 +327,44 @@ impl ConnectionState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Public client. Build once at startup and pass the [`MissionClientHandle`]
|
||||
/// to other components.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
enum EndpointHealth {
|
||||
#[default]
|
||||
Unknown,
|
||||
Ok,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl EndpointHealth {
|
||||
fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Ok => "ok",
|
||||
Self::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
enum PullHealth {
|
||||
#[default]
|
||||
Unknown,
|
||||
Synced,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl PullHealth {
|
||||
fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Synced => "synced",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public client ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MissionClient {
|
||||
options: MissionClientOptions,
|
||||
@@ -136,6 +375,16 @@ pub struct MissionClient {
|
||||
impl MissionClient {
|
||||
pub fn new(options: MissionClientOptions) -> std::result::Result<Self, FetchError> {
|
||||
let http = HttpClient::new(&options)?;
|
||||
if let Err(e) = internal::mapobjects_sync::queue::ensure_dir(&options.state_dir) {
|
||||
// Failure here is non-fatal at construction — it would only break
|
||||
// AZ-647. Surface a warning and let the caller hit the disk error
|
||||
// path if/when push is invoked.
|
||||
tracing::warn!(
|
||||
component = "mission_client",
|
||||
error = %e,
|
||||
"could not ensure state_dir/mapobjects_push at startup"
|
||||
);
|
||||
}
|
||||
Ok(Self {
|
||||
options,
|
||||
http,
|
||||
@@ -161,6 +410,8 @@ pub struct MissionClientHandle {
|
||||
}
|
||||
|
||||
impl MissionClientHandle {
|
||||
// ───── AZ-644 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fetch + validate a mission by id. Implements bounded exponential
|
||||
/// backoff and rejects schema-invalid responses without a silent downcast.
|
||||
pub async fn pull_mission(&self, mission_id: &str) -> std::result::Result<Mission, FetchError> {
|
||||
@@ -186,24 +437,138 @@ impl MissionClientHandle {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_middle_waypoint(&self, _mission_id: &str, _at: Coordinate) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mission_client::post_middle_waypoint (AZ-645)",
|
||||
))
|
||||
// ───── AZ-645 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// POST the patched mission (operator-confirmed middle waypoint inserted)
|
||||
/// to `POST /missions/{id}/middle-waypoint`. Bounded retry; surfaces a
|
||||
/// typed error so `mission_executor` can decide whether to halt, RTL, or
|
||||
/// continue with the in-memory mission.
|
||||
pub async fn post_middle_waypoint(
|
||||
&self,
|
||||
mission_id: &str,
|
||||
patched: &Mission,
|
||||
) -> std::result::Result<MissionUpdateAck, PostError> {
|
||||
let body = serde_json::to_value(patched)
|
||||
.map_err(|e| PostError::Internal(format!("serialise patched mission: {e}")))?;
|
||||
|
||||
let res = self
|
||||
.http
|
||||
.post_middle_waypoint_raw(mission_id, &body, &self.options)
|
||||
.await;
|
||||
match res {
|
||||
Ok(raw) => {
|
||||
let ack: MissionUpdateAck = parse_ack(&raw, mission_id)?;
|
||||
*self.state.last_middle_waypoint_post_status.lock().unwrap() = EndpointHealth::Ok;
|
||||
Ok(ack)
|
||||
}
|
||||
Err(RawHttpError::Permanent(reason)) => {
|
||||
*self.state.last_middle_waypoint_post_status.lock().unwrap() =
|
||||
EndpointHealth::Error;
|
||||
Err(PostError::Permanent(reason))
|
||||
}
|
||||
Err(RawHttpError::MaxRetries {
|
||||
attempts,
|
||||
last_reason,
|
||||
}) => {
|
||||
*self.state.last_middle_waypoint_post_status.lock().unwrap() =
|
||||
EndpointHealth::Error;
|
||||
Err(PostError::MaxRetriesExceeded {
|
||||
attempts,
|
||||
last_reason,
|
||||
})
|
||||
}
|
||||
Err(RawHttpError::Transient(reason)) => {
|
||||
*self.state.last_middle_waypoint_post_status.lock().unwrap() =
|
||||
EndpointHealth::Error;
|
||||
Err(PostError::Permanent(reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn pull_mapobjects(&self, _mission_id: &str) -> Result<MapObjectsBundle> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mission_client::pull_mapobjects (AZ-646)",
|
||||
))
|
||||
// ───── AZ-646 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Pre-flight GET of the MapObjects bundle for a mission. Schema-validates
|
||||
/// before deserialise; on any failure the typed [`PullError`] is surfaced
|
||||
/// to `mission_executor`'s F9 BIT path — never silent.
|
||||
pub async fn pull_mapobjects(
|
||||
&self,
|
||||
mission_id: &str,
|
||||
) -> std::result::Result<MapObjectsBundle, PullError> {
|
||||
let res = mapobjects_pull::pull(&self.http, mission_id, &self.options).await;
|
||||
match &res {
|
||||
Ok(_) => {
|
||||
*self.state.mapobjects_pull_state.lock().unwrap() = PullHealth::Synced;
|
||||
self.state
|
||||
.last_mapobjects_pull_unix_s
|
||||
.store(now_unix_s(), Ordering::Relaxed);
|
||||
}
|
||||
Err(_) => {
|
||||
*self.state.mapobjects_pull_state.lock().unwrap() = PullHealth::Failed;
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
pub async fn push_mapobjects(&self, _bundle: MapObjectsBundle) -> Result<()> {
|
||||
Err(AutopilotError::NotImplemented(
|
||||
"mission_client::push_mapobjects (AZ-647)",
|
||||
))
|
||||
// ───── AZ-647 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Post-flight push of the pass diff. The diff is persisted to disk
|
||||
/// BEFORE the network call (write-ahead) and per-endpoint retry runs
|
||||
/// independently. Partial success is reported through the [`PushReport`]
|
||||
/// — the successful endpoint's payload is removed from disk while the
|
||||
/// failing endpoint's payload remains for manual or crash-recovery replay.
|
||||
pub async fn push_mapobjects_diff(&self, mission_id: &str, diff: MapObjectsDiff) -> PushReport {
|
||||
self.state
|
||||
.mapobjects_push_pending
|
||||
.store(true, Ordering::Relaxed);
|
||||
let mut report = mapobjects_push::push(
|
||||
&self.http,
|
||||
&self.options.state_dir,
|
||||
mission_id,
|
||||
diff,
|
||||
&self.options,
|
||||
)
|
||||
.await;
|
||||
if report.mission_id.is_empty() {
|
||||
// local_invalid / disk_failure paths leave it blank; fill in for
|
||||
// observability.
|
||||
report.mission_id = mission_id.to_owned();
|
||||
}
|
||||
let sync = report.sync_state();
|
||||
let still_pending = sync == SyncState::Degraded;
|
||||
self.state
|
||||
.mapobjects_push_pending
|
||||
.store(still_pending, Ordering::Relaxed);
|
||||
self.state
|
||||
.last_push_unix_s
|
||||
.store(now_unix_s(), Ordering::Relaxed);
|
||||
*self.state.last_push_sync_state.lock().unwrap() = Some(sync);
|
||||
*self.state.last_observations_push_error.lock().unwrap() =
|
||||
endpoint_error(&report.observations);
|
||||
*self.state.last_ignored_push_error.lock().unwrap() = endpoint_error(&report.ignored);
|
||||
report
|
||||
}
|
||||
|
||||
/// Crash-recovery sweep. On startup the caller (mission_executor BIT)
|
||||
/// MUST call this before starting BIT for any new mission so that any
|
||||
/// pending diff from a previously terminated mission is drained first.
|
||||
pub async fn recover_pending_pushes(&self) -> Vec<PushReport> {
|
||||
let reports =
|
||||
mapobjects_push::recover_pending(&self.http, &self.options.state_dir, &self.options)
|
||||
.await;
|
||||
// After recovery, re-check whether anything is still pending on disk
|
||||
// (could still be — a 24 h-budget endpoint that exhausted).
|
||||
let still_pending = !reports.is_empty()
|
||||
&& reports
|
||||
.iter()
|
||||
.any(|r| r.sync_state() == SyncState::Degraded);
|
||||
self.state
|
||||
.mapobjects_push_pending
|
||||
.store(still_pending, Ordering::Relaxed);
|
||||
reports
|
||||
}
|
||||
|
||||
// ───── Health ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn health(&self) -> ComponentHealth {
|
||||
let conn = *self.state.last_connection_state.lock().unwrap();
|
||||
let last_fetch = self.state.last_fetch_unix_s.load(Ordering::Relaxed);
|
||||
@@ -215,25 +580,107 @@ impl MissionClientHandle {
|
||||
.unwrap()
|
||||
.clone()
|
||||
.unwrap_or_else(|| "none".to_owned());
|
||||
let mid_wp = *self.state.last_middle_waypoint_post_status.lock().unwrap();
|
||||
let pull_state = *self.state.mapobjects_pull_state.lock().unwrap();
|
||||
let last_pull = self
|
||||
.state
|
||||
.last_mapobjects_pull_unix_s
|
||||
.load(Ordering::Relaxed);
|
||||
let push_pending = self.state.mapobjects_push_pending.load(Ordering::Relaxed);
|
||||
let last_push = self.state.last_push_unix_s.load(Ordering::Relaxed);
|
||||
let sync = *self.state.last_push_sync_state.lock().unwrap();
|
||||
|
||||
let detail = format!(
|
||||
"last_fetch_ts={} fetch_errors_total={} schema_version={} connection_state={}",
|
||||
if last_fetch == 0 {
|
||||
"none".into()
|
||||
} else {
|
||||
last_fetch.to_string()
|
||||
},
|
||||
"last_fetch_ts={} fetch_errors_total={} schema_version={} connection_state={} \
|
||||
last_middle_waypoint_post_status={} \
|
||||
mapobjects_pull_state={} last_mapobjects_pull_ts={} \
|
||||
mapobjects_push_pending={} last_push_ts={} push_sync_state={}",
|
||||
unix_or_none(last_fetch),
|
||||
errors,
|
||||
schema_version,
|
||||
conn.label(),
|
||||
mid_wp.label(),
|
||||
pull_state.label(),
|
||||
unix_or_none(last_pull),
|
||||
push_pending,
|
||||
unix_or_none(last_push),
|
||||
sync.map(|s| s.label()).unwrap_or("unknown"),
|
||||
);
|
||||
match conn {
|
||||
ConnectionState::Ok => ComponentHealth::green(NAME),
|
||||
ConnectionState::Error => ComponentHealth::red(NAME, detail),
|
||||
ConnectionState::Unknown => ComponentHealth::yellow(NAME, detail),
|
||||
|
||||
// Always populate detail — even on Green — because the AZ-645/646/647
|
||||
// task specs require named health fields (`last_middle_waypoint_post_status`,
|
||||
// `mapobjects_pull_state`, `mapobjects_push_pending`, ...) to be
|
||||
// observable through `/health`, not only when the system is degraded.
|
||||
let level = aggregate_level(conn, mid_wp, pull_state, push_pending, sync);
|
||||
ComponentHealth {
|
||||
level,
|
||||
component: NAME,
|
||||
detail: Some(detail),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ack(body: &str, mission_id: &str) -> std::result::Result<MissionUpdateAck, PostError> {
|
||||
if body.trim().is_empty() {
|
||||
// Some servers return 204; synthesise a minimal ack so the caller
|
||||
// does not have to disambiguate.
|
||||
return Ok(MissionUpdateAck {
|
||||
mission_id: mission_id.to_owned(),
|
||||
revision: None,
|
||||
accepted_at: None,
|
||||
});
|
||||
}
|
||||
serde_json::from_str(body).map_err(|e| PostError::MalformedAck(e.to_string()))
|
||||
}
|
||||
|
||||
fn endpoint_error(s: &PerEndpointStatus) -> Option<String> {
|
||||
match s {
|
||||
PerEndpointStatus::Success => None,
|
||||
PerEndpointStatus::Permanent { reason } => Some(format!("permanent: {reason}")),
|
||||
PerEndpointStatus::MaxRetriesExceeded {
|
||||
attempts,
|
||||
last_reason,
|
||||
} => Some(format!("max_retries({attempts}) exceeded: {last_reason}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn aggregate_level(
|
||||
conn: ConnectionState,
|
||||
mid_wp: EndpointHealth,
|
||||
pull_state: PullHealth,
|
||||
push_pending: bool,
|
||||
sync: Option<SyncState>,
|
||||
) -> HealthLevel {
|
||||
let any_red = matches!(conn, ConnectionState::Error)
|
||||
|| matches!(mid_wp, EndpointHealth::Error)
|
||||
|| matches!(pull_state, PullHealth::Failed)
|
||||
|| matches!(sync, Some(SyncState::Degraded))
|
||||
|| push_pending;
|
||||
if any_red {
|
||||
return HealthLevel::Red;
|
||||
}
|
||||
let any_unknown = matches!(conn, ConnectionState::Unknown)
|
||||
&& matches!(mid_wp, EndpointHealth::Unknown)
|
||||
&& matches!(pull_state, PullHealth::Unknown)
|
||||
&& sync.is_none()
|
||||
&& !push_pending;
|
||||
// If everything is at default Unknown and there's been no traffic, surface
|
||||
// Yellow so callers can tell the difference between "never used" and
|
||||
// "everything green".
|
||||
if any_unknown {
|
||||
return HealthLevel::Yellow;
|
||||
}
|
||||
HealthLevel::Green
|
||||
}
|
||||
|
||||
fn unix_or_none(ts: u64) -> String {
|
||||
if ts == 0 {
|
||||
"none".into()
|
||||
} else {
|
||||
ts.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix_s() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -247,10 +694,53 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn fetch_error_maps_to_autopilot_error() {
|
||||
// Act
|
||||
let e: AutopilotError = FetchError::Permanent("boom".into()).into();
|
||||
|
||||
// Assert
|
||||
match e {
|
||||
AutopilotError::Network(s) => assert!(s.contains("boom")),
|
||||
other => panic!("expected Network, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_error_max_retries_maps_to_network() {
|
||||
// Act
|
||||
let e: AutopilotError = PostError::MaxRetriesExceeded {
|
||||
attempts: 3,
|
||||
last_reason: "http 500".into(),
|
||||
}
|
||||
.into();
|
||||
|
||||
// Assert
|
||||
match e {
|
||||
AutopilotError::Network(s) => {
|
||||
assert!(s.contains("max retries exceeded after 3 attempts"));
|
||||
assert!(s.contains("http 500"));
|
||||
}
|
||||
other => panic!("expected Network, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_report_sync_state_requires_both_endpoints() {
|
||||
// Arrange
|
||||
let only_obs = PushReport {
|
||||
mission_id: "M1".into(),
|
||||
observations: PerEndpointStatus::Success,
|
||||
ignored: PerEndpointStatus::Permanent {
|
||||
reason: "503 budget".into(),
|
||||
},
|
||||
};
|
||||
let both = PushReport {
|
||||
mission_id: "M1".into(),
|
||||
observations: PerEndpointStatus::Success,
|
||||
ignored: PerEndpointStatus::Success,
|
||||
};
|
||||
|
||||
// Assert
|
||||
assert_eq!(only_obs.sync_state(), SyncState::Degraded);
|
||||
assert_eq!(both.sync_state(), SyncState::Synced);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user