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::ast::*;
use kb::storage::{init_db, insert_node, open_db};
use serde_json::{Value, json};
use tower::ServiceExt;
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() -> (tempfile::TempDir, Arc<AppState>) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("api.db");
let conn = open_db(path.to_str().unwrap()).unwrap();
init_db(&conn).unwrap();
let state = Arc::new(AppState {
conn: Mutex::new(conn),
embedding_client: None,
embedding_model: None,
});
(dir, state)
}
async fn send(state: Arc<AppState>, req: Request<Body>) -> Response<Body> {
build_router(state).oneshot(req).await.unwrap()
}
async fn body_bytes(resp: Response<Body>) -> Bytes {
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
}
async fn body_json(resp: Response<Body>) -> Value {
let bytes = body_bytes(resp).await;
serde_json::from_slice(&bytes).expect("response body must be JSON")
}
fn json_post(uri: &str, value: &Value) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(value).unwrap()))
.unwrap()
}
fn json_put(uri: &str, value: &Value) -> Request<Body> {
Request::builder()
.method("PUT")
.uri(uri)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(value).unwrap()))
.unwrap()
}
fn get(uri: &str) -> Request<Body> {
Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.unwrap()
}
fn delete_req(uri: &str) -> Request<Body> {
Request::builder()
.method("DELETE")
.uri(uri)
.body(Body::empty())
.unwrap()
}
#[test]
fn node_view_round_trips_through_json() {
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).unwrap();
let back: NodeView = serde_json::from_str(&s).unwrap();
assert_eq!(view, back);
}
#[test]
fn node_summary_round_trips_through_json() {
let s = NodeSummary {
id: "n1".into(),
title: "T".into(),
};
let v = serde_json::to_string(&s).unwrap();
let back: NodeSummary = serde_json::from_str(&v).unwrap();
assert_eq!(s, back);
}
#[test]
fn create_node_request_round_trips() {
let req = CreateNodeRequest {
id: None,
document: sample_doc(),
};
let s = serde_json::to_string(&req).unwrap();
let back: CreateNodeRequest = serde_json::from_str(&s).unwrap();
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).unwrap();
let back2: CreateNodeRequest = serde_json::from_str(&s2).unwrap();
assert_eq!(back2.id, req2.id);
assert_eq!(back2.document, req2.document);
}
#[test]
fn update_node_request_round_trips() {
let req = UpdateNodeRequest {
document: sample_doc(),
};
let s = serde_json::to_string(&req).unwrap();
let back: UpdateNodeRequest = serde_json::from_str(&s).unwrap();
assert_eq!(back.document, req.document);
}
#[test]
fn document_round_trips_through_json() {
let doc = sample_doc();
let s = serde_json::to_string(&doc).unwrap();
let back: Document = serde_json::from_str(&s).unwrap();
assert_eq!(back, doc);
}
#[tokio::test]
async fn post_nodes_mints_uuid_when_id_omitted() {
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).expect("id field");
assert!(uuid::Uuid::parse_str(id).is_ok(), "expected UUID, got {id}");
}
#[tokio::test]
async fn post_nodes_accepts_caller_supplied_body_id() {
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"));
}
#[tokio::test]
async fn post_nodes_accepts_caller_supplied_query_id() {
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"));
}
#[tokio::test]
async fn post_nodes_query_id_is_used_when_body_id_absent() {
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).unwrap();
assert!(
id == "from-body" || id == "from-query",
"unexpected id: {id}"
);
}
#[tokio::test]
async fn post_nodes_409_on_id_conflict() {
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);
}
#[tokio::test]
async fn get_node_404_for_unknown_id() {
let (_d, st) = fresh_state();
let resp = send(st, get("/nodes/missing")).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_node_returns_stored_node_view() {
let (_d, st) = fresh_state();
{
let conn = st.conn.lock().unwrap();
insert_node(&conn, "n1", &sample_doc()).unwrap();
}
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().unwrap()).unwrap();
assert_eq!(doc, sample_doc());
}
#[tokio::test]
async fn put_node_updates_and_returns_new_view() {
let (_d, st) = fresh_state();
{
let conn = st.conn.lock().unwrap();
insert_node(&conn, "n1", &sample_doc()).unwrap();
}
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().unwrap()).unwrap();
assert_eq!(doc.blocks[0], updated.blocks[0]);
}
#[tokio::test]
async fn put_node_404_for_unknown_id() {
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);
}
#[tokio::test]
async fn delete_node_204_then_404() {
let (_d, st) = fresh_state();
{
let conn = st.conn.lock().unwrap();
insert_node(&conn, "n1", &sample_doc()).unwrap();
}
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);
}
#[tokio::test]
async fn search_returns_ranked_summaries() {
let (_d, st) = fresh_state();
{
let conn = st.conn.lock().unwrap();
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())],
},
],
},
)
.unwrap();
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())],
},
],
},
)
.unwrap();
}
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).unwrap();
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).unwrap();
assert!(summaries.is_empty());
}
#[tokio::test]
async fn tags_returns_matching_summaries() {
let (_d, st) = fresh_state();
{
let conn = st.conn.lock().unwrap();
insert_node(
&conn,
"a",
&Document {
blocks: vec![Block::Heading {
level: 1,
title: Title("T".into()),
tags: vec![Tag("kb".into())],
children: vec![],
}],
},
)
.unwrap();
insert_node(
&conn,
"b",
&Document {
blocks: vec![Block::Heading {
level: 1,
title: Title("T".into()),
tags: vec![Tag("other".into())],
children: vec![],
}],
},
)
.unwrap();
}
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).unwrap();
let ids: Vec<&str> = summaries.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids, vec!["a"]);
}
#[tokio::test]
async fn recent_returns_newest_first() {
let (_d, st) = fresh_state();
{
let conn = st.conn.lock().unwrap();
insert_node(&conn, "a", &sample_doc()).unwrap();
}
sleep(Duration::from_millis(5));
{
let conn = st.conn.lock().unwrap();
insert_node(&conn, "b", &sample_doc()).unwrap();
}
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).unwrap();
let ids: Vec<&str> = summaries.iter().map(|s| s.id.as_str()).collect();
assert_eq!(&ids[..2], &["b", "a"]);
}
#[tokio::test]
async fn list_nodes_paginates() {
let (_d, st) = fresh_state();
{
let conn = st.conn.lock().unwrap();
for i in 1..=5 {
insert_node(&conn, &format!("n{i}"), &sample_doc()).unwrap();
}
}
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).unwrap();
let s2: Vec<NodeSummary> = serde_json::from_value(body_json(p2).await).unwrap();
let s3: Vec<NodeSummary> = serde_json::from_value(body_json(p3).await).unwrap();
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();
}
#[tokio::test]
async fn list_nodes_limit_cap() {
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).unwrap();
assert!(summaries.len() <= 1000, "result is bounded");
}