Menu
Public repository / master
AkurAI-Notes / crates/notes-api/tests/embed_test.rs
Latest change 705e1ce035ea4b9fb4c413dd38f550650185b571 - release: v0.0.5 by Claude Code
use akurai_json::Value;
use akurai_storage::BTree;
use notes_api::auth::idp_client::IdpConfig;
use notes_api::embed::{embed_query_with_key, maybe_reembed, EmbedStore};
use notes_api::NotesState;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::thread;
fn setup_db(name: &str) -> (BTree, String) {
let db_path = format!("_test_embed_{}.db", name);
let _ = std::fs::remove_file(&db_path);
let tree = BTree::open(&db_path).unwrap();
(tree, db_path)
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
struct EnvGuard(Vec<(&'static str, Option<String>)>);
impl EnvGuard {
fn capture(keys: &[&'static str]) -> Self {
Self(
keys.iter()
.map(|key| (*key, std::env::var(key).ok()))
.collect(),
)
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (key, value) in &self.0 {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
fn test_idp_config() -> IdpConfig {
IdpConfig {
issuer: "https://auth.olibuijr.com".into(),
internal_url: "http://127.0.0.1:3500".into(),
client_id: "notes-test".into(),
client_secret: "secret".into(),
redirect_uri: "https://akurai-notes.olibuijr.com/auth/callback".into(),
allow: vec!["user@example.com".into()],
}
}
fn spawn_embed_server(expected_auth: Option<&str>) -> (String, thread::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = format!("http://{}", listener.local_addr().unwrap());
let expected_auth = expected_auth.map(str::to_string);
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = Vec::new();
let mut buf = [0_u8; 1024];
loop {
let read = stream.read(&mut buf).unwrap();
if read == 0 {
break;
}
request.extend_from_slice(&buf[..read]);
let text = String::from_utf8_lossy(&request);
let Some(head_end) = text.find("\r\n\r\n") else {
continue;
};
let content_length = text
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("Content-Length") {
value.trim().parse::<usize>().ok()
} else {
None
}
})
.unwrap_or(0);
if request.len() >= head_end + 4 + content_length {
break;
}
}
let request_text = String::from_utf8_lossy(&request).to_string();
match expected_auth {
Some(token) => {
let expected = format!("\r\nAuthorization: Bearer {token}\r\n");
assert!(
request_text.contains(&expected),
"missing bearer header in request: {request_text}"
);
}
None => assert!(
!request_text.contains("\r\nAuthorization: "),
"unexpected authorization header in request: {request_text}"
),
}
let body = r#"{"data":[{"embedding":[0.5,0.25,-0.75]}]}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
request_text
});
(addr, handle)
}
fn make_note_key(id: u64) -> Vec<u8> {
let mut k = b"notes:".to_vec();
k.extend_from_slice(&id.to_be_bytes());
k
}
fn insert_note(db: &mut BTree, id: u64, title: &str, body: &str, owner: &str) {
let now = NotesState::now();
let record = Value::Object(vec![
("id".into(), Value::Int(id as i64)),
("title".into(), Value::Str(title.into())),
("body".into(), Value::Str(body.into())),
("ownerSub".into(), Value::Str(owner.into())),
("createdAt".into(), Value::Int(now)),
("updatedAt".into(), Value::Int(now)),
]);
db.insert(&make_note_key(id), record.to_json().as_bytes())
.unwrap();
db.commit().unwrap();
}
#[test]
fn embed_store_round_trips_vector() {
let (mut db, db_path) = setup_db("roundtrip");
let mut store = EmbedStore { db: &mut db };
let vec: Vec<f32> = vec![0.1, 0.2, -0.3, 0.5];
store.store(1, &vec).unwrap();
let loaded = store.load(1);
assert!(loaded.is_some());
let loaded = loaded.unwrap();
assert_eq!(loaded.len(), 4);
for (a, b) in vec.iter().zip(loaded.iter()) {
assert!((a - b).abs() < 1e-6);
}
store.remove(1).unwrap();
assert!(store.load(1).is_none());
std::fs::remove_file(&db_path).ok();
}
#[test]
fn embed_store_returns_none_for_missing_key() {
let (mut db, db_path) = setup_db("missing");
let mut store = EmbedStore { db: &mut db };
assert!(store.load(999).is_none());
std::fs::remove_file(&db_path).ok();
}
#[test]
fn rank_notes_returns_only_owned_notes_in_order() {
let (mut db, db_path) = setup_db("rank");
insert_note(&mut db, 1, "Meeting", "Budget review meeting", "alice");
insert_note(&mut db, 2, "Shopping", "Buy groceries", "alice");
insert_note(&mut db, 3, "Other", "Bob's note", "bob");
let mut store = EmbedStore { db: &mut db };
let v1 = vec![0.9, 0.1, 0.0];
store.store(1, &v1).unwrap();
let v2 = vec![0.1, 0.9, 0.0];
store.store(2, &v2).unwrap();
let v3 = vec![0.85, 0.15, 0.0];
store.store(3, &v3).unwrap();
let qv = vec![0.8, 0.2, 0.0];
let ranked = store.rank_notes(&qv, &["alice"], 5);
assert_eq!(
ranked.len(),
2,
"should return 2 alice notes, got {ranked:?}"
);
assert_eq!(ranked[0].0, 1, "note 1 should rank first (closest to qv)");
assert_eq!(ranked[1].0, 2, "note 2 should rank second");
let ranked = store.rank_notes(&qv, &["alice"], 1);
assert_eq!(ranked.len(), 1);
std::fs::remove_file(&db_path).ok();
}
#[test]
fn maybe_reembed_skips_when_url_is_none() {
let (mut db, db_path) = setup_db("skip");
insert_note(&mut db, 1, "Test", "Hello world", "user");
maybe_reembed(&mut db, &None, &None, &None, 1, "Hello world");
let mut store = EmbedStore { db: &mut db };
assert!(store.load(1).is_none());
std::fs::remove_file(&db_path).ok();
}
#[test]
fn embed_query_with_key_sends_bearer_token() {
let (embed_url, handle) = spawn_embed_server(Some("router-secret"));
let vec = embed_query_with_key(
"secure query",
&embed_url,
"test-embed-model",
Some("router-secret"),
)
.unwrap();
assert_eq!(vec, vec![0.5, 0.25, -0.75]);
let request = handle.join().unwrap();
assert!(request.contains(r#""model":"test-embed-model""#));
assert!(request.contains(r#""input":"secure query""#));
}
#[test]
fn embed_query_with_key_skips_bearer_for_blank_token() {
let (embed_url, handle) = spawn_embed_server(None);
let vec =
embed_query_with_key("plain query", &embed_url, "test-embed-model", Some(" ")).unwrap();
assert_eq!(vec, vec![0.5, 0.25, -0.75]);
let _ = handle.join().unwrap();
}
#[test]
fn notes_state_reads_embed_env_values() {
let _lock = env_lock();
let _env = EnvGuard::capture(&[
"AKURAI_NOTES_EMBED_URL",
"AKURAI_NOTES_EMBED_MODEL",
"AKURAI_NOTES_EMBED_API_KEY",
]);
std::env::set_var(
"AKURAI_NOTES_EMBED_URL",
"http://127.0.0.1:4219/v1/embeddings",
);
std::env::set_var("AKURAI_NOTES_EMBED_MODEL", "intfloat/multilingual-e5-small");
std::env::set_var("AKURAI_NOTES_EMBED_API_KEY", "router-secret");
let db_path = format!("_test_state_embed_{}.db", std::process::id());
let _ = std::fs::remove_file(&db_path);
let state = NotesState::new(&db_path, PathBuf::from("site/frontend"), test_idp_config())
.expect("state should build");
assert_eq!(
state.embed_url.as_deref(),
Some("http://127.0.0.1:4219/v1/embeddings")
);
assert_eq!(
state.embed_model.as_deref(),
Some("intfloat/multilingual-e5-small")
);
assert_eq!(state.embed_api_key.as_deref(), Some("router-secret"));
std::fs::remove_file(&db_path).ok();
}
#[test]
fn notes_state_ignores_blank_embed_env_values() {
let _lock = env_lock();
let _env = EnvGuard::capture(&[
"AKURAI_NOTES_EMBED_URL",
"AKURAI_NOTES_EMBED_MODEL",
"AKURAI_NOTES_EMBED_API_KEY",
]);
std::env::set_var("AKURAI_NOTES_EMBED_URL", " ");
std::env::set_var("AKURAI_NOTES_EMBED_MODEL", "");
std::env::set_var("AKURAI_NOTES_EMBED_API_KEY", "\t");
let db_path = format!("_test_state_embed_blank_{}.db", std::process::id());
let _ = std::fs::remove_file(&db_path);
let state = NotesState::new(&db_path, PathBuf::from("site/frontend"), test_idp_config())
.expect("state should build");
assert_eq!(state.embed_url, None);
assert_eq!(state.embed_model, None);
assert_eq!(state.embed_api_key, None);
std::fs::remove_file(&db_path).ok();
}
#[test]
fn substring_search_via_prefix_scan() {
let (mut db, db_path) = setup_db("substr");
insert_note(
&mut db,
1,
"Meeting minutes",
"Discussed Q2 targets",
"alice",
);
insert_note(&mut db, 2, "Grocery list", "Milk and eggs", "bob");
insert_note(&mut db, 3, "Budget review", "Q1 financial results", "alice");
insert_note(&mut db, 4, "Random", "Nothing important", "alice");
let prefix = b"notes:";
let mut bound = prefix.to_vec();
bound.push(0xff);
let mut results: Vec<(u64, String)> = Vec::new();
if let Ok(entries) = db.range(prefix, &bound) {
for entry in entries {
let (key, val) = entry;
let id_bytes: [u8; 8] = key[prefix.len()..].try_into().unwrap();
let id: u64 = u64::from_be_bytes(id_bytes);
let Ok(v) = akurai_json::parse(&String::from_utf8_lossy(&val)) else {
continue;
};
if v.get("ownerSub").and_then(|x| x.as_str()) != Some("alice") {
continue;
}
let title = v.get("title").and_then(|x| x.as_str()).unwrap_or("");
let body = v.get("body").and_then(|x| x.as_str()).unwrap_or("");
let combined = format!("{} {}", title, body).to_lowercase();
if combined.contains("q") {
let what = format!("{} — {}", title, body);
results.push((id, what));
}
}
}
assert_eq!(results.len(), 2, "should find 2 alice notes with 'q'");
let titles: Vec<&str> = results.iter().map(|r| r.1.as_str()).collect();
assert!(titles[0].contains("Q2") || titles[1].contains("Q2"));
assert!(titles[0].contains("Q1") || titles[1].contains("Q1"));
std::fs::remove_file(&db_path).ok();
}
#[test]
#[ignore]
fn create_note_stores_embedding() {
let (mut db, _db_path) = setup_db("create_embed");
let note_id = 1i64;
let body = "Test note content for embedding";
let embed_url = std::env::var("AKURAI_NOTES_EMBED_URL").ok();
let embed_model = std::env::var("AKURAI_NOTES_EMBED_MODEL").ok();
maybe_reembed(&mut db, &embed_url, &embed_model, &None, note_id, body);
let mut store = EmbedStore { db: &mut db };
let loaded = store.load(note_id);
assert!(
loaded.is_some(),
"embedding should be stored when embed server is up"
);
let vec = loaded.unwrap();
assert!(!vec.is_empty(), "embedding should have non-zero dimensions");
}
#[test]
#[ignore]
fn update_note_reembeds_only_when_body_changes() {}
#[test]
#[ignore]
fn search_is_owner_scoped() {}