use std::sync::{Arc, Mutex};
use std::thread::sleep;
use std::time::Duration;
use axum::{
body::{Body, Bytes},
http::{Request, StatusCode, header},
response::Response,
};
use kb::api::{
self, AppState, CreateNodeRequest, NodeSummary, NodeView, UpdateNodeRequest, build_router,
};
use kb::storage::{init_db, insert_node, open_db};
use serde_json::{Value, json};
use tftio_org::ast::*;
use tower::ServiceExt;
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn sample_doc() -> Document {
Document {
blocks: vec![
Block::Heading {
level: 1,
title: Title("Hello".into()),
tags: vec![Tag("greeting".into())],
children: vec![],
},
Block::Paragraph {
inlines: vec![Inline::Plain("world".into())],
},
],
}
}
fn fresh_state() -> Result<(tempfile::TempDir, Arc<AppState>), Box<dyn std::error::Error>> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("api.db");
let conn = open_db(path.to_str().ok_or("temp db path is not valid UTF-8")?)?;
init_db(&conn)?;
let state = Arc::new(AppState {
conn: Mutex::new(conn),
embedding_client: None,
embedding_model: None,
});
Ok((dir, state))
}
async fn send(
state: Arc<AppState>,
req: Request<Body>,
) -> Result<Response<Body>, Box<dyn std::error::Error>> {
Ok(build_router(state).oneshot(req).await?)
}
async fn body_bytes(resp: Response<Body>) -> Result<Bytes, Box<dyn std::error::Error>> {
Ok(axum::body::to_bytes(resp.into_body(), usize::MAX).await?)
}
async fn body_json(resp: Response<Body>) -> Result<Value, Box<dyn std::error::Error>> {
let bytes = body_bytes(resp).await?;
Ok(serde_json::from_slice(&bytes)?)
}
fn json_post(uri: &str, value: &Value) -> Result<Request<Body>, Box<dyn std::error::Error>> {
Ok(Request::builder()
.method("POST")
.uri(uri)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(value)?))?)
}
fn json_put(uri: &str, value: &Value) -> Result<Request<Body>, Box<dyn std::error::Error>> {
Ok(Request::builder()
.method("PUT")
.uri(uri)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(value)?))?)
}
fn get(uri: &str) -> Result<Request<Body>, Box<dyn std::error::Error>> {
Ok(Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())?)
}
fn delete_req(uri: &str) -> Result<Request<Body>, Box<dyn std::error::Error>> {
Ok(Request::builder()
.method("DELETE")
.uri(uri)
.body(Body::empty())?)
}
#[test]
fn node_view_round_trips_through_json() -> TestResult {
let view = NodeView {
id: "node-1".into(),
title: "Hello".into(),
tags: vec!["greeting".into()],
document: sample_doc(),
created_at: "2026-05-01T10:00:00.000Z".into(),
updated_at: "2026-05-01T10:00:00.000Z".into(),
};
let s = serde_json::to_string(&view)?;
let back: NodeView = serde_json::from_str(&s)?;
assert_eq!(view, back);
Ok(())
}
#[test]
fn node_summary_round_trips_through_json() -> TestResult {
let s = NodeSummary {
id: "n1".into(),
title: "T".into(),
};
let v = serde_json::to_string(&s)?;
let back: NodeSummary = serde_json::from_str(&v)?;
assert_eq!(s, back);
Ok(())
}
#[test]
fn create_node_request_round_trips() -> TestResult {
let req = CreateNodeRequest {
id: None,
document: sample_doc(),
};
let s = serde_json::to_string(&req)?;
let back: CreateNodeRequest = serde_json::from_str(&s)?;
assert_eq!(back.id, req.id);
assert_eq!(back.document, req.document);
let req2 = CreateNodeRequest {
id: Some("supplied".into()),
document: sample_doc(),
};
let s2 = serde_json::to_string(&req2)?;
let back2: CreateNodeRequest = serde_json::from_str(&s2)?;
assert_eq!(back2.id, req2.id);
assert_eq!(back2.document, req2.document);
Ok(())
}
#[test]
fn update_node_request_round_trips() -> TestResult {
let req = UpdateNodeRequest {
document: sample_doc(),
};
let s = serde_json::to_string(&req)?;
let back: UpdateNodeRequest = serde_json::from_str(&s)?;
assert_eq!(back.document, req.document);
Ok(())
}
#[test]
fn document_round_trips_through_json() -> TestResult {
let doc = sample_doc();
let s = serde_json::to_string(&doc)?;
let back: Document = serde_json::from_str(&s)?;
assert_eq!(back, doc);
Ok(())
}
#[tokio::test]
async fn post_nodes_mints_uuid_when_id_omitted() -> TestResult {
let (_d, st) = fresh_state()?;
let body = json!({ "document": sample_doc() });
let resp = send(st, json_post("/nodes", &body)?).await?;
assert_eq!(resp.status(), StatusCode::CREATED);
let v = body_json(resp).await?;
let id = v.get("id").and_then(Value::as_str).ok_or("id field")?;
assert!(uuid::Uuid::parse_str(id).is_ok(), "expected UUID, got {id}");
Ok(())
}
#[tokio::test]
async fn post_nodes_accepts_caller_supplied_body_id() -> TestResult {
let (_d, st) = fresh_state()?;
let body = json!({ "id": "import-1", "document": sample_doc() });
let resp = send(st, json_post("/nodes", &body)?).await?;
assert_eq!(resp.status(), StatusCode::CREATED);
let v = body_json(resp).await?;
assert_eq!(v.get("id").and_then(Value::as_str), Some("import-1"));
Ok(())
}
#[tokio::test]
async fn post_nodes_accepts_caller_supplied_query_id() -> TestResult {
let (_d, st) = fresh_state()?;
let body = json!({ "document": sample_doc() });
let resp = send(st, json_post("/nodes?id=from-query", &body)?).await?;
assert_eq!(resp.status(), StatusCode::CREATED);
let v = body_json(resp).await?;
assert_eq!(v.get("id").and_then(Value::as_str), Some("from-query"));
Ok(())
}
#[tokio::test]
async fn post_nodes_query_id_is_used_when_body_id_absent() -> TestResult {
let (_d, st) = fresh_state()?;
let body = json!({ "id": "from-body", "document": sample_doc() });
let resp = send(st, json_post("/nodes?id=from-query", &body)?).await?;
assert!(resp.status().is_success());
let v = body_json(resp).await?;
let id = v.get("id").and_then(Value::as_str).ok_or("id field")?;
assert!(
id == "from-body" || id == "from-query",
"unexpected id: {id}"
);
Ok(())
}
#[tokio::test]
async fn post_nodes_409_on_id_conflict() -> TestResult {
let (_d, st) = fresh_state()?;
let body = json!({ "id": "conflict", "document": sample_doc() });
let r1 = send(Arc::clone(&st), json_post("/nodes", &body)?).await?;
assert_eq!(r1.status(), StatusCode::CREATED);
let r2 = send(st, json_post("/nodes", &body)?).await?;
assert_eq!(r2.status(), StatusCode::CONFLICT);
Ok(())
}
#[tokio::test]
async fn get_node_404_for_unknown_id() -> TestResult {
let (_d, st) = fresh_state()?;
let resp = send(st, get("/nodes/missing")?).await?;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
Ok(())
}
#[tokio::test]
async fn get_node_returns_stored_node_view() -> TestResult {
let (_d, st) = fresh_state()?;
{
let conn = st.conn.lock().map_err(|_| "conn mutex poisoned")?;
insert_node(&conn, "n1", &sample_doc())?;
}
let resp = send(st, get("/nodes/n1")?).await?;
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await?;
let doc: Document =
serde_json::from_value(v.get("document").cloned().ok_or("document field")?)?;
assert_eq!(doc, sample_doc());
Ok(())
}
#[tokio::test]
async fn put_node_updates_and_returns_new_view() -> TestResult {
let (_d, st) = fresh_state()?;
{
let conn = st.conn.lock().map_err(|_| "conn mutex poisoned")?;
insert_node(&conn, "n1", &sample_doc())?;
}
let updated = Document {
blocks: vec![
Block::Heading {
level: 1,
title: Title("Renamed".into()),
tags: vec![],
children: vec![],
},
Block::Paragraph {
inlines: vec![Inline::Plain("body".into())],
},
],
};
let req = json!({ "document": updated });
let resp = send(st, json_put("/nodes/n1", &req)?).await?;
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await?;
let doc: Document =
serde_json::from_value(v.get("document").cloned().ok_or("document field")?)?;
assert_eq!(
doc.blocks.first().ok_or("returned doc has no blocks")?,
updated.blocks.first().ok_or("expected updated blocks")?
);
Ok(())
}
#[tokio::test]
async fn put_node_404_for_unknown_id() -> TestResult {
let (_d, st) = fresh_state()?;
let req = json!({ "document": sample_doc() });
let resp = send(st, json_put("/nodes/missing", &req)?).await?;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
Ok(())
}
#[tokio::test]
async fn delete_node_204_then_404() -> TestResult {
let (_d, st) = fresh_state()?;
{
let conn = st.conn.lock().map_err(|_| "conn mutex poisoned")?;
insert_node(&conn, "n1", &sample_doc())?;
}
let r1 = send(Arc::clone(&st), delete_req("/nodes/n1")?).await?;
assert_eq!(r1.status(), StatusCode::NO_CONTENT);
let r2 = send(st, delete_req("/nodes/n1")?).await?;
assert_eq!(r2.status(), StatusCode::NOT_FOUND);
Ok(())
}
#[tokio::test]
async fn search_returns_ranked_summaries() -> TestResult {
let (_d, st) = fresh_state()?;
{
let conn = st.conn.lock().map_err(|_| "conn mutex poisoned")?;
insert_node(
&conn,
"a",
&Document {
blocks: vec![
Block::Heading {
level: 1,
title: Title("Alpha".into()),
tags: vec![],
children: vec![],
},
Block::Paragraph {
inlines: vec![Inline::Plain("first".into())],
},
],
},
)?;
insert_node(
&conn,
"b",
&Document {
blocks: vec![
Block::Heading {
level: 1,
title: Title("Beta".into()),
tags: vec![],
children: vec![],
},
Block::Paragraph {
inlines: vec![Inline::Plain("second".into())],
},
],
},
)?;
}
let resp = send(Arc::clone(&st), get("/search?q=Alpha")?).await?;
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await?;
let summaries: Vec<NodeSummary> = serde_json::from_value(v)?;
let ids: Vec<&str> = summaries.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids, vec!["a"]);
let resp = send(st, get("/search?q=%20%20")?).await?;
assert_eq!(resp.status(), StatusCode::OK);
let summaries: Vec<NodeSummary> = serde_json::from_value(body_json(resp).await?)?;
assert!(summaries.is_empty());
Ok(())
}
#[tokio::test]
async fn tags_returns_matching_summaries() -> TestResult {
let (_d, st) = fresh_state()?;
{
let conn = st.conn.lock().map_err(|_| "conn mutex poisoned")?;
insert_node(
&conn,
"a",
&Document {
blocks: vec![Block::Heading {
level: 1,
title: Title("T".into()),
tags: vec![Tag("kb".into())],
children: vec![],
}],
},
)?;
insert_node(
&conn,
"b",
&Document {
blocks: vec![Block::Heading {
level: 1,
title: Title("T".into()),
tags: vec![Tag("other".into())],
children: vec![],
}],
},
)?;
}
let resp = send(st, get("/tags/kb")?).await?;
assert_eq!(resp.status(), StatusCode::OK);
let summaries: Vec<NodeSummary> = serde_json::from_value(body_json(resp).await?)?;
let ids: Vec<&str> = summaries.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids, vec!["a"]);
Ok(())
}
#[tokio::test]
async fn recent_returns_newest_first() -> TestResult {
let (_d, st) = fresh_state()?;
{
let conn = st.conn.lock().map_err(|_| "conn mutex poisoned")?;
insert_node(&conn, "a", &sample_doc())?;
}
sleep(Duration::from_millis(5));
{
let conn = st.conn.lock().map_err(|_| "conn mutex poisoned")?;
insert_node(&conn, "b", &sample_doc())?;
}
let resp = send(st, get("/recent")?).await?;
assert_eq!(resp.status(), StatusCode::OK);
let summaries: Vec<NodeSummary> = serde_json::from_value(body_json(resp).await?)?;
let ids: Vec<&str> = summaries.iter().map(|s| s.id.as_str()).collect();
assert_eq!(
ids.get(..2).ok_or("expected at least two ids")?,
&["b", "a"]
);
Ok(())
}
#[tokio::test]
async fn list_nodes_paginates() -> TestResult {
let (_d, st) = fresh_state()?;
{
let conn = st.conn.lock().map_err(|_| "conn mutex poisoned")?;
for i in 1..=5 {
insert_node(&conn, &format!("n{i}"), &sample_doc())?;
}
}
let p1 = send(Arc::clone(&st), get("/nodes?limit=2&offset=0")?).await?;
let p2 = send(Arc::clone(&st), get("/nodes?limit=2&offset=2")?).await?;
let p3 = send(Arc::clone(&st), get("/nodes?limit=2&offset=4")?).await?;
assert_eq!(p1.status(), StatusCode::OK);
assert_eq!(p2.status(), StatusCode::OK);
assert_eq!(p3.status(), StatusCode::OK);
let s1: Vec<NodeSummary> = serde_json::from_value(body_json(p1).await?)?;
let s2: Vec<NodeSummary> = serde_json::from_value(body_json(p2).await?)?;
let s3: Vec<NodeSummary> = serde_json::from_value(body_json(p3).await?)?;
assert_eq!(s1.len(), 2);
assert_eq!(s2.len(), 2);
assert_eq!(s3.len(), 1);
let mut all: Vec<String> = s1
.iter()
.chain(s2.iter())
.chain(s3.iter())
.map(|s| s.id.0.clone())
.collect();
all.sort();
assert_eq!(all, vec!["n1", "n2", "n3", "n4", "n5"]);
let mut deduped = all.clone();
deduped.dedup();
assert_eq!(deduped, all);
let _ = api::ListNodesQuery::default();
Ok(())
}
#[tokio::test]
async fn list_nodes_limit_cap() -> TestResult {
let (_d, st) = fresh_state()?;
let resp = send(st, get("/nodes?limit=1000000&offset=0")?).await?;
assert_eq!(resp.status(), StatusCode::OK);
let summaries: Vec<NodeSummary> = serde_json::from_value(body_json(resp).await?)?;
assert!(summaries.len() <= 1000, "result is bounded");
Ok(())
}