import { and, desc, eq, isNull, lt } from "drizzle-orm"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { chatConversations, chatMessages } from "@paperclipai/db"; import { chatService } from "../services/chat.js"; /** * Build a query chain mock. * - `selectResult`: what `db.select()...limit()` resolves to (array) * - `returningResult`: what `db.insert/update()...returning()` resolves to (array) */ function makeSelectChain(rows: unknown[], opts: { terminalAtWhere?: boolean } = {}) { const chain: any = {}; const methods = ["select", "from", "orderBy"]; for (const m of methods) { chain[m] = vi.fn(() => chain); } chain.limit = vi.fn(() => Promise.resolve(rows)); if (opts.terminalAtWhere) { // getConversation: select().from().where() — no orderBy/limit chain.where = vi.fn(() => Promise.resolve(rows)); } else { // listConversations / listMessages: select().from().where().orderBy().limit() chain.where = vi.fn(() => chain); } return chain; } function makeUpdateChain(rows: unknown[]) { const chain: any = {}; chain.set = vi.fn(() => chain); chain.where = vi.fn(() => chain); chain.returning = vi.fn(() => Promise.resolve(rows)); return chain; } function makeInsertChain(rows: unknown[]) { const chain: any = {}; chain.values = vi.fn(() => chain); chain.returning = vi.fn(() => Promise.resolve(rows)); return chain; } function makeDb() { return { select: vi.fn(), insert: vi.fn(), update: vi.fn(), delete: vi.fn(), }; } describe("chatService", () => { let db: ReturnType; beforeEach(() => { vi.clearAllMocks(); db = makeDb(); }); describe("createConversation", () => { it("inserts into chatConversations and returns the created row", async () => { const inserted = { id: "conv-1", companyId: "company-1", title: "Hello", agentId: null, pinnedAt: null, archivedAt: null, deletedAt: null, createdAt: new Date(), updatedAt: new Date(), }; const chain = makeInsertChain([inserted]); db.insert.mockReturnValue(chain); const svc = chatService(db as any); const result = await svc.createConversation("company-1", { title: "Hello" }); expect(db.insert).toHaveBeenCalledWith(chatConversations); expect(chain.values).toHaveBeenCalledWith( expect.objectContaining({ companyId: "company-1", title: "Hello" }), ); expect(result).toEqual(inserted); }); it("returns the created conversation with id and timestamps", async () => { const now = new Date(); const inserted = { id: "conv-1", companyId: "company-1", title: null, agentId: null, pinnedAt: null, archivedAt: null, deletedAt: null, createdAt: now, updatedAt: now, }; const chain = makeInsertChain([inserted]); db.insert.mockReturnValue(chain); const svc = chatService(db as any); const result = await svc.createConversation("company-1", {}); expect(result.id).toBe("conv-1"); expect(result.createdAt).toBe(now); }); }); describe("listConversations", () => { it("returns conversations sorted by updatedAt DESC", async () => { const rows = [ { id: "conv-2", updatedAt: new Date("2024-01-02") }, { id: "conv-1", updatedAt: new Date("2024-01-01") }, ]; const chain = makeSelectChain(rows); db.select.mockReturnValue(chain); const svc = chatService(db as any); await svc.listConversations("company-1", {}); expect(db.select).toHaveBeenCalled(); expect(chain.orderBy).toHaveBeenCalledWith(desc(chatConversations.updatedAt)); }); it("excludes soft-deleted conversations", async () => { const chain = makeSelectChain([]); db.select.mockReturnValue(chain); const svc = chatService(db as any); await svc.listConversations("company-1", {}); // where should be called with a combined condition that includes isNull(deletedAt) expect(chain.where).toHaveBeenCalled(); }); it("supports cursor-based pagination with hasMore", async () => { // Return limit+1 rows to trigger hasMore=true const rows = Array.from({ length: 31 }, (_, i) => ({ id: `conv-${i}`, updatedAt: new Date(2024, 0, 31 - i), })); const chain = makeSelectChain(rows); db.select.mockReturnValue(chain); const svc = chatService(db as any); const result = await svc.listConversations("company-1", { limit: 30 }); expect(result.hasMore).toBe(true); expect(result.items.length).toBe(30); }); it("limits results to max 100", async () => { const chain = makeSelectChain([]); db.select.mockReturnValue(chain); const svc = chatService(db as any); await svc.listConversations("company-1", { limit: 9999 }); // limit should be called with 101 (100 + 1 for hasMore detection) expect(chain.limit).toHaveBeenCalledWith(101); }); }); describe("getConversation", () => { it("returns conversation by id", async () => { const conv = { id: "conv-1", companyId: "company-1", deletedAt: null }; const chain = makeSelectChain([conv], { terminalAtWhere: true }); db.select.mockReturnValue(chain); const svc = chatService(db as any); const result = await svc.getConversation("conv-1"); expect(result).toEqual(conv); }); it("throws notFound for non-existent conversation", async () => { const chain = makeSelectChain([], { terminalAtWhere: true }); db.select.mockReturnValue(chain); const svc = chatService(db as any); await expect(svc.getConversation("no-such-id")).rejects.toMatchObject({ status: 404, }); }); it("throws notFound for soft-deleted conversation", async () => { // When deletedAt is set, the WHERE clause won't match — returns empty const chain = makeSelectChain([], { terminalAtWhere: true }); db.select.mockReturnValue(chain); const svc = chatService(db as any); await expect(svc.getConversation("deleted-id")).rejects.toMatchObject({ status: 404, }); }); }); describe("updateConversation", () => { it("updates title and bumps updatedAt", async () => { const updated = { id: "conv-1", title: "New Title", updatedAt: new Date() }; const chain = makeUpdateChain([updated]); db.update.mockReturnValue(chain); const svc = chatService(db as any); const result = await svc.updateConversation("conv-1", { title: "New Title" }); expect(db.update).toHaveBeenCalledWith(chatConversations); expect(chain.set).toHaveBeenCalledWith( expect.objectContaining({ title: "New Title", updatedAt: expect.any(Date) }), ); expect(result).toEqual(updated); }); it("sets pinnedAt timestamp when provided as string", async () => { const pinnedAt = "2024-06-01T00:00:00.000Z"; const updated = { id: "conv-1", pinnedAt: new Date(pinnedAt) }; const chain = makeUpdateChain([updated]); db.update.mockReturnValue(chain); const svc = chatService(db as any); await svc.updateConversation("conv-1", { pinnedAt }); expect(chain.set).toHaveBeenCalledWith( expect.objectContaining({ pinnedAt: new Date(pinnedAt) }), ); }); it("clears pinnedAt when set to null", async () => { const updated = { id: "conv-1", pinnedAt: null }; const chain = makeUpdateChain([updated]); db.update.mockReturnValue(chain); const svc = chatService(db as any); await svc.updateConversation("conv-1", { pinnedAt: null }); expect(chain.set).toHaveBeenCalledWith( expect.objectContaining({ pinnedAt: null }), ); }); it("bumps updatedAt on every update", async () => { const before = new Date("2024-01-01"); const updated = { id: "conv-1", updatedAt: new Date() }; const chain = makeUpdateChain([updated]); db.update.mockReturnValue(chain); const svc = chatService(db as any); await svc.updateConversation("conv-1", { title: "x" }); const setCall = chain.set.mock.calls[0][0]; expect(setCall.updatedAt).toBeInstanceOf(Date); expect(setCall.updatedAt.getTime()).toBeGreaterThan(before.getTime()); }); }); describe("softDeleteConversation", () => { it("sets deletedAt and returns the updated row", async () => { const deleted = { id: "conv-1", deletedAt: new Date() }; const chain = makeUpdateChain([deleted]); db.update.mockReturnValue(chain); const svc = chatService(db as any); const result = await svc.softDeleteConversation("conv-1"); expect(db.update).toHaveBeenCalledWith(chatConversations); expect(chain.set).toHaveBeenCalledWith( expect.objectContaining({ deletedAt: expect.any(Date) }), ); expect(result).toEqual(deleted); }); it("throws notFound if conversation is already deleted (no row returned)", async () => { const chain = makeUpdateChain([]); db.update.mockReturnValue(chain); const svc = chatService(db as any); await expect(svc.softDeleteConversation("gone-id")).rejects.toMatchObject({ status: 404, }); }); }); describe("addMessage", () => { it("inserts a message row with conversationId and role", async () => { const message = { id: "msg-1", conversationId: "conv-1", role: "user", content: "Hi" }; const insertChain = makeInsertChain([message]); const updateChain = makeUpdateChain([]); db.insert.mockReturnValue(insertChain); db.update.mockReturnValue(updateChain); const svc = chatService(db as any); const result = await svc.addMessage("conv-1", { role: "user", content: "Hi" }); expect(db.insert).toHaveBeenCalledWith(chatMessages); expect(insertChain.values).toHaveBeenCalledWith( expect.objectContaining({ conversationId: "conv-1", role: "user", content: "Hi" }), ); expect(result).toEqual({ ...message, files: [] }); }); it("bumps conversation updatedAt after insert (Pitfall 3)", async () => { const message = { id: "msg-1", conversationId: "conv-1", role: "user", content: "Hi" }; const insertChain = makeInsertChain([message]); const updateChain = makeUpdateChain([]); db.insert.mockReturnValue(insertChain); db.update.mockReturnValue(updateChain); const svc = chatService(db as any); await svc.addMessage("conv-1", { role: "user", content: "Hi" }); expect(db.update).toHaveBeenCalledWith(chatConversations); }); it("auto-sets title from first user message when title is null (Pitfall 5)", async () => { const content = "This is a long message that should be truncated to 60 chars when used as title"; const message = { id: "msg-1", conversationId: "conv-1", role: "user", content }; const insertChain = makeInsertChain([message]); const updateChain = makeUpdateChain([]); db.insert.mockReturnValue(insertChain); db.update.mockReturnValue(updateChain); const svc = chatService(db as any); await svc.addMessage("conv-1", { role: "user", content }); // Should be called twice: once for updatedAt bump, once for title auto-set expect(db.update).toHaveBeenCalledTimes(2); }); it("does not auto-set title for assistant/system messages", async () => { const content = "Response from assistant"; const message = { id: "msg-1", conversationId: "conv-1", role: "assistant", content }; const insertChain = makeInsertChain([message]); const updateChain = makeUpdateChain([]); db.insert.mockReturnValue(insertChain); db.update.mockReturnValue(updateChain); const svc = chatService(db as any); await svc.addMessage("conv-1", { role: "assistant", content }); // Only one update: updatedAt bump (no auto-title for non-user messages) expect(db.update).toHaveBeenCalledTimes(1); }); }); describe("listMessages", () => { it("returns messages for conversation sorted by createdAt DESC", async () => { const rows = [ { id: "msg-2", createdAt: new Date("2024-01-02") }, { id: "msg-1", createdAt: new Date("2024-01-01") }, ]; const chain = makeSelectChain(rows); db.select.mockReturnValue(chain); const svc = chatService(db as any); await svc.listMessages("conv-1", {}); expect(db.select).toHaveBeenCalled(); expect(chain.orderBy).toHaveBeenCalledWith(desc(chatMessages.createdAt)); }); it("supports cursor-based pagination", async () => { const rows = Array.from({ length: 51 }, (_, i) => ({ id: `msg-${i}`, createdAt: new Date(2024, 0, 51 - i), })); const chain = makeSelectChain(rows); db.select.mockReturnValue(chain); const svc = chatService(db as any); const result = await svc.listMessages("conv-1", { limit: 50 }); expect(result.hasMore).toBe(true); expect(result.items.length).toBe(50); }); }); describe("searchMessages", () => { it.todo("returns ranked results for matching term"); it.todo("returns empty for no match"); it.todo("respects companyId scope"); }); describe("toggleBookmark", () => { it.todo("creates bookmark when not exists"); it.todo("removes bookmark when exists"); }); describe("branchConversation", () => { it.todo("creates child conversation with copied messages"); it.todo("throws not found for invalid message id"); }); describe("exportConversation", () => { it.todo("exports as markdown with agent names"); it.todo("exports as JSON with all messages"); }); });