import { Database } from "bun:sqlite"; import { describe, expect, test } from "bun:test"; import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/bun-sqlite"; import * as schema from "../db/schema"; import { deleteChatConversation, deriveConversationTitle, getChatConversation, listChatConversations, loadChatMessages, renameChatConversation, saveChatConversation, } from "./store"; // Real in-memory bun:sqlite DB with just the chat tables (same DDL as the // generated migrations) — the store is stateful CRUD, so a fake chain would // test nothing. function makeDb() { const sqlite = new Database(":memory:"); sqlite.run(`CREATE TABLE chat_conversations ( id text PRIMARY KEY NOT NULL, user_id text NOT NULL, organization_id text, title text, created_at integer DEFAULT (unixepoch()), updated_at integer DEFAULT (unixepoch()) )`); sqlite.run(`CREATE TABLE chat_messages ( id text PRIMARY KEY NOT NULL, conversation_id text NOT NULL, position integer NOT NULL, message text NOT NULL, created_at integer DEFAULT (unixepoch()) )`); return drizzle({ client: sqlite, schema }); } const alice = { userId: "alice", organizationId: "org1" }; const aliceElsewhere = { userId: "alice", organizationId: "org2" }; const mallory = { userId: "mallory", organizationId: "org1" }; const localUser = { userId: "local", organizationId: null }; const userMsg = (id: string, text: string) => ({ id, role: "user", parts: [{ type: "text", text }], }); const assistantMsg = (id: string, text: string) => ({ id, role: "assistant", parts: [ { type: "text", text }, { type: "tool-generateReport", state: "output-available", output: { report: { totalEvents: 3 } }, }, ], }); describe("chat store", () => { test("save → load roundtrips UIMessages losslessly, including tool parts", async () => { const db = makeDb(); const messages = [ userMsg("m1", "What did I work on?"), assistantMsg("m2", "Here's your week."), ]; expect( await saveChatConversation(db, alice, { conversationId: "c1", messages, }), ).toBe(true); const loaded = await loadChatMessages(db, alice, "c1"); expect(loaded).toEqual(messages); }); test("first save derives the title from the first user message", async () => { const db = makeDb(); await saveChatConversation(db, alice, { conversationId: "c1", messages: [userMsg("m1", " Where did my time go this month? ")], }); const conversation = await getChatConversation(db, alice, "c1"); expect(conversation?.title).toBe("Where did my time go this month?"); }); test("re-save replaces the message list (regenerate converges, no duplicates)", async () => { const db = makeDb(); await saveChatConversation(db, alice, { conversationId: "c1", messages: [userMsg("m1", "hi"), assistantMsg("m2", "first answer")], }); await saveChatConversation(db, alice, { conversationId: "c1", messages: [userMsg("m1", "hi"), assistantMsg("m3", "regenerated answer")], }); const loaded = await loadChatMessages(db, alice, "c1"); expect(loaded?.length).toBe(2); expect((loaded?.[1] as { id: string }).id).toBe("m3"); }); test("ownership: another user (or same user in another org) cannot read, overwrite, rename, or delete", async () => { const db = makeDb(); await saveChatConversation(db, alice, { conversationId: "c1", messages: [userMsg("m1", "secret plans")], }); expect(await loadChatMessages(db, mallory, "c1")).toBeNull(); expect(await loadChatMessages(db, aliceElsewhere, "c1")).toBeNull(); expect( await saveChatConversation(db, mallory, { conversationId: "c1", messages: [userMsg("mx", "hijacked")], }), ).toBe(false); expect(await renameChatConversation(db, mallory, "c1", "pwned")).toBe( false, ); expect(await deleteChatConversation(db, mallory, "c1")).toBe(false); // The rightful owner still sees the original content. const loaded = await loadChatMessages(db, alice, "c1"); expect((loaded?.[0] as { id: string }).id).toBe("m1"); }); test("local mode (organizationId null) scopes with IS NULL, not a stray equality", async () => { const db = makeDb(); await saveChatConversation(db, localUser, { conversationId: "c-local", messages: [userMsg("m1", "local chat")], }); expect(await loadChatMessages(db, localUser, "c-local")).not.toBeNull(); expect( await loadChatMessages( db, { userId: "local", organizationId: "org1" }, "c-local", ), ).toBeNull(); }); test("list is newest-updated first and only the owner's", async () => { const db = makeDb(); await saveChatConversation(db, alice, { conversationId: "c1", messages: [userMsg("m1", "older")], }); await saveChatConversation(db, mallory, { conversationId: "c2", messages: [userMsg("m2", "not alice's")], }); await saveChatConversation(db, alice, { conversationId: "c3", messages: [userMsg("m3", "newer")], }); // updated_at has second granularity; bump c3 explicitly so ordering is // deterministic instead of racing the wall clock. await db .update(schema.chatConversations) .set({ updatedAt: new Date(Date.now() + 60_000) }) .where(eq(schema.chatConversations.id, "c3")); const list = await listChatConversations(db, alice); expect(list.map((c) => c.id)).toEqual(["c3", "c1"]); }); test("rename and delete work for the owner; delete removes messages too", async () => { const db = makeDb(); await saveChatConversation(db, alice, { conversationId: "c1", messages: [userMsg("m1", "hello")], }); expect( await renameChatConversation(db, alice, "c1", "My report chat"), ).toBe(true); expect((await getChatConversation(db, alice, "c1"))?.title).toBe( "My report chat", ); expect(await deleteChatConversation(db, alice, "c1")).toBe(true); expect(await getChatConversation(db, alice, "c1")).toBeNull(); expect(await loadChatMessages(db, alice, "c1")).toBeNull(); }); }); describe("deriveConversationTitle", () => { test("truncates long first messages with an ellipsis", () => { const long = "x".repeat(200); const title = deriveConversationTitle([userMsg("m1", long)]); expect(title?.length).toBe(80); expect(title?.endsWith("…")).toBe(true); }); test("null when there is no user text", () => { expect(deriveConversationTitle([assistantMsg("m1", "hi")])).toBeNull(); expect(deriveConversationTitle([])).toBeNull(); }); });