use super::super::router;
use super::test_state;
use crate::AppState;
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 prompt_context_endpoint_returns_formatted_block() {
let state = test_state();
let app = router().with_state(state.clone());
let resp = app
.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(), 4096).await.unwrap();
let text = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(text, "No prompt facts stored yet.");
{
let mut guard = state.prompt_context_cache.write().await;
let triples = vec![(
"tga".to_string(),
"is_alias_for".to_string(),
"trusty-git-analytics".to_string(),
)];
let formatted = crate::prompt_facts::build_prompt_context(&triples);
*guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
}
let app = router().with_state(state);
let resp = app
.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(), 4096).await.unwrap();
let text = String::from_utf8(bytes.to_vec()).unwrap();
assert!(text.contains("tga → trusty-git-analytics"), "got: {text}");
}
#[tokio::test]
async fn add_alias_endpoint_asserts_triple_and_refreshes_cache() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
let state = AppState::new(root).with_default_palace(Some("aliases".to_string()));
let palace = trusty_common::memory_core::Palace {
id: PalaceId::new("aliases"),
name: "aliases".to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join("aliases"),
};
state
.registry
.create_palace(&state.data_root, palace)
.expect("create palace");
let body = json!({"short": "tm", "full": "trusty-memory"});
let app = router().with_state(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/kg/aliases")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.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["subject"], "tm");
assert_eq!(v["object"], "trusty-memory");
let guard = state.prompt_context_cache.read().await;
assert!(
guard.formatted.contains("tm → trusty-memory"),
"cache missing alias; got: {}",
guard.formatted
);
}
#[tokio::test]
async fn list_prompt_facts_endpoint_returns_hot_triples() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
let state = AppState::new(root).with_default_palace(Some("listfacts".to_string()));
let palace = trusty_common::memory_core::Palace {
id: PalaceId::new("listfacts"),
name: "listfacts".to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join("listfacts"),
};
let handle = state
.registry
.create_palace(&state.data_root, palace)
.expect("create palace");
handle
.kg
.assert(Triple {
subject: "ts".to_string(),
predicate: "is_alias_for".to_string(),
object: "trusty-search".to_string(),
valid_from: chrono::Utc::now(),
valid_to: None,
confidence: 1.0,
provenance: None,
})
.await
.expect("assert alias");
handle
.kg
.assert(Triple {
subject: "alice".to_string(),
predicate: "works_at".to_string(),
object: "Acme".to_string(),
valid_from: chrono::Utc::now(),
valid_to: None,
confidence: 1.0,
provenance: None,
})
.await
.expect("assert works_at");
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/kg/prompt-facts")
.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!(
arr.iter().any(|r| r["subject"] == "ts"
&& r["predicate"] == "is_alias_for"
&& r["object"] == "trusty-search"),
"missing ts alias; got {arr:?}"
);
assert!(
!arr.iter().any(|r| r["predicate"] == "works_at"),
"non-hot triple leaked into prompt facts: {arr:?}"
);
let facts: Vec<crate::prompt_facts::TierSFact> =
serde_json::from_slice(&bytes).expect("body must decode as the doctor decodes it");
let alias = facts
.iter()
.find(|f| f.subject == "ts")
.expect("ts alias present");
assert!(
(chrono::Utc::now() - alias.affirmed_at).num_seconds() < 60,
"affirmed_at must be the write time, got {}",
alias.affirmed_at,
);
}
#[tokio::test]
async fn remove_prompt_fact_endpoint_soft_deletes_and_refreshes_cache() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
let state = AppState::new(root).with_default_palace(Some("rmfacts".to_string()));
let palace = trusty_common::memory_core::Palace {
id: PalaceId::new("rmfacts"),
name: "rmfacts".to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join("rmfacts"),
};
let handle = state
.registry
.create_palace(&state.data_root, palace)
.expect("create palace");
handle
.kg
.assert(Triple {
subject: "ta".to_string(),
predicate: "is_alias_for".to_string(),
object: "trusty-analyze".to_string(),
valid_from: chrono::Utc::now(),
valid_to: None,
confidence: 1.0,
provenance: None,
})
.await
.expect("assert alias");
crate::prompt_facts::rebuild_prompt_cache(&state)
.await
.expect("rebuild prompt cache");
let app = router().with_state(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/v1/kg/prompt-facts?subject=ta&predicate=is_alias_for")
.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["removed"], true);
assert!(v["closed"].as_u64().unwrap_or(0) >= 1);
{
let guard = state.prompt_context_cache.read().await;
assert!(
!guard.formatted.contains("ta → trusty-analyze"),
"alias still in cache after delete: {}",
guard.formatted
);
}
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/v1/kg/prompt-facts?subject=nope&predicate=is_alias_for")
.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["removed"], false);
}
async fn state_with_full_tier_s(name: &str) -> AppState {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
let state = AppState::new(root).with_default_palace(Some(name.to_string()));
let palace = trusty_common::memory_core::Palace {
id: PalaceId::new(name),
name: name.to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join(name),
};
let handle = state
.registry
.create_palace(&state.data_root, palace)
.expect("create palace");
for i in 0..crate::prompt_facts::TIER_S_MAX_FACTS {
handle
.kg
.assert(Triple {
subject: format!("rule-{i}"),
predicate: "has_convention".to_string(),
object: format!("standing rule number {i}"),
valid_from: chrono::Utc::now(),
valid_to: None,
confidence: 1.0,
provenance: None,
})
.await
.expect("seed convention");
}
state
}
#[tokio::test]
async fn add_alias_endpoint_enforces_tier_s_cap() {
let state = state_with_full_tier_s("httpcap").await;
let body = json!({"short": "tga", "full": "trusty-git-analytics"});
let app = router().with_state(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/kg/aliases")
.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 bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
let text = String::from_utf8(bytes.to_vec()).unwrap();
assert!(text.contains("Tier S is full"), "got: {text}");
assert!(text.contains("remove_prompt_fact"), "got: {text}");
let facts = crate::prompt_facts::gather_hot_triples(&state)
.await
.expect("gather");
assert_eq!(facts.len(), crate::prompt_facts::TIER_S_MAX_FACTS);
assert!(!facts.iter().any(|(s, _, _)| s == "tga"), "{facts:?}");
}
#[tokio::test]
async fn kg_assert_endpoint_enforces_tier_s_form_constraint() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
let state = AppState::new(root);
let palace = trusty_common::memory_core::Palace {
id: PalaceId::new("httpform"),
name: "httpform".to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join("httpform"),
};
state
.registry
.create_palace(&state.data_root, palace)
.expect("create palace");
let body = json!({
"subject": "trusty-mpm",
"predicate": "is_fact",
"object": "z".repeat(crate::prompt_facts::TIER_S_MAX_OBJECT_CHARS + 1),
});
let app = router().with_state(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces/httpform/kg")
.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 bytes = to_bytes(resp.into_body(), 8192).await.unwrap();
let text = String::from_utf8(bytes.to_vec()).unwrap();
assert!(text.contains("81 characters"), "got: {text}");
assert!(text.contains("limit is 80"), "got: {text}");
let facts = crate::prompt_facts::gather_hot_triples(&state)
.await
.expect("gather");
assert!(facts.is_empty(), "rejected write must not land: {facts:?}");
}
#[tokio::test]
async fn kg_assert_endpoint_allows_cold_predicates_at_cap() {
let state = state_with_full_tier_s("httpcold").await;
let body = json!({
"subject": "alice",
"predicate": "works_at",
"object": "z".repeat(crate::prompt_facts::TIER_S_MAX_OBJECT_CHARS + 40),
});
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/palaces/httpcold/kg")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}