#![allow(dead_code)] use diesel::prelude::*; use diesel::sqlite::SqliteConnection; use std::ops::DerefMut; use std::sync::{Arc, Mutex}; use crate::database::models::{InsertPersona, Persona}; use crate::database::schema; use crate::database::{DbError, DbErrorKind, connect}; use crate::otel::trace_db_call; /// One row of the persona-chat transcript. Lives in /// `persona_chat_conversations` (one row per `(user_id, persona_id)`). /// /// One persona conversation. /// /// `turn_count` is the cumulative number of assistant turns on the active /// branch — what the list screen shows as the length of a conversation. The /// transcript itself is the `messages_json` blob, holding a serialized /// `ChatHistoryStore` tree. `title` is a generated summary of the opening /// exchange, empty until the first turn completes. #[derive(Clone, Debug)] pub struct PersonaChatRow { pub conversation_id: String, pub persona_id: String, pub title: String, pub messages_json: String, pub turn_count: i32, pub created_at: i64, pub updated_at: i64, /// The SMS thread position this conversation grew out of, for chats /// started via "Continue with persona". None for an open chat. pub anchor: Option, } /// Where in an SMS thread an anchored persona conversation picks up. /// /// `contact_id` / `message_id` are SMS-API ids. `context` is the rendered /// snapshot of the thread leading up to the anchor, captured once at /// creation and spliced into the system prompt on every turn. #[derive(Clone, Debug, PartialEq)] pub struct SmsAnchor { pub contact_id: i64, pub contact_name: String, pub message_id: i64, /// Unix seconds of the anchor message. pub timestamp: i64, pub context: String, } /// The column tuple every persona-chat read selects. Shared by the /// single-row and list queries so their `select(...)` orders can never /// drift apart. type PersonaChatColumns = ( String, String, String, String, i32, i64, i64, Option, Option, Option, Option, Option, ); macro_rules! persona_chat_select { () => { ( conversation_id, persona_id, title, messages_json, turn_count, created_at, updated_at, anchor_contact_id, anchor_contact_name, anchor_message_id, anchor_ts, anchor_context, ) }; } /// Column tuple → `PersonaChatRow`. fn persona_chat_row( ( conversation_id, persona_id, title, messages_json, turn_count, created_at, updated_at, anchor_contact_id, anchor_contact_name, anchor_message_id, anchor_ts, anchor_context, ): PersonaChatColumns, ) -> PersonaChatRow { // An anchor needs at least the thread and the message; the rest has a // usable default if a row was written half-way by hand. let anchor = match (anchor_contact_id, anchor_message_id) { (Some(contact_id), Some(message_id)) => Some(SmsAnchor { contact_id, contact_name: anchor_contact_name.unwrap_or_default(), message_id, timestamp: anchor_ts.unwrap_or(0), context: anchor_context.unwrap_or_default(), }), _ => None, }; PersonaChatRow { conversation_id, persona_id, title, messages_json, turn_count, created_at, updated_at, anchor, } } /// Patch shape for update_persona. None = leave field alone. Built-ins are /// allowed to flip `include_all_memories` but should reject name/prompt /// edits at the handler layer (built-in copy lives in the migration). pub struct PersonaPatch { pub name: Option, pub system_prompt: Option, pub include_all_memories: Option, pub reviewed_only_facts: Option, pub allow_agent_corrections: Option, } /// One row of a bulk migration upload. Fields named to match the JSON /// shape the mobile client uploads (`POST /personas/migrate`). pub struct ImportPersona { pub persona_id: String, pub name: String, pub system_prompt: String, pub is_built_in: bool, pub created_at: i64, } pub trait PersonaDao: Sync + Send { fn list_personas(&mut self, user_id: i32) -> Result, DbError>; fn get_persona(&mut self, user_id: i32, persona_id: &str) -> Result, DbError>; fn create_persona( &mut self, user_id: i32, persona_id: &str, name: &str, system_prompt: &str, is_built_in: bool, include_all_memories: bool, ) -> Result; fn update_persona( &mut self, user_id: i32, persona_id: &str, patch: PersonaPatch, ) -> Result, DbError>; fn delete_persona(&mut self, user_id: i32, persona_id: &str) -> Result; /// Idempotent bulk import. INSERT OR IGNORE on (user_id, persona_id) /// — re-uploading the same set is a no-op. Returns the number of rows /// actually inserted (skipped duplicates don't count). fn bulk_import(&mut self, user_id: i32, personas: &[ImportPersona]) -> Result; // ── Persona-chat (open chat with persona) persistence ─────────── // // Keyed on an opaque `conversation_id` so one persona can hold several // separate conversations. `user_id` is checked on every read and write: // the id is a bearer token for a transcript, so ownership can never be // assumed from the id alone. /// Fetch one conversation. None when it doesn't exist or belongs to /// another user — the two are deliberately indistinguishable to callers. fn get_persona_chat( &mut self, user_id: i32, conversation_id: &str, ) -> Result, DbError>; /// Every conversation this user has going, newest first. Backs the chat /// list screen, so it returns the transcript blob too — the snippet is /// derived from the tree rather than denormalized into its own column. fn list_persona_chats(&mut self, user_id: i32) -> Result, DbError>; /// Start a new conversation with a persona, returning its id. Several /// conversations with the same persona are expected, so this never /// reuses an existing row. fn create_persona_chat( &mut self, user_id: i32, persona_id: &str, created_at: i64, ) -> Result; /// Start a new conversation anchored on a point in an SMS thread /// ("Continue with persona"). Same as `create_persona_chat` plus the /// anchor columns. fn create_anchored_persona_chat( &mut self, user_id: i32, persona_id: &str, created_at: i64, anchor: &SmsAnchor, ) -> Result; /// Replace a conversation's transcript. Called once per completed turn /// with the full new `messages_json`; `turn_count` is the cumulative /// assistant-turn count on the resulting active branch. Returns the /// number of rows written — 0 means the conversation is gone or is not /// this user's. fn update_persona_chat( &mut self, user_id: i32, conversation_id: &str, messages_json: &str, turn_count: i32, updated_at: i64, ) -> Result; /// Name a conversation. Written once, after the first turn produces /// enough of an exchange to summarize. fn set_persona_chat_title( &mut self, user_id: i32, conversation_id: &str, title: &str, ) -> Result<(), DbError>; /// Delete a conversation outright. Backs both the list screen's delete /// affordance and the reset endpoint. fn delete_persona_chat(&mut self, user_id: i32, conversation_id: &str) -> Result<(), DbError>; } pub struct SqlitePersonaDao { connection: Arc>, } impl Default for SqlitePersonaDao { fn default() -> Self { Self::new() } } impl SqlitePersonaDao { pub fn new() -> Self { Self { connection: Arc::new(Mutex::new(connect())), } } pub fn from_connection(conn: Arc>) -> Self { Self { connection: conn } } } impl PersonaDao for SqlitePersonaDao { fn list_personas(&mut self, uid: i32) -> Result, DbError> { trace_db_call("query", "list_personas", |_span| { use schema::personas::dsl::*; let mut conn = self.connection.lock().expect("PersonaDao lock"); personas .filter(user_id.eq(uid)) .order(created_at.asc()) .load::(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn get_persona(&mut self, uid: i32, pid: &str) -> Result, DbError> { trace_db_call("query", "get_persona", |_span| { use schema::personas::dsl::*; let mut conn = self.connection.lock().expect("PersonaDao lock"); personas .filter(user_id.eq(uid)) .filter(persona_id.eq(pid)) .first::(conn.deref_mut()) .optional() .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn create_persona( &mut self, uid: i32, pid: &str, nm: &str, prompt: &str, builtin: bool, include_all: bool, ) -> Result { trace_db_call("insert", "create_persona", |_span| { use schema::personas::dsl::*; let mut conn = self.connection.lock().expect("PersonaDao lock"); let now = chrono::Utc::now().timestamp_millis(); diesel::insert_into(personas) .values(InsertPersona { user_id: uid, persona_id: pid, name: nm, system_prompt: prompt, is_built_in: builtin, include_all_memories: include_all, created_at: now, updated_at: now, reviewed_only_facts: false, allow_agent_corrections: false, }) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Insert error: {}", e))?; personas .filter(user_id.eq(uid)) .filter(persona_id.eq(pid)) .first::(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::InsertError, e)) } fn update_persona( &mut self, uid: i32, pid: &str, patch: PersonaPatch, ) -> Result, DbError> { trace_db_call("update", "update_persona", |_span| { use schema::personas::dsl::*; let mut conn = self.connection.lock().expect("PersonaDao lock"); let now = chrono::Utc::now().timestamp_millis(); // Apply each field as its own UPDATE — keeps types simple // (Diesel's tuple updates don't compose cleanly across optional // columns) and matches the pattern already in use for entities // (knowledge_dao.rs::update_entity). if let Some(ref new_name) = patch.name { diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid))) .set((name.eq(new_name), updated_at.eq(now))) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Update name error: {}", e))?; } if let Some(ref new_prompt) = patch.system_prompt { diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid))) .set((system_prompt.eq(new_prompt), updated_at.eq(now))) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Update prompt error: {}", e))?; } if let Some(new_include_all) = patch.include_all_memories { diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid))) .set((include_all_memories.eq(new_include_all), updated_at.eq(now))) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Update include_all error: {}", e))?; } if let Some(new_reviewed_only) = patch.reviewed_only_facts { diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid))) .set(( reviewed_only_facts.eq(new_reviewed_only), updated_at.eq(now), )) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Update reviewed_only_facts error: {}", e))?; } if let Some(new_allow_corrections) = patch.allow_agent_corrections { diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid))) .set(( allow_agent_corrections.eq(new_allow_corrections), updated_at.eq(now), )) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Update allow_agent_corrections error: {}", e))?; } personas .filter(user_id.eq(uid)) .filter(persona_id.eq(pid)) .first::(conn.deref_mut()) .optional() .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::UpdateError, e)) } fn delete_persona(&mut self, uid: i32, pid: &str) -> Result { trace_db_call("delete", "delete_persona", |_span| { let mut conn = self.connection.lock().expect("PersonaDao lock"); // One transaction so a persona and its conversations can't get // out of step. There is no FK between the two tables, and the // chat list skips conversations whose persona is gone — so // without this the transcripts would linger invisibly forever. conn.deref_mut().transaction::<_, anyhow::Error, _>(|tx| { { use schema::persona_chat_conversations::dsl::*; diesel::delete( persona_chat_conversations .filter(user_id.eq(uid)) .filter(persona_id.eq(pid)), ) .execute(tx) .map_err(|e| anyhow::anyhow!("Delete error: {}", e))?; } use schema::personas::dsl::*; let n = diesel::delete(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid))) .execute(tx) .map_err(|e| anyhow::anyhow!("Delete error: {}", e))?; Ok(n > 0) }) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn bulk_import(&mut self, uid: i32, rows: &[ImportPersona]) -> Result { trace_db_call("insert", "bulk_import_personas", |_span| { let mut conn = self.connection.lock().expect("PersonaDao lock"); let now = chrono::Utc::now().timestamp_millis(); let mut inserted = 0usize; // INSERT OR IGNORE on the (user_id, persona_id) UNIQUE so // re-running migrate is a no-op for personas already on the // server. for p in rows { let n = diesel::sql_query( "INSERT OR IGNORE INTO personas (user_id, persona_id, name, system_prompt, \ is_built_in, include_all_memories, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, 0, ?, ?)", ) .bind::(uid) .bind::(&p.persona_id) .bind::(&p.name) .bind::(&p.system_prompt) .bind::(p.is_built_in) .bind::(p.created_at) .bind::(now) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Insert error: {}", e))?; inserted += n; } Ok(inserted) }) .map_err(|e| DbError::log(DbErrorKind::InsertError, e)) } fn get_persona_chat(&mut self, uid: i32, cid: &str) -> Result, DbError> { trace_db_call("query", "get_persona_chat", |_span| { use schema::persona_chat_conversations::dsl::*; let mut conn = self.connection.lock().expect("PersonaDao lock"); persona_chat_conversations .filter(conversation_id.eq(cid)) // Scoped by user as well as id: the id is a bearer token for // someone's transcript, so it never grants access on its own. .filter(user_id.eq(uid)) .select(persona_chat_select!()) .first::(conn.deref_mut()) .optional() .map(|opt| opt.map(persona_chat_row)) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn list_persona_chats(&mut self, uid: i32) -> Result, DbError> { trace_db_call("query", "list_persona_chats", |_span| { use schema::persona_chat_conversations::dsl::*; let mut conn = self.connection.lock().expect("PersonaDao lock"); persona_chat_conversations .filter(user_id.eq(uid)) .order(updated_at.desc()) .select(persona_chat_select!()) .load::(conn.deref_mut()) .map(|rows| rows.into_iter().map(persona_chat_row).collect()) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn create_persona_chat( &mut self, uid: i32, pid: &str, created: i64, ) -> Result { trace_db_call("insert", "create_persona_chat", |_span| { use schema::persona_chat_conversations::dsl::*; let mut conn = self.connection.lock().expect("PersonaDao lock"); let new_id = uuid::Uuid::new_v4().to_string(); diesel::insert_into(persona_chat_conversations) .values(( conversation_id.eq(&new_id), user_id.eq(uid), persona_id.eq(pid), title.eq(""), // The empty tree; the first turn seeds it with the // persona's system prompt. messages_json.eq("[]"), turn_count.eq(0), created_at.eq(created), updated_at.eq(created), )) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Insert error: {}", e))?; Ok(new_id) }) .map_err(|e| DbError::log(DbErrorKind::InsertError, e)) } fn create_anchored_persona_chat( &mut self, uid: i32, pid: &str, created: i64, anchor: &SmsAnchor, ) -> Result { trace_db_call("insert", "create_anchored_persona_chat", |_span| { use schema::persona_chat_conversations::dsl::*; let mut conn = self.connection.lock().expect("PersonaDao lock"); let new_id = uuid::Uuid::new_v4().to_string(); diesel::insert_into(persona_chat_conversations) .values(( conversation_id.eq(&new_id), user_id.eq(uid), persona_id.eq(pid), title.eq(""), messages_json.eq("[]"), turn_count.eq(0), created_at.eq(created), updated_at.eq(created), anchor_contact_id.eq(Some(anchor.contact_id)), anchor_contact_name.eq(Some(&anchor.contact_name)), anchor_message_id.eq(Some(anchor.message_id)), anchor_ts.eq(Some(anchor.timestamp)), anchor_context.eq(Some(&anchor.context)), )) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Insert error: {}", e))?; Ok(new_id) }) .map_err(|e| DbError::log(DbErrorKind::InsertError, e)) } fn update_persona_chat( &mut self, uid: i32, cid: &str, json: &str, count: i32, updated: i64, ) -> Result { trace_db_call("update", "update_persona_chat", |_span| { use schema::persona_chat_conversations::dsl::*; let mut conn = self.connection.lock().expect("PersonaDao lock"); diesel::update( persona_chat_conversations .filter(conversation_id.eq(cid)) .filter(user_id.eq(uid)), ) .set(( messages_json.eq(json), turn_count.eq(count), updated_at.eq(updated), )) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Update error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn set_persona_chat_title( &mut self, uid: i32, cid: &str, new_title: &str, ) -> Result<(), DbError> { trace_db_call("update", "set_persona_chat_title", |_span| { use schema::persona_chat_conversations::dsl::*; let mut conn = self.connection.lock().expect("PersonaDao lock"); diesel::update( persona_chat_conversations .filter(conversation_id.eq(cid)) .filter(user_id.eq(uid)), ) .set(title.eq(new_title)) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Update error: {}", e))?; Ok(()) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn delete_persona_chat(&mut self, uid: i32, cid: &str) -> Result<(), DbError> { trace_db_call("delete", "delete_persona_chat", |_span| { use schema::persona_chat_conversations::dsl::*; let mut conn = self.connection.lock().expect("PersonaDao lock"); diesel::delete( persona_chat_conversations .filter(conversation_id.eq(cid)) .filter(user_id.eq(uid)), ) .execute(conn.deref_mut()) .map_err(|e| anyhow::anyhow!("Delete error: {}", e))?; Ok(()) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } } #[cfg(test)] mod tests { use super::*; use crate::database::test::in_memory_db_connection; fn dao_with_user(username: &str) -> (SqlitePersonaDao, i32) { use crate::database::schema::users::dsl as u; let conn = Arc::new(Mutex::new(in_memory_db_connection())); diesel::insert_into(u::users) .values((u::username.eq(username), u::password.eq("x"))) .execute(conn.lock().unwrap().deref_mut()) .unwrap(); let user_id: i32 = u::users .filter(u::username.eq(username)) .select(u::id) .first(conn.lock().unwrap().deref_mut()) .unwrap(); (SqlitePersonaDao::from_connection(conn), user_id) } #[test] fn create_and_list_round_trip() { let (mut dao, uid) = dao_with_user("alice"); // The migration seeds 3 built-ins for any existing user; alice // was created post-migration so she starts empty. let p = dao .create_persona(uid, "custom-1", "Custom A", "prompt A", false, false) .unwrap(); assert_eq!(p.persona_id, "custom-1"); assert_eq!(p.user_id, uid); assert!(!p.is_built_in); let list = dao.list_personas(uid).unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].persona_id, "custom-1"); } #[test] fn unique_constraint_blocks_duplicate_persona_id() { let (mut dao, uid) = dao_with_user("bob"); dao.create_persona(uid, "x", "X", "p", false, false) .unwrap(); let err = dao.create_persona(uid, "x", "X2", "p2", false, false); assert!( err.is_err(), "second insert with same persona_id should fail" ); } #[test] fn bulk_import_is_idempotent() { let (mut dao, uid) = dao_with_user("carol"); let rows = vec![ ImportPersona { persona_id: "custom-a".into(), name: "A".into(), system_prompt: "p1".into(), is_built_in: false, created_at: 1, }, ImportPersona { persona_id: "custom-b".into(), name: "B".into(), system_prompt: "p2".into(), is_built_in: false, created_at: 2, }, ]; let first = dao.bulk_import(uid, &rows).unwrap(); assert_eq!(first, 2); let second = dao.bulk_import(uid, &rows).unwrap(); assert_eq!(second, 0, "re-import should insert nothing"); assert_eq!(dao.list_personas(uid).unwrap().len(), 2); } #[test] fn dao_update_does_not_block_built_ins() { // Documenting contract: the DAO is intentionally permissive — // `update_persona` will apply name/system_prompt edits to ANY // row, including built-ins. The guard against editing built-in // identity (name + systemPrompt) lives in the HTTP handler // (src/personas.rs::update_persona). If you find yourself // wanting to add the guard here too, prefer that — defence in // depth — but keep this test passing so anyone who removes // the handler guard gets a failing call site, not silent data // corruption. let (mut dao, uid) = dao_with_user("eve"); dao.create_persona(uid, "default", "Default", "old", true, false) .unwrap(); let updated = dao .update_persona( uid, "default", PersonaPatch { name: Some("Renamed".into()), system_prompt: Some("new prompt".into()), include_all_memories: None, reviewed_only_facts: None, allow_agent_corrections: None, }, ) .unwrap() .unwrap(); assert_eq!(updated.name, "Renamed"); assert_eq!(updated.system_prompt, "new prompt"); assert!( updated.is_built_in, "is_built_in flag should be unchanged by patch" ); } #[test] fn update_toggles_include_all_memories() { let (mut dao, uid) = dao_with_user("dan"); dao.create_persona(uid, "j", "Journal", "p", true, false) .unwrap(); let updated = dao .update_persona( uid, "j", PersonaPatch { name: None, system_prompt: None, include_all_memories: Some(true), reviewed_only_facts: None, allow_agent_corrections: None, }, ) .unwrap() .unwrap(); assert!(updated.include_all_memories); } // ── Persona-chat DAO tests ───────────────────────────────────── /// Second user, for the isolation tests. fn second_user(dao: &SqlitePersonaDao, username: &str) -> i32 { use crate::database::schema::users::dsl as u; let conn = dao.connection.clone(); diesel::insert_into(u::users) .values((u::username.eq(username), u::password.eq("x"))) .execute(conn.lock().unwrap().deref_mut()) .unwrap(); u::users .filter(u::username.eq(username)) .select(u::id) .first(conn.lock().unwrap().deref_mut()) .unwrap() } #[test] fn persona_chat_get_returns_none_for_an_unknown_conversation() { let (mut dao, uid) = dao_with_user("p1"); assert!(dao.get_persona_chat(uid, "no-such-id").unwrap().is_none()); } #[test] fn persona_chat_create_then_get_round_trip() { let (mut dao, uid) = dao_with_user("p2"); let cid = dao.create_persona_chat(uid, "journal", 100).unwrap(); let row = dao.get_persona_chat(uid, &cid).unwrap().unwrap(); assert_eq!(row.conversation_id, cid); assert_eq!(row.persona_id, "journal"); assert_eq!(row.title, "", "unnamed until the first turn completes"); assert_eq!(row.turn_count, 0); assert_eq!(row.created_at, 100); assert_eq!(row.updated_at, 100); } #[test] fn persona_chat_create_makes_a_separate_row_each_time() { // The whole point of the conversation_id key: one persona, several // independent threads. let (mut dao, uid) = dao_with_user("p3"); let first = dao.create_persona_chat(uid, "journal", 100).unwrap(); let second = dao.create_persona_chat(uid, "journal", 200).unwrap(); assert_ne!(first, second); assert_eq!(dao.list_persona_chats(uid).unwrap().len(), 2); } #[test] fn persona_chat_update_replaces_the_transcript() { let (mut dao, uid) = dao_with_user("p4"); let cid = dao.create_persona_chat(uid, "journal", 100).unwrap(); let rows = dao.update_persona_chat(uid, &cid, "first", 1, 150).unwrap(); assert_eq!(rows, 1); let rows = dao .update_persona_chat(uid, &cid, "second", 2, 200) .unwrap(); assert_eq!(rows, 1); let row = dao.get_persona_chat(uid, &cid).unwrap().unwrap(); assert_eq!(row.messages_json, "second"); assert_eq!(row.turn_count, 2); assert_eq!(row.updated_at, 200); assert_eq!(row.created_at, 100, "creation time is not disturbed"); } #[test] fn persona_chat_update_reports_zero_rows_for_an_unknown_conversation() { // The turn loop treats 0 as "the conversation went away mid-turn" // rather than silently succeeding. let (mut dao, uid) = dao_with_user("p5"); let rows = dao .update_persona_chat(uid, "no-such-id", "x", 1, 1) .unwrap(); assert_eq!(rows, 0); } #[test] fn persona_chat_set_title_names_the_conversation() { let (mut dao, uid) = dao_with_user("p6"); let cid = dao.create_persona_chat(uid, "journal", 100).unwrap(); dao.set_persona_chat_title(uid, &cid, "June recap").unwrap(); let row = dao.get_persona_chat(uid, &cid).unwrap().unwrap(); assert_eq!(row.title, "June recap"); } #[test] fn persona_chat_list_orders_newest_first() { let (mut dao, uid) = dao_with_user("p7"); let older = dao.create_persona_chat(uid, "journal", 100).unwrap(); let newer = dao.create_persona_chat(uid, "coach", 300).unwrap(); let middle = dao.create_persona_chat(uid, "default", 200).unwrap(); let ids: Vec = dao .list_persona_chats(uid) .unwrap() .into_iter() .map(|r| r.conversation_id) .collect(); assert_eq!( ids, vec![newer, middle, older], "ordered by updated_at descending so the list screen needs no re-sort" ); } #[test] fn persona_chat_anchor_round_trips_and_open_chats_have_none() { let (mut dao, uid) = dao_with_user("p10"); let anchor = SmsAnchor { contact_id: 42, contact_name: "Sam".to_string(), message_id: 9001, timestamp: 1_700_000_000, context: "[2023-11-14 22:13] Sam: see you there".to_string(), }; let anchored = dao .create_anchored_persona_chat(uid, "journal", 100, &anchor) .unwrap(); let open = dao.create_persona_chat(uid, "journal", 200).unwrap(); let row = dao.get_persona_chat(uid, &anchored).unwrap().unwrap(); assert_eq!(row.anchor, Some(anchor)); let row = dao.get_persona_chat(uid, &open).unwrap().unwrap(); assert_eq!(row.anchor, None); assert!( dao.list_persona_chats(uid) .unwrap() .iter() .any(|r| r.anchor.is_some()), "the list query carries the anchor too" ); } #[test] fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() { let (mut dao, uid) = dao_with_user("p8"); assert!(dao.list_persona_chats(uid).unwrap().is_empty()); } #[test] fn persona_chat_delete_removes_the_conversation() { let (mut dao, uid) = dao_with_user("p9"); let cid = dao.create_persona_chat(uid, "journal", 100).unwrap(); dao.delete_persona_chat(uid, &cid).unwrap(); assert!(dao.get_persona_chat(uid, &cid).unwrap().is_none()); assert!(dao.list_persona_chats(uid).unwrap().is_empty()); } #[test] fn deleting_a_persona_takes_its_conversations_with_it() { // No FK between the tables, and the chat list hides conversations // whose persona is gone — so an orphan row would be an invisible, // unreachable transcript sitting in the database forever. let (mut dao, uid) = dao_with_user("p-cascade"); dao.create_persona(uid, "custom-1", "Custom", "prompt", false, false) .unwrap(); dao.create_persona_chat(uid, "custom-1", 100).unwrap(); dao.create_persona_chat(uid, "custom-1", 200).unwrap(); let survivor = dao.create_persona_chat(uid, "other", 300).unwrap(); assert!(dao.delete_persona(uid, "custom-1").unwrap()); let remaining: Vec = dao .list_persona_chats(uid) .unwrap() .into_iter() .map(|r| r.conversation_id) .collect(); assert_eq!( remaining, vec![survivor], "only the untouched persona's conversation survives" ); } #[test] fn persona_chat_reads_and_writes_are_scoped_to_the_owner() { // A conversation_id is a bearer token for someone's transcript, so // holding one must not grant another user access to it. let (mut dao, uid1) = dao_with_user("owner"); let uid2 = second_user(&dao, "intruder"); let cid = dao.create_persona_chat(uid1, "journal", 100).unwrap(); dao.update_persona_chat(uid1, &cid, "private", 1, 100) .unwrap(); assert!( dao.get_persona_chat(uid2, &cid).unwrap().is_none(), "another user cannot read it" ); assert_eq!( dao.update_persona_chat(uid2, &cid, "tampered", 9, 999) .unwrap(), 0, "another user cannot write it" ); dao.delete_persona_chat(uid2, &cid).unwrap(); let row = dao.get_persona_chat(uid1, &cid).unwrap().unwrap(); assert_eq!(row.messages_json, "private", "and cannot delete it"); assert!(dao.list_persona_chats(uid2).unwrap().is_empty()); } }