/** * TASK-0122 — Ask AI chat (SSE) integration suite. * * Runs the full stack (AppModule + Postgres + Redis) against the * deterministic `MockProvider` (env `AI_PROVIDER=mock`) and covers the * spec's integration acceptance criteria: * * - conversation CRUD + ownership isolation (user A cannot GET user B's * conversation → 404 — existence hidden across the boundary); * - the SSE stream contract: content-type, frame sequence * (delta* → usage → done), auto-title, context inclusion; * - context binding: the mock echoes the system-prompt tail, so a question * about a page's seeded content answers with THAT content, and a user * without read access to the referenced page gets a context-unavailable * notice — NOT the page content (no page text in the prompt); * - budget exhaustion → canonical 402 AI_BUDGET_EXCEEDED (pre-stream); * - retry (`retryOf`) re-sends without duplicating the user message row; * - abort → partial assistant message with `metadata.truncated = true`; * - per-user 30 msg/min rate limit → 429 RATE_LIMITED + retryAfterSeconds; * - the `ai.conversation_created` audit event. */ // Config must be fixed BEFORE the app boots (loadEnv + the workspace cache are // both lazily primed on first use). process.env.AI_PROVIDER = "mock"; process.env.SEARCH_VECTOR_DIMENSION = "8"; // Pace the mock's deltas (one per word) so the mid-stream-cancel test can // abort BETWEEN deltas deterministically. 25 ms × ~13 deltas ≈ 325 ms per // stream — the client lands its abort (after 3 deltas ≈ 75 ms) well before // the server finishes, so the partial-persist path is guaranteed. process.env.AI_MOCK_DELTA_DELAY_MS = "25"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); // --------------------------------------------------------------------------- // SSE helpers // --------------------------------------------------------------------------- type SseEvent = { type: "delta" | "tool_call" | "usage" | "error" | "done"; text?: string; tokensIn?: number; tokensOut?: number; code?: string; message?: string; retryable?: boolean; retryAfterSeconds?: number; assistantMessageId?: string; userMessageId?: string; reason?: string; }; /** POST an SSE message, collect the frames; `abortAfter` closes the socket * (client-cancel simulation) once ≥N delta frames arrived. */ async function streamMessage( accessToken: string, workspaceId: string, conversationId: string, body: Record, opts: { abortAfterDeltas?: number } = {}, ): Promise<{ status: number; events: SseEvent[]; contentType: string }> { const controller = new AbortController(); let deltas = 0; const res = await fetch(`${app.baseUrl}/api/v1/workspaces/${workspaceId}/ai/conversations/${conversationId}/messages`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` }, body: JSON.stringify(body), signal: controller.signal, }); const contentType = res.headers.get("content-type") ?? ""; if (!res.body) throw new Error("no SSE body"); const events: SseEvent[] = []; const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; try { for (;;) { const chunk = await reader.read(); if (chunk.done) break; buf += decoder.decode(chunk.value, { stream: true }); let idx: number; while ((idx = buf.indexOf("\n\n")) !== -1) { const frame = buf.slice(0, idx); buf = buf.slice(idx + 2); for (const line of frame.split("\n")) { if (!line.startsWith("data:")) continue; // skip `: keepalive` comments events.push(JSON.parse(line.slice(5).trim()) as SseEvent); } if (opts.abortAfterDeltas !== undefined) { deltas = events.filter((e) => e.type === "delta").length; if (deltas >= opts.abortAfterDeltas) { controller.abort(); break; } } } if (controller.signal.aborted) break; } } catch (err) { // client-side abort: the pending read() rejects with an AbortError — // that IS the cancellation we wanted (the frames seen so far are kept). if (!controller.signal.aborted || !(err instanceof Error && err.name === "AbortError")) throw err; } return { status: res.status, events, contentType }; } async function createConversation( accessToken: string, workspaceId: string, body?: Record, ): Promise<{ status: number; id?: string }> { const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/conversations`, { headers: authHeaders(accessToken), body: body ?? {}, }); const out = (await res.json().catch(() => ({}))) as { id?: string }; return { status: res.status, id: out.id }; } /** Create a page + N PARAGRAPH blocks (distinctive text) via the API. */ async function createPageWithBlocks( accessToken: string, workspaceId: string, title: string, blockTexts: string[], ): Promise<{ pageId: string; blockId: string }> { const pageRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: authHeaders(accessToken), body: { title }, }); const page = (await pageRes.json()) as { id: string }; let prevId: string | undefined; for (const text of blockTexts) { const r = await app.request("POST", `/api/v1/pages/${page.id}/blocks`, { headers: authHeaders(accessToken), body: { type: "PARAGRAPH", content: { text }, ...(prevId ? { afterBlockId: prevId } : {}) }, }); expect(r.status).toBe(201); const b = (await r.json()) as { id: string }; prevId = b.id; } const blocks = await app.prisma.block.findMany({ where: { pageId: page.id, deletedAt: null }, orderBy: { position: "asc" } }); return { pageId: page.id, blockId: blocks[0]!.id }; } /** Concatenated assistant text from the SSE events. */ const deltaText = (events: SseEvent[]) => events.filter((e) => e.type === "delta").map((e) => e.text ?? "").join(""); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("TASK-0122 Ask AI (SSE) — conversation CRUD + ownership", () => { it("create → list → get → rename → delete round-trips (own scope)", async () => { const owner = await createUser(app.request, { name: "AI Chat Owner" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "AI Chat WS"); const h = authHeaders(owner.accessToken!); const created = await createConversation(owner.accessToken!, workspaceId, { title: "First" }); expect(created.status).toBe(201); expect(created.id).toBeTruthy(); // audit event expect(await countAuditEvents(app.prisma, { action: "ai.conversation_created" })).toBeGreaterThanOrEqual(1); // list (most recent 50, own scope) const listRes = await app.request("GET", `/api/v1/workspaces/${workspaceId}/ai/conversations`, { headers: h }); expect(listRes.status).toBe(200); const list = (await listRes.json()) as { data: Array<{ id: string; title: string }> }; expect(list.data.some((c) => c.id === created.id)).toBe(true); // rename const patchRes = await app.request("PATCH", `/api/v1/ai/conversations/${created.id}`, { headers: h, body: { title: "Renamed" }, }); expect(patchRes.status).toBe(200); // delete (soft) → 404 thereafter const delRes = await app.request("DELETE", `/api/v1/ai/conversations/${created.id}`, { headers: h }); expect(delRes.status).toBe(200); const getRes = await app.request("GET", `/api/v1/ai/conversations/${created.id}`, { headers: h }); expect(getRes.status).toBe(404); }); it("user A cannot read / write user B's conversation (404 — existence hidden)", async () => { const a = await createUser(app.request, { name: "AI Chat A" }); const b = await createUser(app.request, { name: "AI Chat B" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(a.accessToken!), "AI Chat WS2"); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, b.email, "MEMBER"); const mine = await createConversation(a.accessToken!, workspaceId, { title: "A's convo" }); expect(mine.id).toBeTruthy(); // B: GET → 404, DELETE → 404 const getRes = await app.request("GET", `/api/v1/ai/conversations/${mine.id}`, { headers: authHeaders(b.accessToken!), }); expect(getRes.status).toBe(404); const delRes = await app.request("DELETE", `/api/v1/ai/conversations/${mine.id}`, { headers: authHeaders(b.accessToken!), }); expect(delRes.status).toBe(404); // B: SSE message send → 404 (JSON envelope, pre-stream) const res = await fetch(`${app.baseUrl}/api/v1/workspaces/${workspaceId}/ai/conversations/${mine.id}/messages`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${b.accessToken}` }, body: JSON.stringify({ content: "hi" }), }); expect(res.status).toBe(404); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe("NOT_FOUND"); }); it("non-member gets 403 (JSON envelope)", async () => { const owner = await createUser(app.request, { name: "AI Chat Owner3" }); const stranger = await createUser(app.request, { name: "AI Stranger" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "AI Chat WS3"); const conv = await createConversation(owner.accessToken!, workspaceId, {}); const res = await fetch(`${app.baseUrl}/api/v1/workspaces/${workspaceId}/ai/conversations/${conv.id}/messages`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${stranger.accessToken}` }, body: JSON.stringify({ content: "hi" }), }); expect(res.status).toBe(403); }); }); describe("TASK-0122 Ask AI (SSE) — stream contract", () => { it("streams: content-type, delta* → usage → done, auto-title, persisted", async () => { const owner = await createUser(app.request, { name: "AI Stream Owner" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "AI Stream WS"); const conv = await createConversation(owner.accessToken!, workspaceId, {}); expect(conv.id).toBeTruthy(); const { status, events, contentType } = await streamMessage(owner.accessToken!, workspaceId, conv.id!, { content: "tell me about quantum gravity", }); expect(status).toBe(200); expect(contentType).toContain("text/event-stream"); // frame order: deltas first, then usage, then exactly one terminal done const types = events.map((e) => e.type); const firstUsage = types.indexOf("usage"); const doneIdx = types.lastIndexOf("done"); expect(firstUsage).toBeGreaterThan(-1); expect(doneIdx).toBeGreaterThan(firstUsage); // no non-delta frames before the first usage (keepalives are comments) for (const t of types.slice(0, firstUsage)) expect(t).toBe("delta"); expect(types.filter((t) => t === "done")).toHaveLength(1); const usage = events.find((e) => e.type === "usage")!; expect(usage.tokensIn).toBeGreaterThan(0); expect(usage.tokensOut).toBeGreaterThan(0); const done = events.find((e) => e.type === "done")!; expect(done.reason).toBe("ok"); expect(done.assistantMessageId).toBeTruthy(); expect(done.userMessageId).toBeTruthy(); // the mock echoes the user message (word-for-word, first 40 chars) expect(deltaText(events)).toContain("quantum"); // persistence: user + assistant rows; assistant carries tokens + model const messages = await app.prisma.aiMessage.findMany({ where: { conversationId: conv.id! }, orderBy: { createdAt: "asc" }, }); expect(messages.map((m) => m.role)).toEqual(["user", "assistant"]); expect(messages[1]!.tokensIn).toBe(usage.tokensIn); expect(messages[1]!.tokensOut).toBe(usage.tokensOut); expect(messages[1]!.model).toBeTruthy(); expect(messages[1]!.metadata).not.toContain("truncated"); // auto-title = first user message (≤60 chars) const convRow = await app.prisma.aiConversation.findUniqueOrThrow({ where: { id: conv.id! } }); expect(convRow.title).toBe("tell me about quantum gravity"); }); }); describe("TASK-0122 Ask AI (SSE) — context binding", () => { it("a question about a page's seeded content answers with that content", async () => { const owner = await createUser(app.request, { name: "AI Ctx Owner" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "AI Ctx WS"); const { pageId, blockId } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Context Page", [ "The rocket equation governs propulsion.", "Zephyr-9 is the launch site.", ]); const conv = await createConversation(owner.accessToken!, workspaceId, { context: { pageId, blockId }, }); const { status, events } = await streamMessage(owner.accessToken!, workspaceId, conv.id!, { content: "what is the launch site", }); expect(status).toBe(200); // the mock echoes the system-prompt tail → the page content the model SAW const text = deltaText(events); expect(text).toContain("Zephyr-9"); expect(text).toContain("Context Page"); }); it("a user without read access to the referenced page gets context-unavailable, NOT the content", async () => { const owner = await createUser(app.request, { name: "AI CtxDeny Owner" }); const member = await createUser(app.request, { name: "AI CtxDeny Member" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "AI CtxDeny WS"); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, member.email, "MEMBER"); const { pageId } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Secret Page", [ "The codeword is OPALINE.", ]); // explicit NONE grant on the page for the member (a hard deny — // TASK-0104: overrides the MEMBER role's EDIT baseline) const memberUser = await app.prisma.user.findUniqueOrThrow({ where: { email: member.email } }); await app.prisma.permission.create({ data: { workspaceId, granteeType: "user", granteeId: memberUser.id, resourceType: "page", resourceId: pageId, level: "NONE", }, }); const conv = await createConversation(member.accessToken!, workspaceId, { context: { pageId } }); const { status, events } = await streamMessage(member.accessToken!, workspaceId, conv.id!, { content: "what is the codeword", }); expect(status).toBe(200); const text = deltaText(events); // the unavailable notice is surfaced… expect(text).toMatch(/unavailable/i); // …and the page content is NOT in the prompt (no leak) expect(text).not.toContain("OPALINE"); expect(text).not.toContain("Secret Page"); }); }); describe("TASK-0122 Ask AI (SSE) — budget, retry, rate limit", () => { it("exhausted budget → canonical 402 AI_BUDGET_EXCEEDED (pre-stream JSON)", async () => { const owner = await createUser(app.request, { name: "AI Budget Owner" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "AI Budget WS"); // 1-token budget → the first call's estimate (2×maxOutput) blows it await app.prisma.workspace.update({ where: { id: workspaceId }, data: { usageLimits: { ...(await app.prisma.workspace.findUniqueOrThrow({ where: { id: workspaceId } })).usageLimits, aiMonthlyTokens: 1 } }, }); const conv = await createConversation(owner.accessToken!, workspaceId, {}); const res = await fetch(`${app.baseUrl}/api/v1/workspaces/${workspaceId}/ai/conversations/${conv.id}/messages`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${owner.accessToken}` }, body: JSON.stringify({ content: "hello" }), }); expect(res.status).toBe(402); const body = (await res.json()) as { error: { code: string; details?: Array> } }; expect(body.error.code).toBe("AI_BUDGET_EXCEEDED"); const detail = body.error.details?.[0] ?? {}; expect(detail.limit).toBe(1); // no message rows persisted for the blocked call const messages = await app.prisma.aiMessage.count({ where: { conversationId: conv.id! } }); expect(messages).toBe(0); }); it("retry (retryOf) re-sends without duplicating the user message row", async () => { const owner = await createUser(app.request, { name: "AI Retry Owner" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "AI Retry WS"); const conv = await createConversation(owner.accessToken!, workspaceId, {}); const first = await streamMessage(owner.accessToken!, workspaceId, conv.id!, { content: "first message here" }); const userMsgId = first.events.find((e) => e.type === "done")!.userMessageId!; expect(userMsgId).toBeTruthy(); // simulate a provider outage recovery: the UI's Retry re-sends the SAME // user message (retryOf) — no duplicate user row const second = await streamMessage(owner.accessToken!, workspaceId, conv.id!, { content: "first message here", retryOf: userMsgId, }); expect(second.status).toBe(200); const messages = await app.prisma.aiMessage.findMany({ where: { conversationId: conv.id! }, orderBy: { createdAt: "asc" }, }); const userRows = messages.filter((m) => m.role === "user"); expect(userRows).toHaveLength(1); expect(userRows[0]!.id).toBe(userMsgId); const assistantRows = messages.filter((m) => m.role === "assistant"); expect(assistantRows.length).toBeGreaterThanOrEqual(1); }); it("per-user 30 msg/min cap → 429 RATE_LIMITED with retryAfterSeconds", async () => { const owner = await createUser(app.request, { name: "AI Rate Owner" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "AI Rate WS"); const conv = await createConversation(owner.accessToken!, workspaceId, {}); let last: { status: number } = { status: 0 }; for (let i = 0; i < 30; i++) { const res = await fetch(`${app.baseUrl}/api/v1/workspaces/${workspaceId}/ai/conversations/${conv.id}/messages`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${owner.accessToken}` }, body: JSON.stringify({ content: `burst ${i}` }), }); expect(res.status).toBe(200); await res.body?.cancel().catch(() => {}); // drain not needed — cancel frees the socket last = { status: res.status }; } expect(last.status).toBe(200); // 31st → 429 (per-user cap; the per-workspace guard allows 60) const res = await fetch(`${app.baseUrl}/api/v1/workspaces/${workspaceId}/ai/conversations/${conv.id}/messages`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${owner.accessToken}` }, body: JSON.stringify({ content: "burst 30" }), }); expect(res.status).toBe(429); const body = (await res.json()) as { error: { code: string; details?: Array> } }; expect(body.error.code).toBe("RATE_LIMITED"); expect(typeof body.error.details?.[0]?.retryAfterSeconds).toBe("number"); }); }); describe("TASK-0122 Ask AI (SSE) — cancellation", () => { it("client abort mid-stream persists a partial assistant message (metadata.truncated)", async () => { const owner = await createUser(app.request, { name: "AI Abort Owner" }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), "AI Abort WS"); const conv = await createConversation(owner.accessToken!, workspaceId, {}); // a long user message → many word-deltas (the mock streams one per word); // abort after 3 → the tail is unpersisted. const longMsg = "one two three four five six seven eight nine ten eleven twelve"; const { status, events } = await streamMessage(owner.accessToken!, workspaceId, conv.id!, { content: longMsg }, { abortAfterDeltas: 3, }); expect(status).toBe(200); const received = deltaText(events); expect(received.length).toBeGreaterThan(0); expect(received).not.toContain("twelve"); // aborted before the tail const messages = await app.prisma.aiMessage.findMany({ where: { conversationId: conv.id! }, orderBy: { createdAt: "asc" }, }); // the server persists the partial asynchronously after detecting the // client close — poll briefly for the teardown to land. let assistant = messages.find((m) => m.role === "assistant"); for (let i = 0; !assistant && i < 40; i++) { await new Promise((r) => setTimeout(r, 50)); const rows = await app.prisma.aiMessage.findMany({ where: { conversationId: conv.id! } }); assistant = rows.find((m) => m.role === "assistant"); } expect(assistant).toBeTruthy(); const meta = assistant!.metadata as { truncated?: boolean } | null; expect(meta?.truncated).toBe(true); expect(assistant!.content.length).toBeLessThan(longMsg.length); }); });