use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt as _;
use serde_json::Value;
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")
}
#[tokio::test]
async fn healthz_returns_ok() {
let (app, _td) = make_app();
let resp = app
.oneshot(
Request::builder()
.uri("/v1/healthz")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let j = to_json(resp.into_body()).await;
assert_eq!(j["ok"], true);
assert_eq!(j["schema"], "mnem.v1.healthz");
}
#[tokio::test]
async fn stats_returns_op_id_and_schema() {
let (app, _td) = make_app();
let resp = app
.oneshot(
Request::builder()
.uri("/v1/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let j = to_json(resp.into_body()).await;
assert_eq!(j["schema"], "mnem.v1.stats");
assert!(j["op_id"].as_str().unwrap().starts_with("bafyrei"));
}
#[tokio::test]
async fn post_node_then_get_then_retrieve() {
let (app, _td) = make_app();
let body = serde_json::json!({
"label": "Memory",
"summary": "Alice lives in Berlin",
"author": "tests"
});
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/nodes")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "post_node");
let j = to_json(resp.into_body()).await;
let id = j["id"].as_str().unwrap().to_string();
assert_eq!(j["label"], "Memory");
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/v1/nodes/{id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "get_node");
let j = to_json(resp.into_body()).await;
assert_eq!(j["summary"], "Alice lives in Berlin");
assert_eq!(j["label"], "Memory");
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/v1/retrieve?label=Memory&budget=200")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "retrieve");
let j = to_json(resp.into_body()).await;
assert_eq!(j["schema"], "mnem.v1.retrieve");
assert!(j["items"].as_array().unwrap().len() >= 1);
assert_eq!(j["items"][0]["summary"], "Alice lives in Berlin");
assert_eq!(j["tokens_budget"], 200);
}
#[tokio::test]
async fn delete_node_round_trip() {
let (app, _td) = make_app();
let body = serde_json::json!({
"label": "Memory",
"summary": "scratch",
"author": "tests"
});
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/nodes")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
let id = to_json(resp.into_body()).await["id"]
.as_str()
.unwrap()
.to_string();
let resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/v1/nodes/{id}?author=tests"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let j = to_json(resp.into_body()).await;
assert_eq!(j["existed"], true);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/v1/nodes/{id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn bad_uuid_is_400_not_500() {
let (app, _td) = make_app();
let resp = app
.oneshot(
Request::builder()
.uri("/v1/nodes/not-a-uuid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let j = to_json(resp.into_body()).await;
assert_eq!(j["schema"], "mnem.v1.err");
assert!(j["error"].as_str().unwrap().contains("invalid UUID"));
}
#[tokio::test]
async fn empty_label_falls_back_to_default_ntype() {
let (app, _td) = make_app();
let body = serde_json::json!({
"label": "",
"summary": "x",
"author": "tests"
});
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/nodes")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let j = to_json(resp.into_body()).await;
let id = j["id"].as_str().unwrap().to_string();
let resp = app
.oneshot(
Request::builder()
.uri(format!("/v1/nodes/{id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let j = to_json(resp.into_body()).await;
assert_eq!(j["label"], "Node");
}
#[tokio::test]
async fn tombstone_node_round_trip_returns_schema_and_op_id() {
let (app, _td) = make_app();
let body = serde_json::json!({
"label": "Memory",
"summary": "Alice likes jazz",
"author": "tests"
});
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/nodes")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let id = to_json(resp.into_body()).await["id"]
.as_str()
.unwrap()
.to_string();
let body = serde_json::json!({
"reason": "user asked to forget",
"author": "tests"
});
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/v1/nodes/{id}/tombstone"))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "tombstone call ok");
let j = to_json(resp.into_body()).await;
assert_eq!(j["schema"], "mnem.v1.tombstone");
assert_eq!(j["node_id"], id);
assert!(j["op_id"].as_str().is_some());
let resp = app
.oneshot(
Request::builder()
.uri(format!("/v1/nodes/{id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn tombstone_returns_404_for_missing_and_409_for_already_tombstoned() {
let (app, _td) = make_app();
let fake_id = "00000000-0000-0000-0000-000000000001";
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/v1/nodes/{fake_id}/tombstone"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"reason": "r",
"author": "tests"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"missing node must be 404"
);
let body = serde_json::json!({
"label": "Memory",
"summary": "ephemeral",
"author": "tests"
});
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/nodes")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
let id = to_json(resp.into_body()).await["id"]
.as_str()
.unwrap()
.to_string();
let ts_body = serde_json::json!({ "reason": "first", "author": "tests" });
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/v1/nodes/{id}/tombstone"))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&ts_body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "first tombstone ok");
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/v1/nodes/{id}/tombstone"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"reason": "second",
"author": "tests"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::CONFLICT,
"double tombstone must be 409"
);
}
#[tokio::test]
async fn retrieve_with_no_filters_errors() {
let (app, _td) = make_app();
let resp = app
.oneshot(
Request::builder()
.uri("/v1/retrieve")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
resp.status() == StatusCode::BAD_REQUEST
|| resp.status() == StatusCode::INTERNAL_SERVER_ERROR,
"unexpected status: {}",
resp.status()
);
}
#[tokio::test]
async fn retrieve_get_rejects_oversized_limit() {
let (app, _td) = make_app();
let resp = app
.oneshot(
Request::builder()
.uri("/v1/retrieve?limit=99999999")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let j = to_json(resp.into_body()).await;
let err = j["error"].as_str().unwrap();
assert!(
err.contains("limit=99999999"),
"error must name the rejected knob + value: {err}"
);
assert!(
err.contains("max of"),
"error must state the ceiling: {err}"
);
}
#[tokio::test]
async fn retrieve_post_rejects_oversized_vector_cap() {
let (app, _td) = make_app();
let body = serde_json::json!({
"vector_cap": 9_999_999_u64
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/retrieve")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let j = to_json(resp.into_body()).await;
let err = j["error"].as_str().unwrap();
assert!(
err.contains("vector_cap"),
"error must name the knob: {err}"
);
}
#[tokio::test]
async fn retrieve_post_rejects_oversized_rerank_top_k() {
let (app, _td) = make_app();
let body = serde_json::json!({
"rerank_top_k": 10_000_u64
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/retrieve")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let j = to_json(resp.into_body()).await;
let err = j["error"].as_str().unwrap();
assert!(
err.contains("rerank_top_k"),
"error must name the knob: {err}"
);
}
#[tokio::test]
async fn retrieve_post_accepts_at_limit() {
let (app, _td) = make_app();
let body = serde_json::json!({
"limit": 1000,
"vector_cap": 100_000,
"rerank_top_k": 500
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/retrieve")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
if status == StatusCode::BAD_REQUEST {
let j = to_json(resp.into_body()).await;
let err = j["error"].as_str().unwrap_or("");
assert!(
!(err.contains("limit=1000 exceeds")
|| err.contains("vector_cap=100000 exceeds")
|| err.contains("rerank_top_k=500 exceeds")),
"at-cap values must not trip the clamp: {err}"
);
}
}
#[tokio::test]
async fn response_carries_minted_correlation_id() {
let (app, _td) = make_app();
let resp = app
.oneshot(
Request::builder()
.uri("/v1/healthz")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let id = resp
.headers()
.get("x-request-id")
.expect("x-request-id echoed on every response")
.to_str()
.expect("ascii header")
.to_string();
assert_eq!(
id.len(),
36,
"minted UUIDv7 has 36 chars (32 hex + 4 hyphens), got {id}"
);
assert_eq!(id.matches('-').count(), 4, "UUIDv7 has 4 hyphens, got {id}");
}
#[tokio::test]
async fn response_reuses_caller_supplied_correlation_id() {
let (app, _td) = make_app();
let caller = "req-test-correlation-0001";
let resp = app
.oneshot(
Request::builder()
.uri("/v1/healthz")
.header("x-request-id", caller)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok()),
Some(caller),
"caller-supplied correlation id must round-trip"
);
}
fn multipart_body(
boundary: &str,
file_name: &str,
file_bytes: &[u8],
fields: &[(&str, &str)],
) -> Vec<u8> {
let mut out = Vec::new();
for (k, v) in fields {
out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
out.extend_from_slice(
format!("Content-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n").as_bytes(),
);
}
out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
out.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"file\"; filename=\"{file_name}\"\r\n\
Content-Type: application/octet-stream\r\n\r\n"
)
.as_bytes(),
);
out.extend_from_slice(file_bytes);
out.extend_from_slice(b"\r\n");
out.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
out
}
#[tokio::test]
async fn ingest_multipart_markdown_commits_subgraph() {
let (app, _td) = make_app();
let boundary = "----mnemTestBoundary";
let body = multipart_body(
boundary,
"hello.md",
b"# Title\n\nAlice Johnson met Bob Lee at Acme Corp on 2026-04-24.\n",
&[
("author", "http-ingest-test"),
("message", "ingest roundtrip"),
],
);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/ingest")
.header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let j = to_json(resp.into_body()).await;
assert_eq!(j["schema"], "mnem.v1.ingest");
assert!(j["chunk_count"].as_u64().unwrap_or(0) >= 1);
assert!(j["node_count"].as_u64().unwrap_or(0) >= 2);
assert!(j["commit_cid"].is_string());
}
#[tokio::test]
async fn ingest_json_body_text_kind_commits_subgraph() {
let (app, _td) = make_app();
let body = serde_json::json!({
"text": "Alice Johnson joined Acme Corp on 2026-04-24.",
"kind": "text",
"author": "json-ingest-test"
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/ingest")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let j = to_json(resp.into_body()).await;
assert_eq!(j["schema"], "mnem.v1.ingest");
assert!(j["chunk_count"].as_u64().unwrap_or(0) >= 1);
}
#[tokio::test]
async fn ingest_json_body_missing_author_is_bad_request() {
let (app, _td) = make_app();
let body = serde_json::json!({
"text": "no author on this one",
"kind": "text"
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/ingest")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn ingest_json_body_max_tokens_clamp_is_bad_request() {
let (app, _td) = make_app();
let body = serde_json::json!({
"text": "irrelevant",
"kind": "text",
"author": "clamp-test",
"max_tokens": 999_999
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/ingest")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let j = to_json(resp.into_body()).await;
let err = j["error"].as_str().unwrap_or_default();
assert!(err.contains("8192"), "expected clamp message, got {err:?}");
}