/** * TASK-0125 — RAG workspace search (query path) integration suite. * * Full stack (AppModule + Postgres + Redis) + the shared OpenSearch content * index, against the deterministic `MockProvider` (`AI_PROVIDER=mock`; the * `rag` mode emits a cited answer derived from the numbered context). The * query path is the spec's proof surface: * * - the FIRST SSE frame is `sources` (the server-side citations, built from * the Phase-8 hybrid retrieval — BM25 + kNN + RRF, both legs filtered by * the requester's tenant + visibility snapshot); * - permission isolation: a non-member workspace → 404; a member without * access to the page → ZERO citations for it, both via the RAG endpoint * AND via the raw hybrid search (mirrors the Phase-8 proof); * - hybrid determinism: the same query twice → the same citation order; * - persistence: the answer row is hidden (never in the conversation list), * carries `metadata.feature="rag"` + the citation list in `toolResults`. * * Indexing is seeded DIRECTLY (SearchClient.indexDocs with the mock vector) — * the chunk+embed WORKER pipeline is covered by the worker unit suite * (`ai-embedding.test.ts`); the worker's event-driven indexing is E2E-only * (it needs the domain-event stream, which the Testcontainers app does not * emit here). Recorded decision: this spec proves the QUERY path end-to-end. * * ── RUNNER GATE (Docker + OpenSearch) — SELF-SKIPS WHEN THE STACK IS ABSENT ── * `dockerAvailable() && opensearchUp()` (the API reads the SAME OpenSearch * the worker writes), so an absent stack never fails the suite. */ // Config must be fixed BEFORE the app boots (loadEnv + the workspace cache). process.env.AI_PROVIDER = "mock"; process.env.SEARCH_VECTOR_DIMENSION = "8"; process.env.AI_MOCK_DELTA_DELAY_MS = "5"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { execSync } from "node:child_process"; import { SearchClient, contentMapping, type GrantSnapshot, type SearchDoc, } from "@factory/search"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed"; /** The OpenSearch the API talks to (its SEARCH_URL default). */ const SEARCH_URL = process.env.SEARCH_URL ?? "http://localhost:9200"; const CONTENT_INDEX = "factory-content"; const CONTENT_INDEX_ALIAS = "factory-content"; /** A distinctive, unique seeded fact (the spec's "Zanzibar-7"). */ const FACT = "The launch code word is Zanzibar-7."; const QUERY = "What is the launch code word?"; function dockerAvailable(): boolean { try { execSync("docker info", { stdio: "ignore", timeout: 8_000 }); return true; } catch { return false; } } async function opensearchUp(url: string): Promise { try { const res = await fetch(`${url.replace(/\/+$/, "")}/_cluster/health`, { signal: AbortSignal.timeout(4_000) }); return res.ok; } catch { return false; } } const stackReady = dockerAvailable() && (await opensearchUp(SEARCH_URL)); let app: TestAppHandle; let client: SearchClient; // A run-unique suffix so seeded docs never collide across runs in the shared index. const RUN = `rag${Date.now().toString(36)}`; // Fixtures. let wsA = ""; // A's workspace (the page's tenant). let pageA = ""; // the seeded page id (A owns it). let userA: Awaited> | null = null; let userB: Awaited> | null = null; let userC: Awaited> | null = null; let pageDocId = ""; // `page:` let chunkDocId = ""; // `page:#c0` /** One SSE frame (the RAG wire contract). */ type RagSseEvent = { type: string; sources?: Array>; text?: string; tokensIn?: number; tokensOut?: number; code?: string; message?: string; assistantMessageId?: string; userMessageId?: string; reason?: string; }; /** POST the RAG endpoint, collect the SSE frames. */ async function ragStream(accessToken: string, body: Record): Promise<{ status: number; events: RagSseEvent[]; contentType: string }> { const res = await fetch(`${app.baseUrl}/api/v1/workspaces/${wsA}/ai/search`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` }, body: JSON.stringify(body), }); const contentType = res.headers.get("content-type") ?? ""; const events: RagSseEvent[] = []; if (!res.body) return { status: res.status, events, contentType }; const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; 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` try { events.push(JSON.parse(line.slice(5).trim()) as RagSseEvent); } catch { /* malformed frame — skip */ } } } } return { status: res.status, events, contentType }; } /** The deterministic mock vector for a text (dim 8): v0=len/100 (≤1), v1=0.5. */ function mockVector(text: string, dim = 8): number[] { const v = new Array(dim).fill(0); v[0] = Math.min(1, text.length / 100); if (dim > 1) v[1] = 0.5; return v; } /** * The page's grant snapshot, reproducing the worker's output for a PRIVATE * page (grants.ts `workspace = !isPrivate && …` → false; `users = [creator]`): * ONLY the creator (A) can see it — a workspace member (B) without a page * grant is excluded. This is the permission-isolation scenario: B is a * member of the workspace but has NO access to page P. */ function pageGrants(creatorId: string): GrantSnapshot { return { public: false, workspace: false, user: true, teams: [], users: [creatorId] }; } beforeAll(async () => { app = await createTestApp(); client = new SearchClient({ baseUrl: SEARCH_URL }); // Ensure the content index (or the alias to it) exists with the canonical // mapping. The dev stack rolled it to `factory-content-v2` behind the alias; // a fresh stack has nothing. `indexExists` resolves through the alias, so // this is idempotent for BOTH the real-index and the alias cases. if (!(await client.indexExists(CONTENT_INDEX_ALIAS))) { const mapping = contentMapping(); await client.createIndexIfMissing(`${CONTENT_INDEX}-${RUN}`, { settings: mapping.settings as Record, mappings: mapping.mappings as Record, }); await client.createAlias(`${CONTENT_INDEX}-${RUN}`, CONTENT_INDEX_ALIAS); } // ---- fixtures through the API (real authorization) ---- userA = await createUser(app.request, { name: "RAG Owner A" }); wsA = (await createWorkspace(app.request, authHeaders(userA.accessToken!), `RAG WS A ${RUN}`)).workspaceId; userB = await createUser(app.request, { name: "RAG Member B" }); userC = await createUser(app.request, { name: "RAG Outsider C" }); await addMemberViaSeam(app.request, app.internalServiceToken, wsA, userB.email, "MEMBER"); // The seeded page (A owns it; B is a workspace member but has NO page-level // grant — a page-level privacy scenario, so B's snapshot excludes it). const pageRes = await app.request("POST", `/api/v1/workspaces/${wsA}/pages`, { headers: authHeaders(userA.accessToken!), body: { title: "Launch Runbook" }, }); expect(pageRes.status).toBe(201); pageA = (await pageRes.json()) as { id: string }; const blockRes = await app.request("POST", `/api/v1/pages/${pageA.id}/blocks`, { headers: authHeaders(userA.accessToken!), body: { type: "PARAGRAPH", content: { text: FACT } }, }); expect(blockRes.status).toBe(201); // Seed the index DIRECTLY: the whole page doc + one chunk doc (the query // path de-dupes chunks to the parent). Both carry the mock vector (dim 8). const body = `Launch Runbook\n${FACT}`; const vec = mockVector(body); pageDocId = `page:${pageA.id}`; chunkDocId = `${pageDocId}#c0`; const base = { workspaceId: wsA, title: "Launch Runbook", pageId: pageA.id, url: `/w/${wsA}/page/${pageA.id}`, grants: pageGrants(userA.userId), archived: false, createdAt: "2025-01-01T00:00:00.000Z", updatedAt: "2025-06-01T00:00:00.000Z", vectorModel: "mock-embed", vector: vec, }; await client.indexDocs([ { ...base, docId: pageDocId, type: "page", id: pageA.id, body } satisfies SearchDoc, { ...base, docId: chunkDocId, type: "chunk", id: pageA.id, body: FACT, chunkId: "c0", chunkIndex: 0, parentDocId: pageDocId, } satisfies SearchDoc, ]); await client.refresh(CONTENT_INDEX_ALIAS); // The registry parity row (what the worker would write) — the query path // does not read it, but the test asserts the count equality the spec calls // for (OpenSearch chunk docs vs ai_embeddings rows for the workspace). await app.prisma.aiEmbedding.create({ data: { workspaceId: wsA, chunkRef: chunkDocId, chunkText: FACT, vectorId: chunkDocId, model: "mock-embed", dimension: 8, metadata: { docType: "page", refId: pageA.id, index: CONTENT_INDEX_ALIAS }, }, }); }, 180_000); afterAll(async () => { // Best-effort cleanup of the seeded docs (the shared index persists). try { await ( await fetch(`${SEARCH_URL}/${CONTENT_INDEX_ALIAS}/_delete_by_query`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: { term: { pageId: pageA } } }), refresh: true, }) ).json(); await app.prisma.aiEmbedding.deleteMany({ where: { workspaceId: wsA } }); } catch { /* shared index — do not fail teardown */ } await app?.close(); }); describe.skipIf(!stackReady)("RAG workspace search (TASK-0125 query path)", () => { it("happy path: sources-first SSE, the cited answer contains the fact + [n], usage + done ok", async () => { const { status, events, contentType } = await ragStream(userA!.accessToken!, { query: QUERY }); expect(status).toBe(200); expect(contentType).toContain("text/event-stream"); // The FIRST data frame is `sources` (the server-side citations). expect(events[0]?.type).toBe("sources"); const sources = events[0]?.sources ?? []; expect(sources.length).toBeGreaterThan(0); const cited = sources.find((s) => s.docId === pageDocId); expect(cited, "the seeded page must be cited").toBeTruthy(); expect(cited?.title).toBe("Launch Runbook"); expect(cited?.type).toBe("page"); expect(cited?.n).toBe(1); // Chunk hits de-dup to the parent → the page is cited ONCE. expect(sources.filter((s) => s.id === pageA.id).length).toBe(1); // The answer streams, contains the seeded fact + the citation marker. const answer = events.filter((e) => e.type === "delta").map((e) => e.text ?? "").join(""); expect(answer).toContain("Zanzibar-7"); expect(answer).toContain("[1]"); // usage + a terminal done ok. expect(events.some((e) => e.type === "usage")).toBe(true); const done = events.find((e) => e.type === "done"); expect(done?.reason).toBe("ok"); expect(done?.assistantMessageId).toBeTruthy(); expect(done?.userMessageId).toBeTruthy(); }); it("non-member workspace → 404 (the search viewer's fail-closed rule)", async () => { const res = await fetch(`${app.baseUrl}/api/v1/workspaces/${wsA}/ai/search`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${userC!.accessToken}` }, body: JSON.stringify({ query: QUERY }), }); expect(res.status).toBe(404); const body = (await res.json()) as { error?: { code?: string } }; expect(body.error?.code).toBe("NOT_FOUND"); }); it("member without page access → ZERO citations for it (RAG + raw hybrid both prove the permission filter)", async () => { const { status, events } = await ragStream(userB!.accessToken!, { query: QUERY }); expect(status).toBe(200); const sources = events.find((e) => e.type === "sources")?.sources ?? []; // B is a workspace member, but the page's snapshot does not include B → // the page is invisible to B's viewer → zero citations for it. expect(sources.find((s) => s.id === pageA)).toBeUndefined(); // The no-results answer is streamed (the mock's deterministic empty text). const answer = events.filter((e) => e.type === "delta").map((e) => e.text ?? "").join(""); expect(answer).toContain("I could not find any workspace content"); // Raw search (the Phase-8 proof, mirrored): the same query through the // public /search endpoint (the viewer's tenant + visibility filter on the // keyword leg — and the RAG hybrid's kNN leg uses the SAME visibility // filter) also returns NO page doc for B. const searchRes = await fetch(`${app.baseUrl}/api/v1/search`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${userB!.accessToken}` }, body: JSON.stringify({ q: QUERY }), }); expect(searchRes.status).toBe(200); const searchBody = (await searchRes.json()) as { data?: Array> }; const data = searchBody.data ?? []; expect(data.find((d) => d.id === pageA || d.pageId === pageA), "B's raw search must not surface the page").toBeUndefined(); }, 60_000); it("hybrid determinism: the same query twice → the same citation order", async () => { const a = (await ragStream(userA!.accessToken!, { query: QUERY })).events.find((e) => e.type === "sources")?.sources ?? []; const b = (await ragStream(userA!.accessToken!, { query: QUERY })).events.find((e) => e.type === "sources")?.sources ?? []; expect(b.map((s) => `${s.docId}:${s.n}`)).toEqual(a.map((s) => `${s.docId}:${s.n}`)); expect(a.length).toBeGreaterThan(0); }); it("persistence: the answer row is hidden + carries feature=rag + the citations in toolResults", async () => { const { events } = await ragStream(userA!.accessToken!, { query: QUERY }); const done = events.find((e) => e.type === "done")!; const row = await app.prisma.aiMessage.findUniqueOrThrow({ where: { id: done.assistantMessageId! } }); expect(row.role).toBe("assistant"); expect(row.metadata).toMatchObject({ feature: "rag" }); const toolResults = (row.toolResults ?? {}) as { sources?: Array<{ id?: string }> }; expect(toolResults.sources?.some((s) => s.id === pageA.id)).toBe(true); // The hidden per-call conversation never appears in the panel list. const convRes = await app.request("GET", `/api/v1/workspaces/${wsA}/ai/conversations`, { headers: authHeaders(userA.accessToken!), }); expect(convRes.status).toBe(200); const convs = ((await convRes.json()) as { data: Array<{ title: string | null }> }).data; expect(convs.some((c) => (c.title ?? "").startsWith("rag:"))).toBe(false); }); it("rate limit: the 31st rapid RAG query in the per-user window → 429", async () => { // A workspace MEMBER (so the 404 non-member gate does not fire first) — // the shared ai.calls limiter (30/min) is the gate under test. const userD = await createUser(app.request, { name: `RAG Rate D ${RUN}` }); await addMemberViaSeam(app.request, app.internalServiceToken, wsA, userD.email, "MEMBER"); let saw429 = false; let lastStatus = 0; for (let i = 0; i < 31; i++) { const res = await fetch(`${app.baseUrl}/api/v1/workspaces/${wsA}/ai/search`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${userD.accessToken}` }, body: JSON.stringify({ query: QUERY }), }); lastStatus = res.status; if (res.status === 429) { saw429 = true; const body = (await res.json()) as { error?: { code?: string } }; expect(body.error?.code).toBe("RATE_LIMITED"); break; } // Drain the (short) stream so the next request starts clean. if (res.body) await res.body.cancel(); } expect(saw429, "a 429 must fire within 31 rapid queries").toBe(true); expect(lastStatus).toBe(429); }, 120_000); });