use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt as _;
use proptest::prelude::*;
use serde_json::{Value, json};
use tempfile::TempDir;
use tower::ServiceExt;
fn make_app() -> (axum::Router, TempDir) {
let td = TempDir::new().expect("tmp dir");
let opts = mnem_http::AppOptions {
allow_labels: Some(true),
in_memory: false,
metrics_enabled: false,
};
let app = mnem_http::app_with_options(td.path(), opts).expect("build app");
(app, td)
}
async fn to_json(body: Body) -> Value {
let bytes = body.collect().await.expect("collect body").to_bytes();
serde_json::from_slice(&bytes).expect("valid JSON")
}
async fn post_node(app: &axum::Router, summary: &str) -> String {
let body = json!({ "label": "Memory", "summary": summary, "author": "tests" });
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/nodes")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.expect("post node");
assert_eq!(resp.status(), StatusCode::OK);
let v = to_json(resp.into_body()).await;
v["id"].as_str().expect("node id").to_string()
}
async fn post_explain(app: &axum::Router, body: Value) -> (StatusCode, Value) {
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/explain")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.expect("post explain");
let status = resp.status();
let v = to_json(resp.into_body()).await;
(status, v)
}
#[tokio::test]
async fn explain_returns_path_to_seed() {
let (app, _td) = make_app();
let seed_id = post_node(&app, "seed node with no backlinks").await;
let (status, v) = post_explain(
&app,
json!({
"node_id": seed_id,
"depth": 3,
"mode": "compact",
}),
)
.await;
assert_eq!(status, StatusCode::OK, "body = {v}");
assert_eq!(v["schema"], "mnem.v1.explain");
assert_eq!(v["mode"], "compact");
assert_eq!(v["seed"], seed_id);
let nodes = v["nodes"].as_array().expect("nodes array");
assert!(!nodes.is_empty(), "path must contain at least the seed");
assert_eq!(
nodes[0].as_str().expect("seed id"),
seed_id,
"nodes[0] must be the seed"
);
let path_source = v["path_source"].as_str().expect("path_source");
assert!(
path_source.starts_with("bfs.v1:graph_depth="),
"path_source should carry BFS provenance, got {path_source}"
);
for step in v["steps"].as_array().expect("steps array") {
let to_idx = step["to_idx"].as_u64().expect("to_idx") as usize;
let parent_idx = step["parent_idx"].as_u64().expect("parent_idx") as usize;
assert!(to_idx < nodes.len(), "to_idx out of bounds");
assert!(parent_idx < nodes.len(), "parent_idx out of bounds");
assert!(
parent_idx < to_idx,
"parent must precede child in BFS order"
);
}
}
#[tokio::test]
async fn explain_compact_full_downgrades_without_acl() {
let (app, _td) = make_app();
let seed_id = post_node(&app, "seed for compact_full downgrade test").await;
let (status, v) = post_explain(
&app,
json!({
"node_id": seed_id,
"depth": 2,
"mode": "compact_full",
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
v["mode"], "compact",
"compact_full without ACL must downgrade to compact"
);
let warnings = v["warnings"].as_array().expect("warnings");
assert!(
warnings
.iter()
.any(|w| w["code"] == "explain.mode_downgraded"),
"expected explain.mode_downgraded warning, got {warnings:?}"
);
}
#[tokio::test]
async fn explain_byte_cap_is_runtime_derived() {
let (app, _td) = make_app();
let seed_id = post_node(&app, "seed for byte-cap derivation test").await;
let (status, v) = post_explain(
&app,
json!({
"node_id": seed_id,
"depth": 1,
"mode": "compact",
"latency_budget_ms": 100,
"serialization_rate_bytes_per_ms": 512,
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
v["max_path_bytes_total"].as_u64().expect("cap"),
100 * 512,
"cap must derive from latency_budget_ms * serialization_rate"
);
assert_eq!(v["latency_budget_ms"], 100);
assert_eq!(v["serialization_rate_bytes_per_ms"], 512);
}
proptest! {
#[test]
fn byte_cap_never_exceeds_budget(
remaining_ms in 0u32..60_000u32,
rate in 0u64..1_000_000u64,
) {
let cap = mnem_http::derive_max_path_bytes(remaining_ms, rate);
let projected = u64::from(remaining_ms).saturating_mul(rate);
if usize::try_from(projected).is_ok() {
prop_assert_eq!(cap as u64, projected);
} else {
prop_assert_eq!(cap, usize::MAX);
}
}
}
#[tokio::test]
async fn explain_rejects_invalid_node_id() {
let (app, _td) = make_app();
let (status, _v) = post_explain(
&app,
json!({
"node_id": "not-a-uuid",
"depth": 2,
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}