use crate::config::Config;
use crate::mcp::store_wrapper::StoreWrapper;
use crate::mcp::tools::ToolHandler;
use crate::memory::lifecycle::{MemoryImportance, MemoryStatus, MemoryType};
use crate::memory::{MemoryStore, NewMemory, UpdateParams};
use crate::memory_types::IngestPolicy;
use std::sync::{Arc, Mutex};
use super::*;
#[test]
fn database_errors_carry_a_machine_readable_type_discriminator() {
let cases: Vec<(Error, &str)> = vec![
(Error::DatabaseLocked, "database_locked"),
(
Error::CorruptEmbedding {
id: "mem-1".to_string(),
reason: "not enough bytes".to_string(),
},
"corrupt_embedding",
),
(
Error::InvalidBlobSize {
expected: crate::embedding::EMBEDDING_DIMS * 4,
actual: 4,
},
"invalid_blob_size",
),
(
Error::MismatchedDimensions {
expected: 384,
actual: 768,
},
"mismatched_dimensions",
),
];
for (err, expected_type) in cases {
let data = rmcp::ErrorData::from(err)
.data
.unwrap_or_else(|| panic!("{expected_type} must carry a data payload"));
assert_eq!(
data["type"].as_str(),
Some(expected_type),
"expected the {expected_type} discriminator"
);
}
}
#[test]
fn the_corrupt_embedding_discriminator_carries_the_row_id() {
let err = Error::CorruptEmbedding {
id: "mem-42".to_string(),
reason: "invalid BLOB size".to_string(),
};
let data = rmcp::ErrorData::from(err).data.expect("data payload");
assert_eq!(data["id"].as_str(), Some("mem-42"));
assert_eq!(data["expected"], serde_json::Value::Null);
}
fn poison_store(store: &Arc<Mutex<MemoryStore>>) {
let poisoner = {
let store = store.clone();
std::thread::spawn(move || {
let _guard = store.lock().expect("fresh mutex must lock");
panic!("deliberate panic to poison the store mutex");
})
};
let joined = poisoner.join();
assert!(
joined.is_err(),
"the poisoner must have panicked, otherwise the mutex is not poisoned: {joined:?}"
);
assert!(store.is_poisoned(), "the fixture must leave it poisoned");
}
struct Poisoned {
_dir: tempfile::TempDir,
store: Arc<Mutex<MemoryStore>>,
wrapper: StoreWrapper,
}
fn poisoned_store() -> Poisoned {
let (dir, mut store) = MemoryStore::test_store();
let row = NewMemory {
project_id: "poison-proj",
content: "seeded before the poison",
metadata: None,
memory_type: MemoryType::Fact,
status: MemoryStatus::Active,
};
store
.ingest_with_importance(&row, IngestPolicy::Force, MemoryImportance::Medium)
.expect("seed before poisoning");
let store = Arc::new(Mutex::new(store));
let wrapper = StoreWrapper::new(store.clone());
poison_store(&store);
Poisoned {
_dir: dir,
store,
wrapper,
}
}
fn assert_poison_answer(err: &rmcp::ErrorData, what: &str) {
assert_eq!(
err.code,
rmcp::model::ErrorCode::INTERNAL_ERROR,
"{what} must answer INTERNAL_ERROR"
);
assert!(
err.message.contains("store lock poisoned"),
"{what} must say so, got {:?}",
err.message
);
let data = err
.data
.clone()
.unwrap_or_else(|| panic!("{what} must carry a data payload like every internal error"));
assert_eq!(
data["type"].as_str(),
Some("internal_error"),
"{what} must carry the internal_error discriminator"
);
}
fn options() -> crate::memory::SearchOptions<'static> {
crate::memory::SearchOptions {
memory_types: None,
statuses: None,
}
}
fn poison_row() -> NewMemory<'static> {
NewMemory {
project_id: "poison-proj",
content: "never written",
metadata: None,
memory_type: MemoryType::Fact,
status: MemoryStatus::Superseded,
}
}
const POISONED_ACQUISITIONS: [&str; 12] = [
"ingest",
"ingest_with_lifecycle",
"supersede_with_row",
"search_raw",
"search_hybrid_raw",
"search",
"search_hybrid",
"list",
"get",
"delete",
"update",
"touch_memories",
];
fn library_error_arms(w: &StoreWrapper) -> Vec<Result<usize, Error>> {
vec![
w.search_raw("poison-proj", "seeded", 5, 0.0, options())
.map(|memories| memories.len()),
w.search_hybrid_raw("poison-proj", "seeded", 5, 0.0, options())
.map(|memories| memories.len()),
w.search("poison-proj", "seeded", 5, 0.0, None, None)
.map(|value| value.to_string().len()),
w.search_hybrid("poison-proj", "seeded", 5, 0.0, None, None)
.map(|value| value.to_string().len()),
w.list("poison-proj", 10, None, None)
.map(|value| value.to_string().len()),
]
}
fn library_error_arms_with_names(w: &StoreWrapper) -> Vec<(&'static str, Result<usize, Error>)> {
[
"search_raw",
"search_hybrid_raw",
"search",
"search_hybrid",
"list",
]
.into_iter()
.zip(library_error_arms(w))
.collect()
}
fn take_err(arm: (&'static str, Result<usize, Error>), what: &str) -> Error {
assert_eq!(arm.0, what, "the call table pairs a tag with the wrong arm");
arm.1
.expect_err("a poisoned arm must answer, not return a result")
}
#[test]
fn a_poisoned_store_mutex_is_answered_as_an_internal_error_instead_of_panicking() {
let poisoned = poisoned_store();
let w = &poisoned.wrapper;
let row = poison_row();
let update = UpdateParams {
status: Some(MemoryStatus::Superseded),
..UpdateParams::default()
};
let mut library_errors = library_error_arms_with_names(w);
let answers: [(&str, rmcp::ErrorData); 12] = [
(
"ingest",
w.ingest("poison-proj", "never written", None, true)
.expect_err("ingest must answer, not panic"),
),
(
"ingest_with_lifecycle",
w.ingest_with_lifecycle(&row, true, MemoryImportance::High)
.expect_err("ingest_with_lifecycle must answer, not panic"),
),
(
"supersede_with_row",
w.supersede_with_row(&row, "whatever", MemoryImportance::High)
.expect_err("supersede_with_row must answer, not panic"),
),
(
"search_raw",
rmcp::ErrorData::from(take_err(library_errors.remove(0), "search_raw")),
),
(
"search_hybrid_raw",
rmcp::ErrorData::from(take_err(library_errors.remove(0), "search_hybrid_raw")),
),
(
"search",
rmcp::ErrorData::from(take_err(library_errors.remove(0), "search")),
),
(
"search_hybrid",
rmcp::ErrorData::from(take_err(library_errors.remove(0), "search_hybrid")),
),
(
"list",
rmcp::ErrorData::from(take_err(library_errors.remove(0), "list")),
),
(
"get",
w.get("whatever", "poison-proj")
.expect_err("get must answer, not panic"),
),
(
"delete",
w.delete("whatever", "poison-proj")
.expect_err("delete must answer, not panic"),
),
(
"update",
w.update("whatever", "poison-proj", update)
.expect_err("update must answer, not panic"),
),
(
"touch_memories",
w.touch_memories(&["whatever"])
.expect_err("touch_memories must answer, not panic"),
),
];
let mut covered: Vec<&str> = Vec::new();
for (what, err) in &answers {
assert_poison_answer(err, what);
assert!(
err.message.contains(&format!("({what})")),
"{what} must name its own acquisition in the message, got {:?}",
err.message
);
covered.push(*what);
}
assert_eq!(
covered, POISONED_ACQUISITIONS,
"the roster and the call table must be the same 12 acquisitions in order"
);
for (what, _) in &library_errors {
assert!(
POISONED_ACQUISITIONS.contains(what),
"the library-Error arms must be acquisition names: {what}"
);
}
assert!(
poisoned.store.is_poisoned(),
"answering must not clear the poison — the guard is never taken back out"
);
}
#[test]
fn a_poisoned_lock_store_answer_is_never_the_retryable_locked_verdict() {
let poisoned = poisoned_store();
for (what, arm) in library_error_arms_with_names(&poisoned.wrapper) {
let err = arm.expect_err("the library-Error arms must answer, not panic");
assert!(
!err.is_locked(),
"{what}: a poison must not be reported as the retryable locked verdict: {err}"
);
}
}
#[tokio::test]
async fn a_poisoned_store_is_answered_at_the_tool_boundary() {
let poisoned = poisoned_store();
let handler = ToolHandler::new(
poisoned.store.clone(),
"poison-proj".to_string(),
Config::default(),
);
let err = handler
.call_tool_for_test("list_memories", serde_json::json!({"limit": 10}))
.await
.expect_err("list_memories must answer, not panic");
assert_poison_answer(&err, "list_memories");
let err = handler
.call_tool_for_test("search_memories", serde_json::json!({"query": "seeded"}))
.await
.expect_err("search_memories must answer, not panic");
assert_poison_answer(&err, "search_memories");
let err = handler
.call_tool_for_test("get_memory", serde_json::json!({"id": "whatever"}))
.await
.expect_err("get_memory must answer, not panic");
assert_poison_answer(&err, "get_memory");
}
#[tokio::test]
async fn a_poisoned_store_is_answered_by_the_hybrid_search_tool() {
let poisoned = poisoned_store();
let config = Config {
hybrid: true,
..Config::default()
};
let handler = ToolHandler::new(poisoned.store.clone(), "poison-proj".to_string(), config);
let err = handler
.call_tool_for_test("search_memories", serde_json::json!({"query": "seeded"}))
.await
.expect_err("the hybrid arm must answer, not panic");
assert_poison_answer(&err, "search_memories (hybrid)");
assert!(
err.message.contains("(search_hybrid_raw)"),
"the hybrid arm must name its own acquisition, got {:?}",
err.message
);
}
#[test]
fn a_poisoned_store_is_answered_by_the_telemetry_acquisition_its_callers_swallow() {
let poisoned = poisoned_store();
let err = poisoned
.wrapper
.touch_memories(&["whatever"])
.expect_err("telemetry must answer, not panic");
assert_poison_answer(&err, "touch_memories");
assert!(
err.message.contains("(touch_memories)"),
"the op tag is the only clue an operator gets in a swallowed-telemetry log, got {:?}",
err.message
);
}