#![cfg(feature = "server")]
use std::path::Path;
use std::process::{Command, Stdio};
use serde_json::{json, Value};
mod server_support;
use server_support::{Server, DIM};
fn start(dir: &Path, extra: &[&str]) -> Server {
let mut args = vec!["--no-auth"];
args.extend_from_slice(extra);
Server::start_in(dir, &args, &[])
}
trait MemoryPost {
fn post(&self, op: &str, tenant: &str, body: &Value) -> (u16, Value);
}
impl MemoryPost for Server {
fn post(&self, op: &str, tenant: &str, body: &Value) -> (u16, Value) {
let response = self
.req("POST", &format!("/api/v1/memory/{op}"))
.tenant(tenant)
.json(body)
.send();
(response.status, response.json())
}
}
fn unit(axis: usize) -> Vec<f64> {
(0..DIM)
.map(|i| if i == axis { 1.0 } else { 0.0 })
.collect()
}
fn ids(response: &Value) -> Vec<&str> {
response["results"]
.as_array()
.unwrap()
.iter()
.map(|r| r["id"].as_str().unwrap())
.collect()
}
#[test]
fn memory_round_trip_over_http_with_history_errors_and_isolation() {
let dir = tempfile::tempdir().unwrap();
let server = start(&dir.path().join("data"), &[]);
let (status, out) = server.post(
"remember",
"a",
&json!({"id": "c1", "text": "Alice lives in Paris", "subject": "alice/city",
"validFrom": 100, "embedding": unit(0)}),
);
assert_eq!(status, 200, "{out}");
assert_eq!(out["remembered"][0]["created"], json!(true));
let (status, out) = server.post(
"remember",
"a",
&json!({"facts": [{"id": "c2", "text": "Alice lives in Berlin", "validFrom": 200,
"supersedes": ["c1"], "embedding": unit(1)}]}),
);
assert_eq!(status, 200, "{out}");
assert_eq!(out["remembered"][0]["supersedes"], json!(["c1"]));
let (_, current) = server.post(
"recall",
"a",
&json!({"query": "Alice lives", "asOf": 1000}),
);
assert_eq!(ids(¤t), ["c2"]);
let (_, all) = server.post(
"recall",
"a",
&json!({"query": "Alice lives", "history": "all", "asOf": 1000}),
);
assert_eq!(all["results"].as_array().unwrap().len(), 2);
let (_, early) = server.post("recall", "a", &json!({"query": "Alice lives", "asOf": 150}));
assert_eq!(ids(&early), ["c1"]);
let (_, semantic) = server.post(
"recall",
"a",
&json!({"query": "", "embedding": unit(1), "history": "all", "asOf": 1000,
"lexicalWeight": 0.0, "minSemanticScore": 0.5}),
);
assert_eq!(ids(&semantic), ["c2"]);
let (status, explained) = server.post(
"explain",
"a",
&json!({"query": "Alice Berlin", "id": "c2", "asOf": 1000}),
);
assert_eq!(status, 200, "{explained}");
assert_eq!(
explained["explanation"]["matchedTerms"],
json!(["alice", "berlin"])
);
assert_eq!(explained["explanation"]["chain"][0]["id"], json!("c1"));
let (_, got) = server.post("get", "a", &json!({"id": "c1"}));
assert_eq!(got["fact"]["supersededBy"], json!(["c2"]));
let (_, history) = server.post("history", "a", &json!({"id": "c2"}));
assert_eq!(history["history"].as_array().unwrap().len(), 2);
let (_, other) = server.post("recall", "b", &json!({"query": "Alice", "history": "all"}));
assert!(ids(&other).is_empty());
assert_eq!(server.post("nope", "a", &json!({})).0, 404);
assert_eq!(
server.post("history", "a", &json!({"id": "missing"})).0,
404
);
let (status, conflict) =
server.post("remember", "a", &json!({"id": "c1", "text": "different"}));
assert_eq!(
(status, conflict["error"]["code"].as_str()),
(409, Some("conflict"))
);
for bad in [
json!({"id": "x", "text": "t", "unknownField": 1}),
json!({"id": "x", "text": "t", "embedding": [1.0, 2.0]}),
json!({"id": "hms:x", "text": "t"}),
json!({"id": "x", "text": "t", "supersedes": ["missing"]}),
json!({"facts": []}),
] {
let (status, body) = server.post("remember", "a", &bad);
assert_eq!(status, 422, "{bad} -> {body}");
assert_eq!(body["error"]["code"], json!("invalid_request"));
}
assert_eq!(
server.post("recall", "a", &json!({"query": "q", "k": 0})).0,
422
);
assert_eq!(server.post("recall", "a", &json!({"k": 3})).0, 422);
let (_, again) = server.post(
"remember",
"a",
&json!({"id": "c1", "text": "Alice lives in Paris", "subject": "alice/city",
"validFrom": 100, "embedding": unit(0)}),
);
assert_eq!(again["remembered"][0]["created"], json!(false));
let scope = json!({"kind": "subject", "subject": "alice/city"});
let (_, dry) = server.post("forget", "a", &json!({"scope": scope, "dryRun": true}));
assert_eq!(dry["forgotten"], json!(["c1"]));
let (_, done) = server.post("forget", "a", &json!({"scope": scope}));
assert_eq!(done["forgotten"], json!(["c1"]));
let (_, repeat) = server.post("forget", "a", &json!({"scope": scope}));
assert_eq!(repeat["forgotten"], json!([]));
let (_, now) = server.post("recall", "a", &json!({"query": "Alice", "history": "all"}));
assert_eq!(ids(&now), ["c2"]);
}
#[test]
fn memory_state_and_configuration_survive_restart() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("memory.json");
std::fs::write(
&config,
json!({"supersession": "latest-by-subject", "defaultHistory": "current", "defaultK": 3})
.to_string(),
)
.unwrap();
let data = dir.path().join("data");
let args = ["--memory-config", config.to_str().unwrap()];
let mut server = start(&data, &args);
for (id, from) in [("v1", 100), ("v2", 200), ("v3", 300)] {
let (status, out) = server.post(
"remember",
"t",
&json!({"id": id, "text": format!("setting value {id}"), "subject": "setting",
"validFrom": from}),
);
assert_eq!(status, 200, "{out}");
}
assert!(server.stop(), "server must exit cleanly on SIGTERM");
let server = start(&data, &args);
let (_, current) = server.post(
"recall",
"t",
&json!({"query": "setting value", "asOf": 1000}),
);
assert_eq!(ids(¤t), ["v3"]);
let (_, early) = server.post(
"recall",
"t",
&json!({"query": "setting value", "asOf": 250}),
);
assert_eq!(ids(&early), ["v2"]);
let (_, history) = server.post("history", "t", &json!({"id": "v1"}));
assert_eq!(history["history"].as_array().unwrap().len(), 3);
}
#[test]
fn invalid_memory_config_stops_the_server_at_startup() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("memory.json");
std::fs::write(&config, r#"{"defaultK": 0}"#).unwrap();
let status = Command::new(env!("CARGO_BIN_EXE_hms-server"))
.args(["--bind", "127.0.0.1:0", "--data-dir"])
.arg(dir.path().join("data"))
.args(["--memory-config", config.to_str().unwrap()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
assert!(!status.success());
}