import { describe, it, expect, afterAll, beforeAll } from "vitest"; import { execSync } from "node:child_process"; // Loads .env (DATABASE_URL) without printing anything. import "../../scripts/load-env"; const hasDb = (() => { try { execSync(`psql "${process.env.DATABASE_URL ?? "postgres://localhost:5432/polyllm"}" -Atc "select 1"`, { stdio: "ignore" }); return true; } catch { return false; } })(); /** * Exercises the real SQL of workstream G against the local database (needs drizzle/0003_search_index applied): * full-text search with filters, ILIKE fallback, cursor pagination, selective share, share listing/revocation, exports. */ describe.skipIf(!hasDb)("integration: search + share + export", () => { const uid = `it_g_${Date.now()}`; let convA = ""; let convB = ""; let projectId = ""; let msgIds: string[] = []; beforeAll(async () => { const { getDb, users, conversations, messages, projects, folders } = await import("@/db"); const db = getDb(); await db.insert(users).values({ id: uid, name: "Search IT", email: `${uid}@polyllm.test`, emailVerified: true }); projectId = `${uid}_prj`; await db.insert(projects).values({ id: projectId, userId: uid, name: "Quarterly Research" }); const folderId = `${uid}_fld`; await db.insert(folders).values({ id: folderId, userId: uid, name: "Clients" }); convA = `${uid}_cnv_a`; convB = `${uid}_cnv_b`; const old = new Date("2026-01-15T10:00:00Z"); const recent = new Date("2026-09-10T10:00:00Z"); await db.insert(conversations).values([ { id: convA, userId: uid, title: "Gemini rate limits explained", modelKey: "gemini/gemini-3-flash", provider: "gemini", projectId, pinned: true, messageCount: 3, lastMessageAt: recent, updatedAt: recent, createdAt: recent }, { id: convB, userId: uid, title: "Old Claude brainstorm", modelKey: "anthropic/claude-sonnet-5", provider: "anthropic", folderId, archived: true, messageCount: 1, lastMessageAt: old, updatedAt: old, createdAt: old }, ]); msgIds = [`${uid}_m1`, `${uid}_m2`, `${uid}_m3`, `${uid}_m4`]; await db.insert(messages).values([ { id: msgIds[0], conversationId: convA, userId: uid, role: "user", content: "Why do I get 429 errors with the Gemini free tier?", parts: [{ type: "text", text: "Why do I get 429 errors with the Gemini free tier?" }], createdAt: new Date("2026-09-10T10:00:00Z") }, { id: msgIds[1], conversationId: convA, userId: uid, role: "assistant", modelKey: "gemini/gemini-3-flash", provider: "gemini", content: "Free-tier keys have **tiny per-model daily quotas**. Reconfiguring the project does not help.", parts: [{ type: "text", text: "Free-tier keys have tiny quotas." }], createdAt: new Date("2026-09-10T10:00:05Z") }, { id: msgIds[2], conversationId: convA, userId: uid, role: "assistant", modelKey: "gemini/gemini-3-flash", provider: "gemini", content: "This one is inactive and must never show up: zebrafish", parts: [], active: false, createdAt: new Date("2026-09-10T10:00:06Z") }, { id: msgIds[3], conversationId: convB, userId: uid, role: "assistant", modelKey: "anthropic/claude-sonnet-5", provider: "anthropic", content: "Brainstorm: quotas for a research budget.", parts: [], createdAt: old }, ]); }); afterAll(async () => { const { getDb, users, closeDb } = await import("@/db"); const { eq } = await import("drizzle-orm"); await getDb().delete(users).where(eq(users.id, uid)); // cascades to everything else await closeDb(); }); it("finds titles and message content with full-text search and highlights terms", async () => { const { searchAll } = await import("@/lib/search/service"); const res = await searchAll(uid, "quotas", { limit: 10 }); expect(res.engine).toBe("fts"); expect(res.messages.map((m) => m.id).sort()).toEqual([msgIds[1], msgIds[3]].sort()); expect(res.messages.every((m) => !m.id.includes("_m3"))).toBe(true); // inactive excluded expect(res.messages.find((m) => m.id === msgIds[1])?.snippet).toContain("quotas"); expect(res.query.terms).toEqual(["quotas"]); const byTitle = await searchAll(uid, "rate limits", { limit: 10 }); expect(byTitle.conversations.map((c) => c.id)).toEqual([convA]); expect(byTitle.conversations[0].pinned).toBe(true); expect(byTitle.conversations[0].shared).toBe(false); }); it("supports prefix matching and falls back to ILIKE for word fragments", async () => { const { searchAll } = await import("@/lib/search/service"); const prefix = await searchAll(uid, "quot", { limit: 10 }); expect(prefix.messages.length).toBe(2); const fragment = await searchAll(uid, "configur", { limit: 10 }); // inside "Reconfiguring" expect(fragment.engine).toBe("ilike"); expect(fragment.messages.map((m) => m.id)).toEqual([msgIds[1]]); }); it("applies filters: project, folder, provider, model, role, dates and flags", async () => { const { searchAll } = await import("@/lib/search/service"); expect((await searchAll(uid, "quotas project:quarterly")).messages.map((m) => m.id)).toEqual([msgIds[1]]); expect((await searchAll(uid, "quotas folder:clients")).messages.map((m) => m.id)).toEqual([msgIds[3]]); expect((await searchAll(uid, "quotas provider:anthropic")).messages.map((m) => m.id)).toEqual([msgIds[3]]); expect((await searchAll(uid, "quotas model:gemini-3")).messages.map((m) => m.id)).toEqual([msgIds[1]]); expect((await searchAll(uid, "gemini role:user")).messages.map((m) => m.id)).toEqual([msgIds[0]]); expect((await searchAll(uid, "gemini role:user")).conversations).toEqual([]); // role hides the conversation group expect((await searchAll(uid, "quotas after:2026-06-01")).messages.map((m) => m.id)).toEqual([msgIds[1]]); expect((await searchAll(uid, "quotas before:2026-06-01")).messages.map((m) => m.id)).toEqual([msgIds[3]]); expect((await searchAll(uid, "is:pinned")).conversations.map((c) => c.id)).toEqual([convA]); expect((await searchAll(uid, "is:archived")).conversations.map((c) => c.id)).toEqual([convB]); expect((await searchAll(uid, "quotas project:does-not-exist")).messages).toEqual([]); expect((await searchAll(uid, "role:assistant after:2026-09-01")).messages.map((m) => m.id)).toEqual([msgIds[1]]); }); it("paginates messages with an opaque cursor", async () => { const { searchAll } = await import("@/lib/search/service"); const p1 = await searchAll(uid, "quotas", { limit: 1 }); expect(p1.messages.length).toBe(1); expect(p1.nextCursor).toBeTruthy(); const p2 = await searchAll(uid, "quotas", { limit: 1, cursor: p1.nextCursor }); expect(p2.messages.length).toBe(1); expect(p2.messages[0].id).not.toBe(p1.messages[0].id); expect(p2.models).toEqual([]); // secondary groups only on the first page }); it("shares whole conversations (upsert) and selected messages (new link), lists and revokes", async () => { const { shareConversation, listShares, revokeShareById, getPublicShare, readShareMeta } = await import("@/lib/conversations/service"); const { searchAll } = await import("@/lib/search/service"); const full1 = await shareConversation(uid, convA); const full2 = await shareConversation(uid, convA); expect(full1.id).toBe(full2.id); // same URL refreshed expect(full1.partial).toBe(false); expect(full1.messageCount).toBe(2); // inactive message excluded const partial = await shareConversation(uid, convA, { messageIds: [msgIds[1]] }); expect(partial.id).not.toBe(full1.id); expect(partial.partial).toBe(true); expect(partial.messageCount).toBe(1); const list = await listShares(uid); expect(list.map((s) => s.id).sort()).toEqual([full1.id, partial.id].sort()); expect(list.find((s) => s.id === partial.id)?.partial).toBe(true); const pub = await getPublicShare(partial.id, { peek: true }); expect(pub).not.toBeNull(); expect(readShareMeta(pub!.snapshot)?.selectedCount).toBe(1); expect((pub!.snapshot as { role?: string }[]).filter((m) => m.role).length).toBe(1); expect((await searchAll(uid, "is:shared")).conversations.map((c) => c.id)).toEqual([convA]); await revokeShareById(uid, partial.id); expect((await listShares(uid)).map((s) => s.id)).toEqual([full1.id]); expect(await getPublicShare(partial.id, { peek: true })).toBeNull(); await expect(revokeShareById(uid, partial.id)).rejects.toThrow(); }); it("exports txt and html", async () => { const { exportConversation } = await import("@/lib/conversations/service"); const txt = await exportConversation(uid, convA, "txt"); expect(txt.contentType).toContain("text/plain"); expect(txt.body).toContain("GEMINI RATE LIMITS EXPLAINED"); expect(txt.body).toContain("Assistant ยท gemini/gemini-3-flash"); expect(txt.body).not.toContain("zebrafish"); const html = await exportConversation(uid, convA, "html", { print: true, appUrl: "https://www.polyllm.io" }); expect(html.contentType).toContain("text/html"); expect(html.body).toContain("tiny per-model daily quotas"); expect(html.body).toContain("window.print()"); expect(html.filename).toBe("gemini-rate-limits-explained.html"); }); });