tests.rs
Language Rust10.8 KB277 lines
use super::*;use crate::types::{Prices, Usage, UsageSource};fn db() -> Db {Db::open_in_memory().unwrap()}fn chat(db: &Db) -> Conversation {db.create_conversation(DEEPINFRA_ID, "standin/echo", "be brief", None, "main").unwrap()}fn reply(db: &Db, conv: &str, text: &str, cost: i64, source: UsageSource) -> Message {let m = db.insert_assistant_placeholder(conv, DEEPINFRA_ID, "standin/echo").unwrap();db.finalize_assistant(&m.id,&Finalize {content: text.into(),status: "complete",usage: Usage { prompt_tokens: 10, completion_tokens: 20, cached_tokens: 0 },usage_source: Some(source),prices: Some(Prices { input_per_m: 0.5, output_per_m: 1.5, cache_read_per_m: None }),cost_nanousd: cost,..Default::default()},).unwrap()}#[test]fn migrations_run_once_and_fts5_is_compiled_in() {let d = db();assert_eq!(d.schema_version().unwrap(), MIGRATIONS.len() as i64);let fts: bool = d.conn().query_row("SELECT sqlite_compileoption_used('ENABLE_FTS5')", [], |r| r.get(0)).unwrap();assert!(fts);// DeepInfra is seeded exactly once.let dir = tempfile::tempdir().unwrap();let path = dir.path().join("c.db");Db::open(&path).unwrap();let again = Db::open(&path).unwrap();assert_eq!(again.list_providers().unwrap().len(), 1);assert_eq!(again.schema_version().unwrap(), MIGRATIONS.len() as i64);}#[test]fn a_conversation_round_trip() {let d = db();let c = chat(&d);assert_eq!(c.title, "New chat");d.insert_user_message(&c.id, "How does FTS5 rank results?\nsecond line", &[]).unwrap();let a = reply(&d, &c.id, "It uses bm25.", 35_000, UsageSource::Reported);assert_eq!(a.status, "complete");assert_eq!(a.prices.unwrap().output_per_m, 1.5);let msgs = d.list_messages(&c.id).unwrap();assert_eq!(msgs.iter().map(|m| m.role.as_str()).collect::<Vec<_>>(), ["user", "assistant"]);// Titled from the first line of the first message, and only once.assert_eq!(d.get_conversation(&c.id).unwrap().title, "How does FTS5 rank results?");d.insert_user_message(&c.id, "something else", &[]).unwrap();assert_eq!(d.get_conversation(&c.id).unwrap().title, "How does FTS5 rank results?");let list = d.list_conversations(false, 50).unwrap();assert_eq!(list[0].cost_nanousd, 35_000);assert_eq!(list[0].message_count, 3);assert!(list[0].has_usage);}#[test]fn a_chat_whose_only_reply_failed_is_not_free() {let d = db();let c = chat(&d);d.insert_user_message(&c.id, "hi", &[]).unwrap();let p = d.insert_assistant_placeholder(&c.id, DEEPINFRA_ID, "m").unwrap();d.finalize_assistant(&p.id,&Finalize { status: "error", error_kind: Some("invalid_key".into()), ..Default::default() },).unwrap();let s = &d.list_conversations(false, 10).unwrap()[0];assert_eq!((s.cost_nanousd, s.has_usage), (0, false));}#[test]fn title_trimming() {assert_eq!(title_from("\n\n hello \nworld"), "hello");let long = "word ".repeat(30);let t = title_from(&long);assert!(t.ends_with("...") && t.chars().count() <= 63, "{t}");assert_eq!(title_from(""), "New chat");}#[test]fn truncate_from_removes_that_message_and_later() {let d = db();let c = chat(&d);let u1 = d.insert_user_message(&c.id, "one", &[]).unwrap();reply(&d, &c.id, "r1", 1, UsageSource::Reported);let u2 = d.insert_user_message(&c.id, "two", &[]).unwrap();reply(&d, &c.id, "r2", 1, UsageSource::Reported);assert_eq!(d.truncate_from(&c.id, &u2.id).unwrap(), 2);let left: Vec<String> = d.list_messages(&c.id).unwrap().into_iter().map(|m| m.content).collect();assert_eq!(left, ["one", "r1"]);assert_eq!(d.truncate_from(&c.id, &u1.id).unwrap(), 2);}#[test]fn editing_a_message_keeps_its_attachments_for_the_resend() {let d = db();let c = chat(&d);let a = d.insert_attachment(&NewAttachment {kind: "text",filename: "a.txt",mime: "text/plain",byte_size: 1,sha256: "x",width: None,height: None,path: None,text_content: Some("a"),}).unwrap();let u = d.insert_user_message(&c.id, "see file", std::slice::from_ref(&a.id)).unwrap();d.truncate_from(&c.id, &u.id).unwrap();let again = d.insert_user_message(&c.id, "see this file", std::slice::from_ref(&a.id)).unwrap();assert_eq!(again.attachments.len(), 1, "the attachment survives the edit");}#[test]fn deleting_a_conversation_cascades() {let d = db();let c = chat(&d);let a = d.insert_attachment(&NewAttachment {kind: "text",filename: "notes.txt",mime: "text/plain",byte_size: 5,sha256: "x",width: None,height: None,path: None,text_content: Some("hello"),}).unwrap();let u = d.insert_user_message(&c.id, "see attached", std::slice::from_ref(&a.id)).unwrap();assert_eq!(u.attachments.len(), 1);d.delete_conversation(&c.id).unwrap();let n: i64 = d.conn().query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0)).unwrap();let na: i64 = d.conn().query_row("SELECT COUNT(*) FROM attachments", [], |r| r.get(0)).unwrap();assert_eq!((n, na), (0, 0));// The index follows: nothing left to find.assert!(d.search("attached", 10).unwrap().is_empty());}#[test]fn interrupted_streams_are_closed_on_open() {let dir = tempfile::tempdir().unwrap();let path = dir.path().join("c.db");let d = Db::open(&path).unwrap();let c = chat(&d);let p = d.insert_assistant_placeholder(&c.id, DEEPINFRA_ID, "m").unwrap();drop(d);let d = Db::open(&path).unwrap();let m = d.get_message(&p.id).unwrap();assert_eq!((m.status.as_str(), m.error_kind.as_deref()), ("error", Some("interrupted")));}#[test]fn search_finds_messages_and_titles_and_survives_odd_input() {let d = db();let c = chat(&d);d.insert_user_message(&c.id, "Tidy a bash backup script", &[]).unwrap();reply(&d, &c.id, "Use rsync with --delete and quote every variable.", 1, UsageSource::Reported);let other = chat(&d);d.insert_user_message(&other.id, "Weekend sourdough schedule", &[]).unwrap();let hits = d.search("rsync", 10).unwrap();assert_eq!(hits.len(), 1);assert_eq!(hits[0].conversation_id, c.id);assert!(hits[0].snippet.contains("\u{1}rsync\u{2}"), "{}", hits[0].snippet);// Prefix match on the last word while typing; porter stemming on the rest.assert!(!d.search("sourd", 10).unwrap().is_empty());assert!(!d.search("quoting variables", 10).unwrap().is_empty());// Operators and quotes are just text.for q in ["\"", "AND OR NOT", "a:b", "(", "*", "-", "' OR 1=1 --", ""] {d.search(q, 10).unwrap();}// Edited titles are found.d.rename_conversation(&other.id, "Bread plans").unwrap();assert!(d.search("bread", 10).unwrap().iter().any(|h| h.conversation_id == other.id && h.message_id.is_none()));// Archived chats drop out of search.d.set_archived(&c.id, true).unwrap();assert!(d.search("rsync", 10).unwrap().is_empty());}#[test]fn fts_query_sanitizer() {assert_eq!(fts_query("hello world").unwrap(), "\"hello\" \"world\"*");assert_eq!(fts_query(" (a:b) \"c ").unwrap(), "\"a\" \"b\" \"c\"*");assert_eq!(fts_query("don't"), Some("\"don't\"*".into()));assert_eq!(fts_query("!!! ---"), None);}#[test]fn spend_and_month_boundaries() {let d = db();let c = chat(&d);let old = reply(&d, &c.id, "old", 5_000, UsageSource::Reported);reply(&d, &c.id, "new", 7_000, UsageSource::Estimated);// Move the first reply to 2026-08-31 23:30 in UTC-5, which is September in UTC.let utc_minus_5 = chrono::FixedOffset::west_opt(5 * 3600).unwrap();let aug_local = utc_minus_5.with_ymd_and_hms(2026, 8, 31, 23, 30, 0).unwrap();d.conn().execute("UPDATE messages SET created_at = ?1 WHERE id = ?2", params![aug_local.timestamp_millis(), old.id]).unwrap();let sept_local = utc_minus_5.with_ymd_and_hms(2026, 9, 15, 12, 0, 0).unwrap();let start = month_start_ms(&sept_local);assert_eq!(start, utc_minus_5.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap().timestamp_millis());let (spend, estimated) = d.spend_since(start).unwrap();assert_eq!((spend, estimated), (7_000, true), "an August-local reply must not count in September");// In UTC the same instant is already September.let sept_utc = chrono::Utc.with_ymd_and_hms(2026, 9, 15, 12, 0, 0).unwrap();assert_eq!(d.spend_since(month_start_ms(&sept_utc)).unwrap().0, 12_000);// Grouped views.let by_model = d.usage_by_model(0).unwrap();assert_eq!((by_model[0].key.as_str(), by_model[0].cost_nanousd, by_model[0].messages), ("standin/echo", 12_000, 2));let by_day = d.usage_by_day(0, -300).unwrap();assert!(by_day.iter().any(|r| r.key == "2026-08-31" && r.cost_nanousd == 5_000));}#[test]fn settings_presets_models() {let d = db();assert_eq!(d.get_setting::<f64>("budget").unwrap(), None);d.set_setting("budget", &12.5).unwrap();assert_eq!(d.get_setting::<f64>("budget").unwrap(), Some(12.5));let p = d.save_preset(&Preset {id: String::new(),name: "Editor".into(),system_prompt: "Tighten prose.".into(),provider_id: None,model_id: None,temperature: Some(0.3),max_tokens: None,}).unwrap();d.save_preset(&Preset { system_prompt: "Tighten prose. Prefer short sentences.".into(), ..p.clone() }).unwrap();let all = d.list_presets().unwrap();assert_eq!(all.len(), 1);assert!(all[0].system_prompt.ends_with("Prefer short sentences."));let models = crate::pricing::parse_priced_models(DEEPINFRA_ID, &standin::models());d.replace_models(DEEPINFRA_ID, &models, "live", 1000).unwrap();assert_eq!(d.list_models(DEEPINFRA_ID).unwrap(), models);assert_eq!(d.models_source(DEEPINFRA_ID).unwrap(), Some(("live".into(), 1000)));d.replace_models(DEEPINFRA_ID, &models[..2], "snapshot", 2000).unwrap();assert_eq!(d.list_models(DEEPINFRA_ID).unwrap().len(), 2);}#[test]fn providers() {let d = db();d.upsert_provider(&Provider {id: "ollama".into(),kind: "openai_compatible".into(),name: "Ollama".into(),base_url: "http://localhost:11434/v1/".into(),requires_key: false,}).unwrap();let all = d.list_providers().unwrap();assert_eq!(all[0].id, DEEPINFRA_ID);assert_eq!(all[1].base_url, "http://localhost:11434/v1");d.delete_provider(DEEPINFRA_ID).unwrap();d.delete_provider("ollama").unwrap();assert_eq!(d.list_providers().unwrap().len(), 1);}