use super::super::kg_routes::{decode_triple_id, encode_triple_id};
use super::super::recall_routes::recall_entry_json;
use super::super::router;
use super::test_state;
use crate::service::{drawer_content_preview, DRAWER_PREVIEW_MAX_CHARS};
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::{Palace, PalaceId};
use trusty_common::memory_core::retrieval::RecallResult;
use uuid::Uuid;
#[test]
fn decode_triple_id_round_trips() {
let cases = [
("drawer:some-uuid", "has_tag"),
("entity:alice", "works_at"),
("entity:project/foo", "depends_on"),
("subject", ""),
("path/to/node", "rel:type:sub"),
];
for (subject, predicate) in cases {
let encoded = encode_triple_id(subject, predicate)
.unwrap_or_else(|e| panic!("encode_triple_id failed: {e}"));
assert!(
!encoded.contains('+') && !encoded.contains('/') && !encoded.contains('='),
"encoded triple id {encoded:?} is not URL-safe"
);
let (s, p) = decode_triple_id(&encoded)
.unwrap_or_else(|| panic!("decode_triple_id failed for {encoded:?}"));
assert_eq!(s, subject, "subject mismatch for ({subject}, {predicate})");
assert_eq!(
p, predicate,
"predicate mismatch for ({subject}, {predicate})"
);
}
}
#[test]
fn decode_triple_id_returns_none_for_invalid_input() {
assert!(decode_triple_id("not!!valid%%base64").is_none());
use base64::Engine as _;
let no_sep = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"no-separator");
assert!(decode_triple_id(&no_sep).is_none());
}
#[test]
fn encode_triple_id_rejects_null_byte() {
let err = encode_triple_id("sub\0ject", "predicate")
.expect_err("null byte in subject must return Err");
assert!(
err.contains("null-byte") || err.contains("\\0"),
"error message should mention null-byte separator; got {err:?}"
);
let err = encode_triple_id("subject", "pred\0icate")
.expect_err("null byte in predicate must return Err");
assert!(
err.contains("null-byte") || err.contains("\\0"),
"error message should mention null-byte separator; got {err:?}"
);
assert!(
encode_triple_id("clean-subject", "clean-predicate").is_ok(),
"clean inputs must encode successfully"
);
}
#[test]
fn extract_partial_error_string_array_returns_first_error() {
use super::super::recall_routes::extract_partial_error;
let v = json!({
"errors": ["palace alpha timed out", "palace beta unreachable"],
"results": []
});
let msg = extract_partial_error(&v);
assert!(msg.is_some(), "non-empty string errors must yield Some");
let msg = msg.unwrap();
assert!(
msg.contains("palace alpha timed out"),
"message must include the first error string; got {msg:?}"
);
assert!(
msg.contains("2 error(s)"),
"message must include the error count; got {msg:?}"
);
}
#[test]
fn extract_partial_error_object_array_extracts_message_field() {
use super::super::recall_routes::extract_partial_error;
let v = json!({
"errors": [
{"message": "KG query failed", "code": 503},
{"message": "BM25 timeout"}
]
});
let msg = extract_partial_error(&v);
assert!(msg.is_some(), "object errors must yield Some");
let msg = msg.unwrap();
assert!(
msg.contains("KG query failed"),
"message must include the first object's message; got {msg:?}"
);
assert!(
msg.contains("2 error(s)"),
"message must include the error count; got {msg:?}"
);
}
#[test]
fn extract_partial_error_empty_array_returns_none() {
use super::super::recall_routes::extract_partial_error;
let v = json!({ "errors": [], "results": [{"content": "ok"}] });
assert!(
extract_partial_error(&v).is_none(),
"empty errors array must return None"
);
}
#[test]
fn extract_partial_error_no_errors_key_returns_none() {
use super::super::recall_routes::extract_partial_error;
let v = json!([{"content": "hello", "score": 0.9}]);
assert!(
extract_partial_error(&v).is_none(),
"JSON array input must return None"
);
let v = json!({ "results": [{"content": "world"}] });
assert!(
extract_partial_error(&v).is_none(),
"object without errors key must return None"
);
}
#[tokio::test]
async fn recall_all_handler_honors_palace_filter() {
let state = test_state();
let palace = Palace {
id: PalaceId::new("filter-target"),
name: "filter-target".to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join("filter-target"),
};
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/recall?q=anything&palace=filter-target")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"recall with valid palace= must return 200"
);
let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v.is_array(),
"recall with palace= must return a JSON array (per-palace shape); got {v}"
);
}
#[tokio::test]
async fn recall_all_handler_palace_filter_missing_palace_returns_404() {
let state = test_state();
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/recall?q=anything&palace=nonexistent-palace")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"recall with palace= pointing to missing palace must return 404"
);
}
#[tokio::test]
async fn recall_all_handler_fans_out_without_palace_param() {
let state = test_state();
let app = router().with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/recall?q=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"cross-palace recall with no palace= must return 200"
);
let bytes = to_bytes(resp.into_body(), 4096).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v.is_array(),
"cross-palace recall must return a JSON array; got {v}"
);
}
#[tokio::test]
async fn remember_async_rejects_short_content() {
let state = test_state();
let app = router().with_state(state);
for body in [
json!({"content": "hi"}),
json!({"content": "two words"}),
json!({"content": "three word content"}),
] {
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/remember")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNPROCESSABLE_ENTITY,
"short content must return 422; body={body}"
);
}
}
#[tokio::test]
async fn remember_async_accepts_content_at_min_words() {
let state = test_state();
let palace = Palace {
id: PalaceId::new("min-words-test"),
name: "min-words-test".to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join("min-words-test"),
};
state
.registry
.create_palace(&state.data_root, palace)
.expect("create_palace");
let app = router().with_state(state);
let body = json!({
"content": "four words exactly here",
"palace": "min-words-test",
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/remember")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::ACCEPTED,
"content at minimum word count must return 202"
);
let bytes = to_bytes(resp.into_body(), 512).await.unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
v["status"], "queued",
"accepted body must carry status=queued"
);
}
#[test]
fn drawer_preview_collapses_whitespace_and_truncates() {
assert_eq!(drawer_content_preview("hello world"), "hello world");
assert_eq!(
drawer_content_preview("first line\n\nsecond\tline third"),
"first line second line third"
);
assert_eq!(drawer_content_preview(" padded "), "padded");
assert_eq!(drawer_content_preview(""), "");
let long = "x".repeat(DRAWER_PREVIEW_MAX_CHARS + 50);
let preview = drawer_content_preview(&long);
assert_eq!(preview.chars().count(), DRAWER_PREVIEW_MAX_CHARS);
assert!(preview.ends_with('…'));
let exact = "y".repeat(DRAWER_PREVIEW_MAX_CHARS);
assert_eq!(drawer_content_preview(&exact), exact);
}
#[test]
fn recall_entry_json_hoists_drawer_fields() {
use trusty_common::memory_core::Drawer;
let room = Uuid::new_v4();
let mut drawer = Drawer::new(room, "the answer is 42");
drawer.tags = vec!["source:kuzu".to_string()];
drawer.importance = 0.7;
let entry = recall_entry_json(RecallResult {
drawer,
score: 0.699,
layer: 1,
});
assert_eq!(
entry.get("content").and_then(|v| v.as_str()),
Some("the answer is 42"),
"content must be at the top level, got {entry:?}"
);
assert!(
entry.get("drawer").is_none(),
"the legacy `drawer` wrapper must not be present, got {entry:?}"
);
assert_eq!(
entry["importance"].as_f64().map(|f| (f * 10.0).round()),
Some(7.0)
);
assert_eq!(
entry["tags"][0].as_str(),
Some("source:kuzu"),
"tags must be hoisted, got {entry:?}"
);
assert_eq!(entry["layer"].as_u64(), Some(1));
assert!(
entry["score"]
.as_f64()
.is_some_and(|s| (s - 0.699).abs() < 1e-6),
"score must be preserved, got {entry:?}"
);
}