use super::super::router;
use super::test_state;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use tower::util::ServiceExt;
use trusty_common::memory_core::palace::PalaceId;
use trusty_common::memory_core::store::kg::Triple;
#[tokio::test]
async fn kg_gaps_endpoint_returns_empty_when_uncached() {
let state = test_state();
let palace = trusty_common::memory_core::Palace {
id: PalaceId::new("gaps-empty"),
name: "gaps-empty".to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join("gaps-empty"),
};
state
.registry
.create_palace(&state.data_root, palace)
.expect("create palace");
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/kg/gaps?palace=gaps-empty")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v.as_array().expect("array").len(), 0);
}
#[tokio::test]
async fn kg_gaps_endpoint_returns_cached_gaps() {
use trusty_common::memory_core::community::KnowledgeGap;
let state = test_state();
let palace = trusty_common::memory_core::Palace {
id: PalaceId::new("gaps-seed"),
name: "gaps-seed".to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join("gaps-seed"),
};
state
.registry
.create_palace(&state.data_root, palace)
.expect("create palace");
state.registry.set_gaps(
PalaceId::new("gaps-seed"),
vec![KnowledgeGap {
entities: vec!["foo".to_string(), "bar".to_string(), "baz".to_string()],
internal_density: 0.15,
external_bridges: 2,
suggested_exploration: "Explore connections between foo and related concepts"
.to_string(),
}],
);
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/kg/gaps?palace=gaps-seed")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let arr = v.as_array().expect("array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["entities"].as_array().unwrap().len(), 3);
assert_eq!(arr[0]["external_bridges"], 2);
assert!(arr[0]["suggested_exploration"]
.as_str()
.unwrap()
.contains("foo"));
}
#[tokio::test]
async fn kg_list_subjects_returns_distinct() {
let state = test_state();
let app = router().with_state(state.clone());
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces")
.header("content-type", "application/json")
.body(Body::from(json!({"name": "kg-list"}).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
for subj in ["alpha", "beta"] {
let body = json!({
"subject": subj,
"predicate": "is",
"object": "thing",
})
.to_string();
let r = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces/kg-list/kg")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::NO_CONTENT);
}
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/palaces/kg-list/kg/subjects?limit=10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let arr = v.as_array().expect("subjects must be array");
let subjects: Vec<String> = arr
.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect();
assert_eq!(subjects, vec!["alpha".to_string(), "beta".to_string()]);
}
#[tokio::test]
async fn kg_list_all_returns_paginated_triples() {
let state = test_state();
let app = router().with_state(state.clone());
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces")
.header("content-type", "application/json")
.body(Body::from(json!({"name": "kg-all"}).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json!({
"subject": "alpha",
"predicate": "is",
"object": "thing",
})
.to_string();
let r = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces/kg-all/kg")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::NO_CONTENT);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/palaces/kg-all/kg/all?limit=10&offset=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let arr = v.as_array().expect("triples must be array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["subject"], "alpha");
assert_eq!(arr[0]["predicate"], "is");
assert_eq!(arr[0]["object"], "thing");
}
#[tokio::test]
async fn kg_graph_returns_active_triples() {
let state = test_state();
let app = router().with_state(state.clone());
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces")
.header("content-type", "application/json")
.body(Body::from(json!({"name": "kg-graph"}).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json!({
"subject": "alpha",
"predicate": "is",
"object": "thing",
})
.to_string();
let r = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces/kg-graph/kg")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::NO_CONTENT);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/palaces/kg-graph/kg/graph")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 16_384).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let triples = v["triples"].as_array().expect("triples array");
assert!(triples
.iter()
.any(|t| t["subject"] == "alpha" && t["predicate"] == "is" && t["object"] == "thing"));
assert!(v["node_count"].as_u64().is_some());
assert!(v["edge_count"].as_u64().is_some());
assert!(v["community_count"].as_u64().is_some());
}
#[tokio::test]
async fn kg_graph_meets_perf_budget_for_500_triples() {
let state = test_state();
let app = router().with_state(state.clone());
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces")
.header("content-type", "application/json")
.body(Body::from(json!({"name": "kg-perf"}).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let pid = trusty_common::memory_core::palace::PalaceId::new("kg-perf");
let handle = state
.registry
.open_palace(&state.data_root, &pid)
.expect("open palace");
let now = chrono::Utc::now();
for s in 0..10 {
for o in 0..50 {
handle
.kg
.assert(Triple {
subject: format!("s{s}"),
predicate: format!("p{o}"),
object: format!("o{o}"),
valid_from: now,
valid_to: None,
confidence: 1.0,
provenance: Some("perf-test".to_string()),
})
.await
.expect("kg.assert");
}
}
let started = std::time::Instant::now();
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/palaces/kg-perf/kg/graph")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let elapsed = started.elapsed();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 1_000_000).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let n = v["triples"].as_array().map(|a| a.len()).unwrap_or(0);
assert_eq!(n, 500, "expected 500 triples in payload");
assert!(
elapsed.as_secs_f64() < 10.0,
"graph endpoint should serve 500 triples in well under 10s; took {elapsed:?}"
);
eprintln!(
"[perf] kg_graph endpoint served 500 triples in {:.3}ms",
elapsed.as_secs_f64() * 1000.0
);
}
async fn seed_explore_palace(state: &crate::AppState, name: &str) -> axum::Router {
let app = router().with_state(state.clone());
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces")
.header("content-type", "application/json")
.body(Body::from(json!({ "name": name }).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let handle = state
.registry
.open_palace(&state.data_root, &PalaceId::new(name))
.expect("open palace");
let now = chrono::Utc::now();
for (s, p, o) in [
("hub", "p1", "a"),
("hub", "p2", "b"),
("hub", "p3", "c"),
("s1", "pa", "hub"),
("s2", "pb", "hub"),
("a", "q1", "b"),
] {
handle
.kg
.assert(Triple {
subject: s.into(),
predicate: p.into(),
object: o.into(),
valid_from: now,
valid_to: None,
confidence: 1.0,
provenance: None,
})
.await
.expect("kg.assert");
}
app
}
#[tokio::test]
async fn kg_graph_seed_ranks_by_degree() {
let state = test_state();
let app = seed_explore_palace(&state, "kg-seed").await;
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/palaces/kg-seed/kg/graph/seed?limit=3")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 65_536).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
let ids: Vec<&str> = v["nodes"]
.as_array()
.expect("nodes")
.iter()
.map(|n| n["id"].as_str().unwrap())
.collect();
assert_eq!(ids, vec!["hub", "a", "b"], "seed must rank by degree desc");
assert_eq!(v["nodes"][0]["degree"], 5);
assert_eq!(v["nodes"][0]["out_degree"], 3);
assert_eq!(v["nodes"][0]["in_degree"], 2);
assert_eq!(v["triples"].as_array().unwrap().len(), 3);
assert_eq!(v["returned_node_count"], 3);
assert_eq!(v["returned_triple_count"], 3);
assert_eq!(v["node_count"], 6);
assert_eq!(v["edge_count"], 6);
assert_eq!(v["truncated"], true);
assert_eq!(v["limit"], 3);
}
#[tokio::test]
async fn kg_graph_seed_clamps_limit() {
let state = test_state();
let app = seed_explore_palace(&state, "kg-seed-clamp").await;
let get = |uri: &'static str, app: axum::Router| async move {
let resp = app
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 65_536).await.unwrap();
serde_json::from_slice::<Value>(&bytes).unwrap()
};
let hi = get(
"/api/v1/palaces/kg-seed-clamp/kg/graph/seed?limit=100000",
app.clone(),
)
.await;
assert_eq!(hi["limit"], 200, "limit must clamp to MAX_KG_SEED_LIMIT");
let lo = get(
"/api/v1/palaces/kg-seed-clamp/kg/graph/seed?limit=0",
app.clone(),
)
.await;
assert_eq!(
lo["limit"], 1,
"limit=0 must clamp to 1, not mean unbounded"
);
assert_eq!(lo["nodes"].as_array().unwrap().len(), 1);
let def = get("/api/v1/palaces/kg-seed-clamp/kg/graph/seed", app).await;
assert_eq!(def["limit"], 75, "default seed limit");
assert_eq!(def["truncated"], false);
}
#[tokio::test]
async fn kg_neighbors_returns_incoming_edges() {
let state = test_state();
let app = seed_explore_palace(&state, "kg-nbr").await;
let fetch = |uri: String, app: axum::Router| async move {
let resp = app
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 65_536).await.unwrap();
serde_json::from_slice::<Value>(&bytes).unwrap()
};
let inbound = fetch(
"/api/v1/palaces/kg-nbr/kg/graph/neighbors?node=hub&direction=in&max_hops=1".into(),
app.clone(),
)
.await;
assert_eq!(inbound["direction"], "in");
assert_eq!(inbound["origin"], "hub");
let tr = inbound["triples"].as_array().unwrap();
assert_eq!(tr.len(), 2, "hub has exactly 2 inbound edges");
for t in tr {
assert_eq!(t["object"], "hub", "direction=in must yield edges INTO hub");
}
let ids: std::collections::HashSet<&str> = inbound["nodes"]
.as_array()
.unwrap()
.iter()
.map(|n| n["id"].as_str().unwrap())
.collect();
assert_eq!(ids, ["hub", "s1", "s2"].into_iter().collect());
assert_eq!(inbound["nodes"][0]["id"], "hub");
assert_eq!(inbound["nodes"][0]["degree"], 5);
let outbound = fetch(
"/api/v1/palaces/kg-nbr/kg/graph/neighbors?node=hub&direction=out".into(),
app.clone(),
)
.await;
assert_eq!(outbound["triples"].as_array().unwrap().len(), 3);
for t in outbound["triples"].as_array().unwrap() {
assert_eq!(t["subject"], "hub");
}
let both = fetch(
"/api/v1/palaces/kg-nbr/kg/graph/neighbors?node=hub".into(),
app,
)
.await;
assert_eq!(both["direction"], "both");
assert_eq!(both["triples"].as_array().unwrap().len(), 5);
assert_eq!(both["returned_node_count"], 6);
}
#[tokio::test]
async fn kg_neighbors_clamps_max_hops() {
let state = test_state();
let app = seed_explore_palace(&state, "kg-nbr-hops").await;
let fetch = |uri: String, app: axum::Router| async move {
let resp = app
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 65_536).await.unwrap();
serde_json::from_slice::<Value>(&bytes).unwrap()
};
let hi = fetch(
"/api/v1/palaces/kg-nbr-hops/kg/graph/neighbors?node=hub&max_hops=99".into(),
app.clone(),
)
.await;
assert_eq!(hi["max_hops"], 4, "max_hops must clamp to 4");
let lo = fetch(
"/api/v1/palaces/kg-nbr-hops/kg/graph/neighbors?node=hub&max_hops=0".into(),
app.clone(),
)
.await;
assert_eq!(
lo["max_hops"], 1,
"max_hops=0 must clamp to 1, not expand nothing"
);
assert!(lo["returned_triple_count"].as_u64().unwrap() > 0);
let one = fetch(
"/api/v1/palaces/kg-nbr-hops/kg/graph/neighbors?node=hub&direction=out&max_hops=1".into(),
app.clone(),
)
.await;
let two = fetch(
"/api/v1/palaces/kg-nbr-hops/kg/graph/neighbors?node=hub&direction=out&max_hops=2".into(),
app,
)
.await;
assert_eq!(one["returned_triple_count"], 3);
assert_eq!(two["returned_triple_count"], 4, "2 hops discovers a→b");
}
#[tokio::test]
async fn kg_neighbors_rejects_bad_direction() {
let state = test_state();
let app = seed_explore_palace(&state, "kg-nbr-bad").await;
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/palaces/kg-nbr-bad/kg/graph/neighbors?node=hub&direction=sideways")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/palaces/kg-nbr-bad/kg/graph/neighbors?node=ghost")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 16_384).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["returned_node_count"], 0);
assert_eq!(v["returned_triple_count"], 0);
}
#[tokio::test]
async fn kg_graph_signals_truncation() {
let state = test_state();
let _app = seed_explore_palace(&state, "kg-trunc").await;
let svc = crate::service::MemoryService::new(state);
let capped = svc
.kg_graph_with_cap("kg-trunc", 3)
.await
.expect("kg_graph");
assert_eq!(capped.triples.len(), 3);
assert_eq!(capped.returned_triple_count, 3);
assert_eq!(
capped.active_triple_count, 6,
"active count must reflect the whole palace, not the window"
);
assert!(
capped.truncated,
"a payload carrying 3 of 6 triples must say so"
);
assert_eq!(capped.node_count, 6);
assert_eq!(capped.edge_count, 6);
let whole = svc
.kg_graph_with_cap("kg-trunc", 1000)
.await
.expect("kg_graph");
assert_eq!(whole.returned_triple_count, 6);
assert_eq!(whole.active_triple_count, 6);
assert!(
!whole.truncated,
"a complete payload must not claim truncation"
);
}
async fn seed_pair(
app: &axum::Router,
name: &str,
subject: &str,
predicate: &str,
objects: &[&str],
) {
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces")
.header("content-type", "application/json")
.body(Body::from(json!({ "name": name }).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "create palace {name}");
for object in objects {
let body = json!({
"subject": subject,
"predicate": predicate,
"object": object,
})
.to_string();
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/palaces/{name}/kg"))
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT, "assert {object}");
}
}
async fn active_triples(app: &axum::Router, name: &str) -> Vec<(String, String, String)> {
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/palaces/{name}/kg/all?limit=50&offset=0"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 16_384).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
v.as_array()
.expect("triples must be array")
.iter()
.map(|t| {
(
t["subject"].as_str().unwrap_or_default().to_string(),
t["predicate"].as_str().unwrap_or_default().to_string(),
t["object"].as_str().unwrap_or_default().to_string(),
)
})
.collect()
}
#[tokio::test]
async fn kg_delete_triple_closes_one_object_and_keeps_siblings() {
use super::super::kg_routes::encode_triple_id;
let state = test_state();
let app = router().with_state(state);
seed_pair(&app, "kg-del", "alpha", "is", &["thing-a", "thing-b"]).await;
let triple_id = encode_triple_id("alpha", "is", "thing-a").expect("encode triple id");
let resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/v1/palaces/kg-del/kg/triples/{triple_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert_eq!(
active_triples(&app, "kg-del").await,
vec![("alpha".to_string(), "is".to_string(), "thing-b".to_string())],
"deleting one object must leave its sibling at the same pair active"
);
let sibling_id = encode_triple_id("alpha", "is", "thing-b").expect("encode triple id");
let resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/v1/palaces/kg-del/kg/triples/{sibling_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert!(active_triples(&app, "kg-del").await.is_empty());
}
#[tokio::test]
async fn kg_delete_triple_returns_404_for_missing() {
use super::super::kg_routes::encode_triple_id;
let state = test_state();
let app = router().with_state(state);
seed_pair(&app, "kg-miss", "alpha", "is", &["thing-a"]).await;
let wrong_object = encode_triple_id("alpha", "is", "thing-zzz").expect("encode triple id");
let resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/v1/palaces/kg-miss/kg/triples/{wrong_object}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("thing-zzz"),
"the 404 must name the object it looked for; got {body}"
);
assert_eq!(
active_triples(&app, "kg-miss").await.len(),
1,
"a miss must not close the sibling that does exist"
);
let real = encode_triple_id("alpha", "is", "thing-a").expect("encode triple id");
for expected in [StatusCode::NO_CONTENT, StatusCode::NOT_FOUND] {
let resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/v1/palaces/kg-miss/kg/triples/{real}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), expected, "repeat delete must be idempotent");
}
}
#[tokio::test]
async fn kg_delete_triple_rejects_a_legacy_pair_id() {
use base64::Engine as _;
let state = test_state();
let app = router().with_state(state);
seed_pair(&app, "kg-legacy", "alpha", "is", &["thing-a", "thing-b"]).await;
let legacy_id = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"alpha\0is");
let resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/v1/palaces/kg-legacy/kg/triples/{legacy_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("object"),
"the 400 must say the id needs an object; got {body}"
);
assert_eq!(
active_triples(&app, "kg-legacy").await.len(),
2,
"a rejected legacy id must close nothing"
);
}
async fn prompt_context(app: &axum::Router) -> String {
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/kg/prompt-context")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), 16_384).await.unwrap();
String::from_utf8(bytes.to_vec()).expect("prompt context is utf-8")
}
#[tokio::test]
async fn kg_delete_triple_rebuilds_prompt_cache_for_hot_predicate() {
use super::super::kg_routes::encode_triple_id;
let state = test_state();
let app = router().with_state(state);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces")
.header("content-type", "application/json")
.body(Body::from(json!({ "name": "kg-hot" }).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "create palace kg-hot");
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/kg/aliases")
.header("content-type", "application/json")
.body(Body::from(
json!({
"palace": "kg-hot",
"short": "tmhot",
"full": "trusty-memory-hot-fact",
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "seed the hot alias");
let before = prompt_context(&app).await;
assert!(
before.contains("tmhot → trusty-memory-hot-fact"),
"the hot fact must be cached before the delete; got: {before}"
);
let triple_id = encode_triple_id("tmhot", "is_alias_for", "trusty-memory-hot-fact")
.expect("encode triple id");
let resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/v1/palaces/kg-hot/kg/triples/{triple_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let after = prompt_context(&app).await;
assert!(
!after.contains("trusty-memory-hot-fact"),
"a retracted hot fact must not still be injected into the prompt; got: {after}"
);
}