use serde_json::json;
use crate::service::{
CreateDrawerBody, CreatePalaceBody, ListDrawersQuery, MemoryService, ServiceError,
};
use crate::{ActivitySource, AppState};
fn test_state() -> AppState {
trusty_common::memory_core::retrieval::seed_shared_embedder_with_mock();
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
unsafe {
std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
}
let state = AppState::new(root);
state.set_ready();
state
}
fn service() -> (MemoryService, AppState) {
let state = test_state();
(MemoryService::new(state.clone()), state)
}
fn palace_body(name: &str) -> CreatePalaceBody {
CreatePalaceBody {
name: name.to_string(),
description: None,
cwd: None,
force: false,
}
}
fn drawer_body(content: &str) -> CreateDrawerBody {
CreateDrawerBody {
content: content.to_string(),
room: None,
tags: Vec::new(),
importance: None,
force: None,
}
}
fn default_creator() -> crate::attribution::CreatorInfo {
crate::transport::methods::CallerParams::default().creator()
}
#[tokio::test]
async fn status_endpoint_returns_payload() {
let (svc, _state) = service();
let payload = serde_json::to_value(svc.status().await).expect("status serialises");
assert!(payload["version"].is_string());
assert_eq!(payload["palace_count"], 0);
}
#[tokio::test]
async fn status_includes_total_counters() {
let (svc, _state) = service();
let payload = serde_json::to_value(svc.status().await).expect("status serialises");
assert_eq!(payload["total_drawers"], 0);
assert_eq!(payload["total_vectors"], 0);
assert_eq!(payload["total_kg_triples"], 0);
}
#[tokio::test]
async fn create_then_list_palace() {
let (svc, _state) = service();
svc.create_palace(palace_body("web-test"), ActivitySource::Http)
.await
.expect("create");
let palaces = svc.list_palaces().await.expect("list");
assert!(palaces.iter().any(|p| p.id == "web-test"));
}
#[tokio::test]
async fn palace_list_includes_graph_counts() {
let (svc, _state) = service();
svc.create_palace(palace_body("graph-counts"), ActivitySource::Http)
.await
.expect("create");
let listed = serde_json::to_value(svc.list_palaces().await.expect("list"))
.expect("palace list serialises");
let row = listed
.as_array()
.expect("array")
.iter()
.find(|p| p["id"] == "graph-counts")
.expect("created palace must appear in the list")
.clone();
assert_eq!(row["node_count"].as_u64(), Some(0));
assert_eq!(row["edge_count"].as_u64(), Some(0));
assert_eq!(row["community_count"].as_u64(), Some(0));
assert_eq!(row["is_compacting"].as_bool(), Some(false));
}
#[tokio::test]
async fn delete_palace_removes_dir_when_empty() {
let (svc, state) = service();
svc.create_palace(palace_body("to-delete"), ActivitySource::Http)
.await
.expect("create");
svc.delete_palace("to-delete", false)
.await
.expect("an empty palace deletes without force");
assert!(
matches!(
svc.get_palace("to-delete").await,
Err(ServiceError::NotFound(_))
),
"a deleted palace must stop resolving"
);
let palace_dir = state.data_root.join("to-delete");
assert!(
!palace_dir.exists(),
"palace dir should be removed: {}",
palace_dir.display()
);
}
#[tokio::test]
async fn delete_palace_refuses_when_drawers_present() {
let (svc, _state) = service();
svc.create_palace(palace_body("keep-me"), ActivitySource::Http)
.await
.expect("create");
svc.create_drawer(
"keep-me",
drawer_body("Important fact that should not be deleted accidentally."),
default_creator(),
ActivitySource::Http,
)
.await
.expect("seed a drawer so the conflict guard fires");
assert!(
matches!(
svc.delete_palace("keep-me", false).await,
Err(ServiceError::Conflict(_))
),
"a populated palace must be refused without force"
);
svc.get_palace("keep-me")
.await
.expect("a refused delete must leave the palace resolvable");
}
#[tokio::test]
async fn delete_palace_refuses_while_legacy_kg_holds_unimported_drawers() {
use crate::commands::legacy_kg::tests::write_legacy_kg;
let (svc, state) = service();
for (name, quarantine_only, with_live) in [
("stranded-kg", false, false),
("stranded-v2", true, false),
("stranded-kg-live", false, true),
] {
svc.create_palace(palace_body(name), ActivitySource::Http)
.await
.expect("create");
if with_live {
svc.create_drawer(
name,
drawer_body("A live fact beside the legacy kg.db."),
default_creator(),
ActivitySource::Http,
)
.await
.expect("seed a live drawer");
}
let dir = state.data_root.join(name);
let marker = if quarantine_only {
std::fs::write(dir.join("kg.redb.v2-incompatible"), b"redb2").expect("write");
dir.join("kg.redb.v2-incompatible")
} else {
write_legacy_kg(
&dir,
&[(
"0b0f5d7e-1d7b-4c35-9a52-4f3f7f2f0a09",
"a fact only kg.db holds",
"2026-04-01T09:00:00Z",
)],
);
dir.join("kg.db")
};
match svc.delete_palace(name, false).await {
Err(ServiceError::Conflict(msg)) => {
assert!(msg.contains("Palace holds legacy data"), "{name}: {msg}");
assert!(
!msg.contains("force"),
"{name}: must not steer to force: {msg}"
);
}
other => panic!("{name}: expected a legacy-data conflict, got {other:?}"),
}
assert!(
marker.exists(),
"{name}: a refused delete removed legacy data"
);
svc.delete_palace(name, true)
.await
.expect("force still deletes");
assert!(!dir.exists());
}
}
#[tokio::test]
async fn delete_palace_refuses_when_open_fails_or_load_is_degraded() {
use trusty_common::memory_core::store::kg_redb::KgStoreRedb;
use trusty_common::memory_core::store::kg_store::DRAWERS;
use trusty_common::memory_core::store::PalaceStore;
use trusty_common::memory_core::{Palace, PalaceId};
let (svc, state) = service();
for (name, degraded) in [("unopenable", false), ("degraded", true)] {
let dir = state.data_root.join(name);
std::fs::create_dir_all(&dir).expect("mkdir");
PalaceStore::save_palace(&Palace {
id: PalaceId::new(name),
name: name.into(),
description: None,
created_at: chrono::Utc::now(),
data_dir: dir.clone(),
})
.expect("save palace");
if degraded {
drop(KgStoreRedb::open(&dir.join("kg.redb")).expect("init kg.redb"));
let db = redb::Database::create(dir.join("kg.redb")).expect("open kg.redb");
let wtx = db.begin_write().expect("begin write");
wtx.open_table(DRAWERS)
.expect("drawers table")
.insert(
uuid::Uuid::new_v4().as_bytes().as_slice(),
[0xFF_u8; 4].as_slice(),
)
.expect("insert corrupt row");
wtx.commit().expect("commit");
} else {
std::fs::create_dir_all(dir.join("identity.txt")).expect("mkdir");
}
let expected = if degraded {
"drawer table loaded degraded"
} else {
"could not be opened"
};
match svc.delete_palace(name, false).await {
Err(ServiceError::Conflict(msg)) => assert!(msg.contains(expected), "{name}: {msg}"),
other => panic!("{name}: expected a conflict, got {other:?}"),
}
assert!(dir.exists(), "{name}: a refused delete removed the palace");
svc.delete_palace(name, true)
.await
.expect("force still deletes");
assert!(!dir.exists());
}
}
#[tokio::test]
async fn delete_palace_force_removes_populated_palace() {
let (svc, _state) = service();
svc.create_palace(palace_body("force-delete"), ActivitySource::Http)
.await
.expect("create");
svc.create_drawer(
"force-delete",
drawer_body("Sacrificial drawer for the force-delete path."),
default_creator(),
ActivitySource::Http,
)
.await
.expect("seed a drawer");
svc.delete_palace("force-delete", true)
.await
.expect("force must override the conflict guard");
assert!(
matches!(
svc.get_palace("force-delete").await,
Err(ServiceError::NotFound(_))
),
"a force-deleted palace must stop resolving"
);
}
#[tokio::test]
async fn delete_palace_returns_not_found_for_missing_id() {
let (svc, _state) = service();
assert!(matches!(
svc.delete_palace("never-existed", false).await,
Err(ServiceError::NotFound(_))
));
}
#[tokio::test]
async fn update_palace_name_renames_palace() {
let (svc, _state) = service();
svc.create_palace(palace_body("rename-me"), ActivitySource::Http)
.await
.expect("create");
let updated = svc
.update_palace_name_typed("rename-me", "New Display Name")
.await
.expect("rename");
assert_eq!(updated["id"].as_str(), Some("rename-me"));
assert_eq!(updated["name"].as_str(), Some("New Display Name"));
let reread = svc.get_palace("rename-me").await.expect("get");
assert_eq!(reread.id, "rename-me");
assert_eq!(reread.name, "New Display Name");
}
#[tokio::test]
async fn update_palace_name_rejects_empty_name() {
let (svc, _state) = service();
svc.create_palace(palace_body("keep-name"), ActivitySource::Http)
.await
.expect("create");
assert!(matches!(
svc.update_palace_name_typed("keep-name", " ").await,
Err(ServiceError::BadRequest(_))
));
}
#[tokio::test]
async fn update_palace_name_returns_not_found_for_missing_id() {
let (svc, _state) = service();
assert!(matches!(
svc.update_palace_name_typed("no-such-palace", "irrelevant")
.await,
Err(ServiceError::NotFound(_))
));
}
#[cfg(unix)]
#[tokio::test]
async fn update_palace_name_reports_an_unstattable_palace_as_internal() {
let (svc, state) = service();
svc.create_palace(palace_body("locked-palace"), ActivitySource::Http)
.await
.expect("create");
let palace_dir = state.data_root.join("locked-palace");
let _restore = deny_statting_metadata(&palace_dir);
match svc
.update_palace_name_typed("locked-palace", "New Display Name")
.await
{
Err(ServiceError::NotFound(m)) => panic!(
"a palace whose metadata cannot be stat'd was reported as absent — that is the \
#5549 coercion re-created at the caller: {m}"
),
Err(ServiceError::Internal(_)) => {}
other => panic!("expected Internal, got {other:?}"),
}
}
#[tokio::test]
async fn delete_drawer_404s_for_an_unknown_drawer_id() {
let (svc, _state) = service();
svc.create_palace(palace_body("ghost-drawer"), ActivitySource::Http)
.await
.expect("create");
assert!(
matches!(
svc.delete_drawer(
"ghost-drawer",
"deadbeef-0000-4000-8000-000000000000",
ActivitySource::Http,
)
.await,
Err(ServiceError::NotFound(_))
),
"an unknown drawer id must be refused, not silently succeed"
);
}
#[tokio::test]
async fn create_drawer_rejects_json_content_without_force() {
let (svc, _state) = service();
svc.create_palace(palace_body("force-gate-reject"), ActivitySource::Http)
.await
.expect("create");
let result = svc
.create_drawer(
"force-gate-reject",
drawer_body(r#"{"score":0.42,"id":"a1b2","tier":3}"#),
default_creator(),
ActivitySource::Http,
)
.await;
assert!(
result.is_err(),
"JSON-shaped content without `force` must still be rejected by the quality gate"
);
}
#[tokio::test]
async fn create_drawer_force_bypasses_quality_gate_for_json_content() {
let (svc, _state) = service();
svc.create_palace(palace_body("force-gate-accept"), ActivitySource::Http)
.await
.expect("create");
let mut body = drawer_body(r#"{"score":0.42,"id":"a1b2","tier":3}"#);
body.force = Some(true);
svc.create_drawer(
"force-gate-accept",
body,
default_creator(),
ActivitySource::Http,
)
.await
.expect("`force: true` must bypass the quality gate for JSON-shaped content");
}
#[tokio::test]
async fn http_create_drawer_runs_auto_kg_extraction() {
let (svc, _state) = service();
svc.create_palace(palace_body("kgauto-http"), ActivitySource::Http)
.await
.expect("create");
let body = CreateDrawerBody {
content: "trusty-memory is a Rust crate that ships an MCP server. \
It tracks #mcp and #rust topics with care."
.to_string(),
room: Some("Backend".to_string()),
tags: vec!["backend".to_string(), "kg".to_string()],
importance: Some(0.5),
force: None,
};
svc.create_drawer("kgauto-http", body, default_creator(), ActivitySource::Http)
.await
.expect("create_drawer");
let graph = svc.kg_graph("kgauto-http").await.expect("kg_graph");
assert!(
!graph.triples.is_empty(),
"a drawer written through this path must populate the KG; got an empty graph"
);
let auto: Vec<_> = graph
.triples
.iter()
.filter(|t| t.provenance.as_deref() == Some(crate::kg_extract::AUTO_PROVENANCE))
.collect();
assert!(
!auto.is_empty(),
"expected at least one auto-extracted triple; got: {:?}",
graph.triples
);
assert!(
auto.iter().any(|t| t.subject == "tag:backend"),
"expected a `tag:backend` auto-extracted edge, got: {auto:?}"
);
assert!(
auto.iter().any(|t| t.predicate == "mentioned-in"),
"expected at least one #hashtag mention triple, got: {auto:?}"
);
}
#[cfg(unix)]
struct RestoreMode {
path: std::path::PathBuf,
mode: u32,
}
#[cfg(unix)]
impl Drop for RestoreMode {
fn drop(&mut self) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(self.mode));
}
}
#[cfg(unix)]
fn deny_reading_metadata(palace_dir: &std::path::Path) -> RestoreMode {
use std::os::unix::fs::PermissionsExt;
let target = palace_dir.join("palace.json");
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o000)).unwrap();
let restore = RestoreMode {
path: target.clone(),
mode: 0o600,
};
match std::fs::read(&target) {
Ok(_) => panic!(
"cannot exercise #5549: {} is still readable at mode 000. Run this suite as a \
non-root user on a filesystem that honours POSIX permission bits.",
target.display()
),
Err(e) => assert_eq!(
e.kind(),
std::io::ErrorKind::PermissionDenied,
"expected the locked palace.json to deny reads, got {e}"
),
}
restore
}
#[cfg(unix)]
fn deny_statting_metadata(palace_dir: &std::path::Path) -> RestoreMode {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(palace_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
let restore = RestoreMode {
path: palace_dir.to_path_buf(),
mode: 0o700,
};
let target = palace_dir.join("palace.json");
match target.try_exists() {
Ok(_) => panic!(
"cannot exercise #5549: {} is still stattable with its directory at mode 000. Run \
this suite as a non-root user on a filesystem that honours POSIX permission bits.",
target.display()
),
Err(e) => assert_eq!(
e.kind(),
std::io::ErrorKind::PermissionDenied,
"expected the locked directory to deny statting palace.json, got {e}"
),
}
restore
}
#[cfg(unix)]
async fn create_and_evict(svc: &MemoryService, state: &AppState, id: &str) -> std::path::PathBuf {
svc.create_palace(palace_body(id), ActivitySource::Http)
.await
.expect("palace create must succeed");
state
.registry
.remove(&trusty_common::memory_core::PalaceId::new(id));
state.data_root.join(id)
}
#[cfg(unix)]
#[tokio::test]
async fn unreadable_palace_is_500_not_404_at_the_service_open_handle() {
let (svc, state) = service();
let dir = create_and_evict(&svc, &state, "unreadable-svc").await;
let _restore = deny_reading_metadata(&dir);
match svc
.list_drawers("unreadable-svc", ListDrawersQuery::default())
.await
{
Err(ServiceError::NotFound(m)) => panic!(
"a palace whose metadata cannot be read was reported as absent — that is the \
#5549 coercion at MemoryService::open_handle: {m}"
),
Err(ServiceError::Internal(_)) => {}
other => panic!("expected Internal, got {other:?}"),
}
}
#[cfg(unix)]
#[tokio::test]
async fn unstattable_palace_is_500_not_404_at_the_service_open_handle() {
let (svc, state) = service();
let dir = create_and_evict(&svc, &state, "unstattable-svc").await;
let _restore = deny_statting_metadata(&dir);
match svc
.list_drawers("unstattable-svc", ListDrawersQuery::default())
.await
{
Err(ServiceError::NotFound(m)) => panic!(
"a palace whose metadata could not even be statted was reported as absent — #5574 \
made that an Io error at load_palace, and open_handle flattened it back: {m}"
),
Err(ServiceError::Internal(_)) => {}
other => panic!("expected Internal, got {other:?}"),
}
}
#[tokio::test]
async fn absent_palace_is_still_404_at_both_open_handles() {
let (svc, state) = service();
assert!(
matches!(
svc.open_handle("never-created"),
Err(ServiceError::NotFound(_))
),
"an absent palace must still be NotFound at MemoryService::open_handle"
);
let via_api = crate::transport::api_error::open_handle(&state, "never-created")
.err()
.expect("an absent palace cannot be opened");
assert_eq!(
via_api.kind,
crate::transport::ErrorKind::NotFound,
"an absent palace must still be NotFound at transport::api_error::open_handle: {}",
via_api.message
);
}
#[tokio::test]
async fn dream_status_empty_returns_nulls() {
let (svc, _state) = service();
let payload =
serde_json::to_value(svc.dream_status_aggregate().await).expect("dream status serialises");
assert!(payload["last_run_at"].is_null());
assert_eq!(payload["merged"], 0);
assert_eq!(payload["pruned"], 0);
}
#[tokio::test]
async fn drawers_list_reads_through_the_folded_method() {
let (svc, state) = service();
svc.create_palace(palace_body("folded-list"), ActivitySource::Http)
.await
.expect("create");
let listed = crate::transport::methods::palaces::list_drawers(
&state,
serde_json::from_value(json!({ "palace_id": "folded-list" })).expect("params decode"),
)
.await
.expect("the folded method must read the same palace the service wrote");
assert!(listed.is_array(), "drawers_list answers an array: {listed}");
}
#[tokio::test]
async fn list_palaces_with_counts_opens_palaces_off_the_executor() {
use std::collections::BTreeSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
const PALACES: usize = 12;
let (svc, state) = service();
for i in 0..PALACES {
svc.create_palace(
palace_body(&format!("sweep-exec-{i}")),
ActivitySource::Http,
)
.await
.expect("create palace");
}
let root = state.data_root.clone();
drop(svc);
drop(state);
let cold = AppState::new(root);
cold.set_ready();
assert!(
cold.registry.list().is_empty(),
"the fixture must start cold, or the handle count measures nothing"
);
let seen: Arc<Mutex<BTreeSet<usize>>> = Arc::new(Mutex::new(BTreeSet::new()));
let stop = Arc::new(AtomicBool::new(false));
let heartbeat = {
let seen = Arc::clone(&seen);
let stop = Arc::clone(&stop);
let registry = Arc::clone(&cold.registry);
tokio::spawn(async move {
while !stop.load(Ordering::Relaxed) {
seen.lock().expect("heartbeat lock").insert(registry.len());
tokio::task::yield_now().await;
}
})
};
let rows = MemoryService::new(cold.clone())
.list_palaces_with_counts()
.await
.expect("list_palaces_with_counts");
stop.store(true, Ordering::Relaxed);
heartbeat.await.expect("heartbeat task");
assert_eq!(rows.len(), PALACES, "every palace must get a row: {rows:?}");
assert!(
rows.iter().all(|(_, row)| row.is_ok()),
"every palace here is readable: {rows:?}"
);
let seen = seen.lock().expect("seen lock");
let partial: Vec<usize> = seen
.iter()
.copied()
.filter(|n| *n > 0 && *n < PALACES)
.collect();
assert!(
!partial.is_empty(),
"a concurrent task never saw the sweep part-way through, so the opens \
ran on the executor thread and nothing else could make progress \
(#6836). Handle counts observed: {seen:?}"
);
}