mod support;
use hyper::StatusCode;
use serde_json::json;
use support::TestDaemon;
const PROJECT_A: &str = "/tmp/kindling-test/project-a";
const PROJECT_B: &str = "/tmp/kindling-test/project-b";
#[tokio::test]
async fn full_round_trip_per_route() {
let daemon = TestDaemon::start().await;
let mut c = daemon.connect().await;
let resp = c
.send(
"POST",
"/v1/capsules",
Some(PROJECT_A),
Some(json!({
"kind": "session",
"intent": "round trip",
"scopeIds": { "sessionId": "s1" }
})),
)
.await;
assert_eq!(resp.status, StatusCode::CREATED, "open capsule");
let capsule = resp.json();
let capsule_id = capsule["id"].as_str().unwrap().to_string();
assert_eq!(capsule["status"], "open");
assert_eq!(capsule["intent"], "round trip");
let resp = c
.send(
"POST",
"/v1/observations",
Some(PROJECT_A),
Some(json!({
"kind": "message",
"content": "the quick brown fox jumps",
"scopeIds": { "sessionId": "s1" },
"capsuleId": capsule_id,
})),
)
.await;
assert_eq!(resp.status, StatusCode::CREATED, "append observation");
let observation = resp.json();
let obs_id = observation["id"].as_str().unwrap().to_string();
assert_eq!(observation["kind"], "message");
assert_eq!(observation["content"], "the quick brown fox jumps");
let resp = c
.send(
"POST",
"/v1/pins",
Some(PROJECT_A),
Some(json!({
"targetType": "observation",
"targetId": obs_id,
"note": "important",
"scopeIds": { "sessionId": "s1" },
})),
)
.await;
assert_eq!(resp.status, StatusCode::CREATED, "create pin");
let pin = resp.json();
let pin_id = pin["id"].as_str().unwrap().to_string();
assert_eq!(pin["targetId"], obs_id);
let resp = c
.send(
"POST",
"/v1/observations",
Some(PROJECT_A),
Some(json!({
"kind": "message",
"content": "another brown fox sighting",
"scopeIds": { "sessionId": "s1" },
"capsuleId": capsule_id,
})),
)
.await;
assert_eq!(resp.status, StatusCode::CREATED);
let obs2_id = resp.json()["id"].as_str().unwrap().to_string();
let resp = c
.send(
"POST",
"/v1/retrieve",
Some(PROJECT_A),
Some(json!({
"query": "brown fox",
"scopeIds": { "sessionId": "s1" }
})),
)
.await;
assert_eq!(resp.status, StatusCode::OK, "retrieve");
let result = resp.json();
let pins = result["pins"].as_array().unwrap();
assert!(
pins.iter()
.any(|p| p["pin"]["id"] == pin_id && p["target"]["id"] == obs_id),
"pinned observation should surface in retrieval pins: {result:#}"
);
let candidates = result["candidates"].as_array().unwrap();
assert!(
candidates.iter().any(|c| c["entity"]["id"] == obs2_id),
"unpinned observation should surface in retrieval candidates: {result:#}"
);
let resp = c
.send(
"PATCH",
&format!("/v1/capsules/{capsule_id}/close"),
Some(PROJECT_A),
Some(json!({})),
)
.await;
assert_eq!(resp.status, StatusCode::OK, "close capsule");
assert_eq!(resp.json()["status"], "closed");
let resp = c
.send(
"DELETE",
&format!("/v1/pins/{pin_id}"),
Some(PROJECT_A),
None,
)
.await;
assert_eq!(resp.status, StatusCode::NO_CONTENT, "unpin");
}
#[tokio::test]
async fn forget_redacts_observation() {
let daemon = TestDaemon::start().await;
let mut c = daemon.connect().await;
let resp = c
.send(
"POST",
"/v1/observations",
Some(PROJECT_A),
Some(json!({
"kind": "message",
"content": "forgettable needle phrase",
"scopeIds": { "sessionId": "fs" },
})),
)
.await;
assert_eq!(resp.status, StatusCode::CREATED, "append observation");
let obs_id = resp.json()["id"].as_str().unwrap().to_string();
let resp = c
.send(
"POST",
"/v1/retrieve",
Some(PROJECT_A),
Some(json!({ "query": "needle", "scopeIds": { "sessionId": "fs" } })),
)
.await;
assert_eq!(resp.status, StatusCode::OK);
let before = resp.json()["candidates"].as_array().unwrap().clone();
assert!(
before.iter().any(|c| c["entity"]["id"] == obs_id),
"observation should surface before forget: {before:#?}"
);
let resp = c
.send(
"POST",
&format!("/v1/observations/{obs_id}/forget"),
Some(PROJECT_A),
None,
)
.await;
assert_eq!(resp.status, StatusCode::NO_CONTENT, "forget → 204");
let resp = c
.send(
"POST",
"/v1/retrieve",
Some(PROJECT_A),
Some(json!({ "query": "needle", "scopeIds": { "sessionId": "fs" } })),
)
.await;
assert_eq!(resp.status, StatusCode::OK);
let after = resp.json()["candidates"].as_array().unwrap().clone();
assert!(
!after.iter().any(|c| c["entity"]["id"] == obs_id),
"redacted observation must not surface in retrieval: {after:#?}"
);
let resp = c
.send(
"POST",
"/v1/observations/does-not-exist/forget",
Some(PROJECT_A),
None,
)
.await;
assert_eq!(
resp.status,
StatusCode::NOT_FOUND,
"forget missing observation → 404"
);
let resp = c
.send(
"POST",
&format!("/v1/observations/{obs_id}/forget"),
None,
None,
)
.await;
assert_eq!(resp.status, StatusCode::BAD_REQUEST, "no project → 400");
}
#[tokio::test]
async fn health_reports_version_and_schema() {
let daemon = TestDaemon::start().await;
let mut c = daemon.connect().await;
let resp = c.send("GET", "/v1/health", None, None).await;
assert_eq!(resp.status, StatusCode::OK);
let body = resp.json();
assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(
body["schemaVersion"],
kindling_store::schema_version().version
);
assert_eq!(body["projects"].as_array().unwrap().len(), 0);
let _ = c
.send(
"POST",
"/v1/capsules",
Some(PROJECT_A),
Some(json!({
"kind": "pocketflow_node",
"intent": "touch",
"scopeIds": {}
})),
)
.await;
let resp = c.send("GET", "/v1/health", None, None).await;
let body = resp.json();
let projects = body["projects"].as_array().unwrap();
let expected = kindling_store::project_id(PROJECT_A);
assert!(
projects.iter().any(|p| p == &expected),
"touched project id should appear in /v1/health: {body:#}"
);
}
#[tokio::test]
async fn per_project_isolation() {
let daemon = TestDaemon::start().await;
let mut c = daemon.connect().await;
let resp = c
.send(
"POST",
"/v1/observations",
Some(PROJECT_A),
Some(json!({
"kind": "message",
"content": "secret alpha content",
"scopeIds": {}
})),
)
.await;
assert_eq!(resp.status, StatusCode::CREATED);
let resp = c
.send(
"POST",
"/v1/retrieve",
Some(PROJECT_B),
Some(json!({ "query": "alpha", "scopeIds": {} })),
)
.await;
assert_eq!(resp.status, StatusCode::OK);
let candidates = resp.json()["candidates"].as_array().unwrap().clone();
assert!(
candidates.is_empty(),
"project B must not see project A's data: {candidates:#?}"
);
let resp = c
.send(
"POST",
"/v1/retrieve",
Some(PROJECT_A),
Some(json!({ "query": "alpha", "scopeIds": {} })),
)
.await;
let candidates = resp.json()["candidates"].as_array().unwrap().clone();
assert!(!candidates.is_empty(), "project A should see its own data");
}
#[tokio::test]
async fn missing_project_header_is_bad_request() {
let daemon = TestDaemon::start().await;
let mut c = daemon.connect().await;
let resp = c
.send(
"POST",
"/v1/observations",
None, Some(json!({
"kind": "message",
"content": "x",
"scopeIds": {}
})),
)
.await;
assert_eq!(resp.status, StatusCode::BAD_REQUEST);
assert!(resp.json()["error"].as_str().unwrap().contains("project"));
let resp = c
.send(
"POST",
"/v1/observations",
Some(" "),
Some(json!({ "kind": "message", "content": "x", "scopeIds": {} })),
)
.await;
assert_eq!(resp.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn concurrent_writes_same_project_all_land() {
let daemon = TestDaemon::start().await;
let socket = daemon.socket_path.clone();
let socket2 = socket.clone();
let h1 = tokio::spawn(async move {
let mut c = support::Client::connect(&socket).await;
c.send(
"POST",
"/v1/observations",
Some(PROJECT_A),
Some(json!({ "kind": "message", "content": "concurrent one", "scopeIds": {} })),
)
.await
});
let h2 = tokio::spawn(async move {
let mut c = support::Client::connect(&socket2).await;
c.send(
"POST",
"/v1/observations",
Some(PROJECT_A),
Some(json!({ "kind": "message", "content": "concurrent two", "scopeIds": {} })),
)
.await
});
let r1 = h1.await.unwrap();
let r2 = h2.await.unwrap();
assert_eq!(r1.status, StatusCode::CREATED, "first concurrent write");
assert_eq!(r2.status, StatusCode::CREATED, "second concurrent write");
let mut c = daemon.connect().await;
let resp = c
.send(
"POST",
"/v1/retrieve",
Some(PROJECT_A),
Some(json!({ "query": "concurrent", "scopeIds": {}, "maxCandidates": 10 })),
)
.await;
let candidates = resp.json()["candidates"].as_array().unwrap().clone();
assert_eq!(
candidates.len(),
2,
"both concurrent observations should be present: {candidates:#?}"
);
}
#[tokio::test]
async fn session_start_context_exact_markdown() {
let daemon = TestDaemon::start().await;
let mut c = daemon.connect().await;
let scope = json!({ "repoId": PROJECT_A });
let ts_c = 1_700_000_000_000_i64; let ts_a = 1_700_000_001_000_i64; let ts_b = 1_700_049_600_000_i64; let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let offset = kindling_server::inject::local_offset_seconds(now_ms);
let date_c = kindling_server::inject::format_local_datetime(ts_c, offset);
let date_a = kindling_server::inject::format_local_datetime(ts_a, offset);
let date_b = kindling_server::inject::format_local_datetime(ts_b, offset);
for (content, ts) in [("git status", ts_a), ("ran tests", ts_b)] {
let resp = c
.send(
"POST",
"/v1/observations",
Some(PROJECT_A),
Some(json!({
"kind": "command",
"content": content,
"ts": ts,
"scopeIds": { "repoId": PROJECT_A },
})),
)
.await;
assert_eq!(resp.status, StatusCode::CREATED, "seed obs");
}
let resp = c
.send(
"POST",
"/v1/observations",
Some(PROJECT_A),
Some(json!({
"kind": "message",
"content": "use argon2id for hashing",
"ts": ts_c,
"scopeIds": { "repoId": PROJECT_A },
})),
)
.await;
let pin_target = resp.json()["id"].as_str().unwrap().to_string();
let resp = c
.send(
"POST",
"/v1/pins",
Some(PROJECT_A),
Some(json!({
"targetType": "observation",
"targetId": pin_target,
"note": "auth decision",
"scopeIds": { "repoId": PROJECT_A },
})),
)
.await;
assert_eq!(resp.status, StatusCode::CREATED);
let resp = c
.send(
"POST",
"/v1/context/session-start",
Some(PROJECT_A),
Some(json!({ "maxResults": 10, "scopeIds": scope })),
)
.await;
assert_eq!(resp.status, StatusCode::OK);
let additional = resp.json()["additionalContext"]
.as_str()
.expect("additionalContext string")
.to_string();
let expected = format!(
"# Prior Context (from Kindling)\n\n\
The following is prior session context for this project:\n\
## Pinned Items\n\
- **auth decision**: use argon2id for hashing\n\
## Recent Activity\n\
- [{date_b}] command: ran tests\n\
- [{date_a}] command: git status\n\
- [{date_c}] message: use argon2id for hashing"
);
assert_eq!(additional, expected);
}
#[tokio::test]
async fn session_start_context_empty_returns_null() {
let daemon = TestDaemon::start().await;
let mut c = daemon.connect().await;
let resp = c
.send(
"POST",
"/v1/context/session-start",
Some(PROJECT_A),
Some(json!({ "scopeIds": { "repoId": "/no/such/repo" } })),
)
.await;
assert_eq!(resp.status, StatusCode::OK);
assert!(resp.json()["additionalContext"].is_null());
}
#[tokio::test]
async fn pre_compact_context_exact_markdown() {
let daemon = TestDaemon::start().await;
let mut c = daemon.connect().await;
let scope = json!({ "repoId": PROJECT_B });
let resp = c
.send(
"POST",
"/v1/capsules",
Some(PROJECT_B),
Some(json!({
"kind": "pocketflow_node",
"intent": "work",
"scopeIds": { "repoId": PROJECT_B },
})),
)
.await;
let capsule_id = resp.json()["id"].as_str().unwrap().to_string();
let resp = c
.send(
"PATCH",
&format!("/v1/capsules/{capsule_id}/close"),
Some(PROJECT_B),
Some(json!({
"generateSummary": true,
"summaryContent": "we shipped the feature",
"confidence": 0.9,
})),
)
.await;
assert_eq!(resp.status, StatusCode::OK);
let resp = c
.send(
"POST",
"/v1/observations",
Some(PROJECT_B),
Some(json!({
"kind": "message",
"content": "remember the migration step",
"scopeIds": { "repoId": PROJECT_B },
})),
)
.await;
let obs_id = resp.json()["id"].as_str().unwrap().to_string();
let resp = c
.send(
"POST",
"/v1/pins",
Some(PROJECT_B),
Some(json!({
"targetType": "observation",
"targetId": obs_id,
"note": "migration",
"scopeIds": { "repoId": PROJECT_B },
})),
)
.await;
assert_eq!(resp.status, StatusCode::CREATED);
let resp = c
.send(
"POST",
"/v1/context/pre-compact",
Some(PROJECT_B),
Some(json!({ "scopeIds": scope })),
)
.await;
assert_eq!(resp.status, StatusCode::OK);
let additional = resp.json()["additionalContext"]
.as_str()
.expect("additionalContext string")
.to_string();
let expected = "## Pinned Items (preserve across compaction)\n\
- **migration**: remember the migration step\n\
## Session Summary\n\
we shipped the feature";
assert_eq!(additional, expected);
}
#[tokio::test]
async fn pre_compact_context_empty_returns_null() {
let daemon = TestDaemon::start().await;
let mut c = daemon.connect().await;
let resp = c
.send(
"POST",
"/v1/context/pre-compact",
Some(PROJECT_A),
Some(json!({ "scopeIds": { "repoId": "/no/such/repo" } })),
)
.await;
assert_eq!(resp.status, StatusCode::OK);
assert!(resp.json()["additionalContext"].is_null());
}
#[tokio::test]
async fn context_endpoints_require_project_header() {
let daemon = TestDaemon::start().await;
let mut c = daemon.connect().await;
for path in ["/v1/context/session-start", "/v1/context/pre-compact"] {
let resp = c.send("POST", path, None, Some(json!({}))).await;
assert_eq!(resp.status, StatusCode::BAD_REQUEST, "{path}");
}
}
#[tokio::test]
async fn error_mapping() {
let daemon = TestDaemon::start().await;
let mut c = daemon.connect().await;
let resp = c
.send(
"PATCH",
"/v1/capsules/does-not-exist/close",
Some(PROJECT_A),
Some(json!({})),
)
.await;
assert_eq!(resp.status, StatusCode::NOT_FOUND, "close missing → 404");
let body = json!({
"kind": "session",
"intent": "dup",
"scopeIds": { "sessionId": "dup-session" }
});
let first = c
.send("POST", "/v1/capsules", Some(PROJECT_A), Some(body.clone()))
.await;
assert_eq!(first.status, StatusCode::CREATED);
let second = c
.send("POST", "/v1/capsules", Some(PROJECT_A), Some(body))
.await;
assert_eq!(
second.status,
StatusCode::CONFLICT,
"duplicate open session → 409"
);
let resp = c
.send(
"POST",
"/v1/capsules",
Some(PROJECT_A),
Some(json!({ "kind": "pocketflow_node", "intent": "", "scopeIds": {} })),
)
.await;
assert_eq!(resp.status, StatusCode::BAD_REQUEST, "empty intent → 400");
}
#[tokio::test]
async fn get_open_capsule_round_trip() {
let daemon = TestDaemon::start().await;
let mut c = daemon.connect().await;
let resp = c
.send(
"GET",
"/v1/capsules/open?sessionId=sess-1",
Some(PROJECT_A),
None,
)
.await;
assert_eq!(resp.status, StatusCode::OK, "no open capsule → 200");
assert!(resp.json().is_null(), "no open capsule → null body");
let opened = c
.send(
"POST",
"/v1/capsules",
Some(PROJECT_A),
Some(json!({
"kind": "session",
"intent": "resolve me",
"scopeIds": { "sessionId": "sess-1" }
})),
)
.await;
assert_eq!(opened.status, StatusCode::CREATED);
let opened_id = opened.json()["id"].as_str().unwrap().to_string();
let resp = c
.send(
"GET",
"/v1/capsules/open?sessionId=sess-1",
Some(PROJECT_A),
None,
)
.await;
assert_eq!(resp.status, StatusCode::OK);
let cap = resp.json();
assert_eq!(cap["id"], opened_id);
assert_eq!(cap["status"], "open");
assert_eq!(cap["scopeIds"]["sessionId"], "sess-1");
let resp = c
.send_with_headers(
"GET",
"/v1/capsules/open",
Some(PROJECT_A),
&[("x-kindling-session", "sess-1")],
None,
)
.await;
assert_eq!(resp.status, StatusCode::OK, "header session id resolves");
assert_eq!(resp.json()["id"], opened_id);
let resp = c
.send(
"GET",
"/v1/capsules/open?sessionId=other",
Some(PROJECT_A),
None,
)
.await;
assert_eq!(resp.status, StatusCode::OK);
assert!(resp.json().is_null());
let resp = c
.send("GET", "/v1/capsules/open", Some(PROJECT_A), None)
.await;
assert_eq!(resp.status, StatusCode::BAD_REQUEST, "no session id → 400");
let resp = c
.send("GET", "/v1/capsules/open?sessionId=sess-1", None, None)
.await;
assert_eq!(resp.status, StatusCode::BAD_REQUEST, "no project → 400");
}