mattrguerra/cortexmainPrivate on GitHub, published here in full

tests.rs

Language Rust10.8 KB277 lines

crates/cortex-core/src/storage/tests.rs

  1. use super::*;
  2. use crate::types::{Prices, Usage, UsageSource};
  3. fn db() -> Db {
  4. Db::open_in_memory().unwrap()
  5. }
  6. fn chat(db: &Db) -> Conversation {
  7. db.create_conversation(DEEPINFRA_ID, "standin/echo", "be brief", None, "main").unwrap()
  8. }
  9. fn reply(db: &Db, conv: &str, text: &str, cost: i64, source: UsageSource) -> Message {
  10. let m = db.insert_assistant_placeholder(conv, DEEPINFRA_ID, "standin/echo").unwrap();
  11. db.finalize_assistant(
  12. &m.id,
  13. &Finalize {
  14. content: text.into(),
  15. status: "complete",
  16. usage: Usage { prompt_tokens: 10, completion_tokens: 20, cached_tokens: 0 },
  17. usage_source: Some(source),
  18. prices: Some(Prices { input_per_m: 0.5, output_per_m: 1.5, cache_read_per_m: None }),
  19. cost_nanousd: cost,
  20. ..Default::default()
  21. },
  22. )
  23. .unwrap()
  24. }
  25. #[test]
  26. fn migrations_run_once_and_fts5_is_compiled_in() {
  27. let d = db();
  28. assert_eq!(d.schema_version().unwrap(), MIGRATIONS.len() as i64);
  29. let fts: bool = d.conn().query_row("SELECT sqlite_compileoption_used('ENABLE_FTS5')", [], |r| r.get(0)).unwrap();
  30. assert!(fts);
  31. // DeepInfra is seeded exactly once.
  32. let dir = tempfile::tempdir().unwrap();
  33. let path = dir.path().join("c.db");
  34. Db::open(&path).unwrap();
  35. let again = Db::open(&path).unwrap();
  36. assert_eq!(again.list_providers().unwrap().len(), 1);
  37. assert_eq!(again.schema_version().unwrap(), MIGRATIONS.len() as i64);
  38. }
  39. #[test]
  40. fn a_conversation_round_trip() {
  41. let d = db();
  42. let c = chat(&d);
  43. assert_eq!(c.title, "New chat");
  44. d.insert_user_message(&c.id, "How does FTS5 rank results?\nsecond line", &[]).unwrap();
  45. let a = reply(&d, &c.id, "It uses bm25.", 35_000, UsageSource::Reported);
  46. assert_eq!(a.status, "complete");
  47. assert_eq!(a.prices.unwrap().output_per_m, 1.5);
  48. let msgs = d.list_messages(&c.id).unwrap();
  49. assert_eq!(msgs.iter().map(|m| m.role.as_str()).collect::<Vec<_>>(), ["user", "assistant"]);
  50. // Titled from the first line of the first message, and only once.
  51. assert_eq!(d.get_conversation(&c.id).unwrap().title, "How does FTS5 rank results?");
  52. d.insert_user_message(&c.id, "something else", &[]).unwrap();
  53. assert_eq!(d.get_conversation(&c.id).unwrap().title, "How does FTS5 rank results?");
  54. let list = d.list_conversations(false, 50).unwrap();
  55. assert_eq!(list[0].cost_nanousd, 35_000);
  56. assert_eq!(list[0].message_count, 3);
  57. assert!(list[0].has_usage);
  58. }
  59. #[test]
  60. fn a_chat_whose_only_reply_failed_is_not_free() {
  61. let d = db();
  62. let c = chat(&d);
  63. d.insert_user_message(&c.id, "hi", &[]).unwrap();
  64. let p = d.insert_assistant_placeholder(&c.id, DEEPINFRA_ID, "m").unwrap();
  65. d.finalize_assistant(
  66. &p.id,
  67. &Finalize { status: "error", error_kind: Some("invalid_key".into()), ..Default::default() },
  68. )
  69. .unwrap();
  70. let s = &d.list_conversations(false, 10).unwrap()[0];
  71. assert_eq!((s.cost_nanousd, s.has_usage), (0, false));
  72. }
  73. #[test]
  74. fn title_trimming() {
  75. assert_eq!(title_from("\n\n hello \nworld"), "hello");
  76. let long = "word ".repeat(30);
  77. let t = title_from(&long);
  78. assert!(t.ends_with("...") && t.chars().count() <= 63, "{t}");
  79. assert_eq!(title_from(""), "New chat");
  80. }
  81. #[test]
  82. fn truncate_from_removes_that_message_and_later() {
  83. let d = db();
  84. let c = chat(&d);
  85. let u1 = d.insert_user_message(&c.id, "one", &[]).unwrap();
  86. reply(&d, &c.id, "r1", 1, UsageSource::Reported);
  87. let u2 = d.insert_user_message(&c.id, "two", &[]).unwrap();
  88. reply(&d, &c.id, "r2", 1, UsageSource::Reported);
  89. assert_eq!(d.truncate_from(&c.id, &u2.id).unwrap(), 2);
  90. let left: Vec<String> = d.list_messages(&c.id).unwrap().into_iter().map(|m| m.content).collect();
  91. assert_eq!(left, ["one", "r1"]);
  92. assert_eq!(d.truncate_from(&c.id, &u1.id).unwrap(), 2);
  93. }
  94. #[test]
  95. fn editing_a_message_keeps_its_attachments_for_the_resend() {
  96. let d = db();
  97. let c = chat(&d);
  98. let a = d
  99. .insert_attachment(&NewAttachment {
  100. kind: "text",
  101. filename: "a.txt",
  102. mime: "text/plain",
  103. byte_size: 1,
  104. sha256: "x",
  105. width: None,
  106. height: None,
  107. path: None,
  108. text_content: Some("a"),
  109. })
  110. .unwrap();
  111. let u = d.insert_user_message(&c.id, "see file", std::slice::from_ref(&a.id)).unwrap();
  112. d.truncate_from(&c.id, &u.id).unwrap();
  113. let again = d.insert_user_message(&c.id, "see this file", std::slice::from_ref(&a.id)).unwrap();
  114. assert_eq!(again.attachments.len(), 1, "the attachment survives the edit");
  115. }
  116. #[test]
  117. fn deleting_a_conversation_cascades() {
  118. let d = db();
  119. let c = chat(&d);
  120. let a = d
  121. .insert_attachment(&NewAttachment {
  122. kind: "text",
  123. filename: "notes.txt",
  124. mime: "text/plain",
  125. byte_size: 5,
  126. sha256: "x",
  127. width: None,
  128. height: None,
  129. path: None,
  130. text_content: Some("hello"),
  131. })
  132. .unwrap();
  133. let u = d.insert_user_message(&c.id, "see attached", std::slice::from_ref(&a.id)).unwrap();
  134. assert_eq!(u.attachments.len(), 1);
  135. d.delete_conversation(&c.id).unwrap();
  136. let n: i64 = d.conn().query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0)).unwrap();
  137. let na: i64 = d.conn().query_row("SELECT COUNT(*) FROM attachments", [], |r| r.get(0)).unwrap();
  138. assert_eq!((n, na), (0, 0));
  139. // The index follows: nothing left to find.
  140. assert!(d.search("attached", 10).unwrap().is_empty());
  141. }
  142. #[test]
  143. fn interrupted_streams_are_closed_on_open() {
  144. let dir = tempfile::tempdir().unwrap();
  145. let path = dir.path().join("c.db");
  146. let d = Db::open(&path).unwrap();
  147. let c = chat(&d);
  148. let p = d.insert_assistant_placeholder(&c.id, DEEPINFRA_ID, "m").unwrap();
  149. drop(d);
  150. let d = Db::open(&path).unwrap();
  151. let m = d.get_message(&p.id).unwrap();
  152. assert_eq!((m.status.as_str(), m.error_kind.as_deref()), ("error", Some("interrupted")));
  153. }
  154. #[test]
  155. fn search_finds_messages_and_titles_and_survives_odd_input() {
  156. let d = db();
  157. let c = chat(&d);
  158. d.insert_user_message(&c.id, "Tidy a bash backup script", &[]).unwrap();
  159. reply(&d, &c.id, "Use rsync with --delete and quote every variable.", 1, UsageSource::Reported);
  160. let other = chat(&d);
  161. d.insert_user_message(&other.id, "Weekend sourdough schedule", &[]).unwrap();
  162. let hits = d.search("rsync", 10).unwrap();
  163. assert_eq!(hits.len(), 1);
  164. assert_eq!(hits[0].conversation_id, c.id);
  165. assert!(hits[0].snippet.contains("\u{1}rsync\u{2}"), "{}", hits[0].snippet);
  166. // Prefix match on the last word while typing; porter stemming on the rest.
  167. assert!(!d.search("sourd", 10).unwrap().is_empty());
  168. assert!(!d.search("quoting variables", 10).unwrap().is_empty());
  169. // Operators and quotes are just text.
  170. for q in ["\"", "AND OR NOT", "a:b", "(", "*", "-", "' OR 1=1 --", ""] {
  171. d.search(q, 10).unwrap();
  172. }
  173. // Edited titles are found.
  174. d.rename_conversation(&other.id, "Bread plans").unwrap();
  175. assert!(d.search("bread", 10).unwrap().iter().any(|h| h.conversation_id == other.id && h.message_id.is_none()));
  176. // Archived chats drop out of search.
  177. d.set_archived(&c.id, true).unwrap();
  178. assert!(d.search("rsync", 10).unwrap().is_empty());
  179. }
  180. #[test]
  181. fn fts_query_sanitizer() {
  182. assert_eq!(fts_query("hello world").unwrap(), "\"hello\" \"world\"*");
  183. assert_eq!(fts_query(" (a:b) \"c ").unwrap(), "\"a\" \"b\" \"c\"*");
  184. assert_eq!(fts_query("don't"), Some("\"don't\"*".into()));
  185. assert_eq!(fts_query("!!! ---"), None);
  186. }
  187. #[test]
  188. fn spend_and_month_boundaries() {
  189. let d = db();
  190. let c = chat(&d);
  191. let old = reply(&d, &c.id, "old", 5_000, UsageSource::Reported);
  192. reply(&d, &c.id, "new", 7_000, UsageSource::Estimated);
  193. // Move the first reply to 2026-08-31 23:30 in UTC-5, which is September in UTC.
  194. let utc_minus_5 = chrono::FixedOffset::west_opt(5 * 3600).unwrap();
  195. let aug_local = utc_minus_5.with_ymd_and_hms(2026, 8, 31, 23, 30, 0).unwrap();
  196. d.conn()
  197. .execute("UPDATE messages SET created_at = ?1 WHERE id = ?2", params![aug_local.timestamp_millis(), old.id])
  198. .unwrap();
  199. let sept_local = utc_minus_5.with_ymd_and_hms(2026, 9, 15, 12, 0, 0).unwrap();
  200. let start = month_start_ms(&sept_local);
  201. assert_eq!(start, utc_minus_5.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap().timestamp_millis());
  202. let (spend, estimated) = d.spend_since(start).unwrap();
  203. assert_eq!((spend, estimated), (7_000, true), "an August-local reply must not count in September");
  204. // In UTC the same instant is already September.
  205. let sept_utc = chrono::Utc.with_ymd_and_hms(2026, 9, 15, 12, 0, 0).unwrap();
  206. assert_eq!(d.spend_since(month_start_ms(&sept_utc)).unwrap().0, 12_000);
  207. // Grouped views.
  208. let by_model = d.usage_by_model(0).unwrap();
  209. assert_eq!((by_model[0].key.as_str(), by_model[0].cost_nanousd, by_model[0].messages), ("standin/echo", 12_000, 2));
  210. let by_day = d.usage_by_day(0, -300).unwrap();
  211. assert!(by_day.iter().any(|r| r.key == "2026-08-31" && r.cost_nanousd == 5_000));
  212. }
  213. #[test]
  214. fn settings_presets_models() {
  215. let d = db();
  216. assert_eq!(d.get_setting::<f64>("budget").unwrap(), None);
  217. d.set_setting("budget", &12.5).unwrap();
  218. assert_eq!(d.get_setting::<f64>("budget").unwrap(), Some(12.5));
  219. let p = d
  220. .save_preset(&Preset {
  221. id: String::new(),
  222. name: "Editor".into(),
  223. system_prompt: "Tighten prose.".into(),
  224. provider_id: None,
  225. model_id: None,
  226. temperature: Some(0.3),
  227. max_tokens: None,
  228. })
  229. .unwrap();
  230. d.save_preset(&Preset { system_prompt: "Tighten prose. Prefer short sentences.".into(), ..p.clone() }).unwrap();
  231. let all = d.list_presets().unwrap();
  232. assert_eq!(all.len(), 1);
  233. assert!(all[0].system_prompt.ends_with("Prefer short sentences."));
  234. let models = crate::pricing::parse_priced_models(DEEPINFRA_ID, &standin::models());
  235. d.replace_models(DEEPINFRA_ID, &models, "live", 1000).unwrap();
  236. assert_eq!(d.list_models(DEEPINFRA_ID).unwrap(), models);
  237. assert_eq!(d.models_source(DEEPINFRA_ID).unwrap(), Some(("live".into(), 1000)));
  238. d.replace_models(DEEPINFRA_ID, &models[..2], "snapshot", 2000).unwrap();
  239. assert_eq!(d.list_models(DEEPINFRA_ID).unwrap().len(), 2);
  240. }
  241. #[test]
  242. fn providers() {
  243. let d = db();
  244. d.upsert_provider(&Provider {
  245. id: "ollama".into(),
  246. kind: "openai_compatible".into(),
  247. name: "Ollama".into(),
  248. base_url: "http://localhost:11434/v1/".into(),
  249. requires_key: false,
  250. })
  251. .unwrap();
  252. let all = d.list_providers().unwrap();
  253. assert_eq!(all[0].id, DEEPINFRA_ID);
  254. assert_eq!(all[1].base_url, "http://localhost:11434/v1");
  255. d.delete_provider(DEEPINFRA_ID).unwrap();
  256. d.delete_provider("ollama").unwrap();
  257. assert_eq!(d.list_providers().unwrap().len(), 1);
  258. }