/** * TASK-0125 — RAG embedding pipeline unit tests (Docker-free: fake prisma, * fake SearchClient, fake gateway). * * Covers: the real embed path (one gateway call per item, vector merge onto * each content doc + registry upsert), the chunk-id enumeration parity with * the indexer (`page:#c`), the source-vanished prune path, the * dimension-parity BLOCK (model-change → alias rollover required), stale-row * pruning, the nightly re-embed sweep (orphan prune + count-parity drift * alert), and payload validation. */ import { describe, expect, it, vi } from "vitest"; import type { PrismaClient } from "@prisma/client"; import type { AiGateway, EmbedResult } from "@factory/ai"; import type { SearchClient } from "@factory/search"; import { aiJobSchema } from "../processors/ai.processor.js"; import { chunkDocId, deriveItemChunks, handleEmbedChunks, handleReembed, type EmbedChunksJobData, } from "../processors/ai-embedding.js"; import type { WorkerEnv } from "../config.js"; // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- const NOW = new Date("2026-07-01T00:00:00Z"); /** A page row with one paragraph block of `text` (the search body source). */ function pageRow(text: string, overrides: Record = {}) { return { id: "pg1", workspaceId: "ws1", teamId: null, title: "Magic page", icon: null, description: null, isPrivate: false, archivedAt: null, trashedAt: null, deletedAt: null, createdBy: "u1", createdAt: NOW, updatedAt: NOW, blocks: [ { id: "b1", type: "paragraph", content: { text }, properties: null, position: "a0", deletedAt: null, createdBy: "u1", createdAt: NOW, updatedAt: NOW, }, ], ...overrides, }; } function fakePrisma(opts: { page?: unknown | null; embeddings?: Record[]; } = {}) { const upsert = vi.fn(async (_args: Record) => ({})); const deleteMany = vi.fn(async (_args: Record) => ({ count: 1 })); const update = vi.fn(async (_args: Record) => ({})); const del = vi.fn(async (_args: Record) => ({})); const auditCreate = vi.fn(async (_args: Record) => ({})); const prisma = { page: { findUnique: vi.fn(async () => opts.page) }, databaseRecord: { findUnique: vi.fn(async () => null) }, share: { findMany: vi.fn(async () => []) }, permission: { findMany: vi.fn(async () => []) }, aiEmbedding: { upsert, deleteMany, update, delete: del, findMany: vi.fn(async () => opts.embeddings ?? []), }, auditLog: { create: auditCreate }, } as unknown as PrismaClient; return { prisma, upsert, deleteMany, update, del, auditCreate }; } function fakeSearch(updateDocErr?: (docId: string) => Error | undefined) { const updateDoc = vi.fn(async (docId: string, patch: Record) => { if (updateDocErr) { const err = updateDocErr(docId); if (err) throw err; } return {}; }); const search = { updateDoc } as unknown as SearchClient; return { search, updateDoc }; } function fakeGateway(dimension: number, model = "mock-embed", fail?: (texts: string[]) => Error) { const embed = vi.fn(async (params: { texts: string[] }): Promise => { if (fail) throw fail(params.texts); const vectors = params.texts.map((t) => { const v = new Array(dimension).fill(0); v[0] = Math.min(1, t.length / 100); if (dimension > 1) v[1] = 0.5; return v; }); return { model, dimension, vectors, usage: { tokens: Math.ceil(params.texts.join("").length / 4) }, latencyMs: 1, provider: "custom", }; }); const gateway = { embed } as unknown as AiGateway; return { gateway, embed }; } const ENV: WorkerEnv = { SEARCH_VECTOR_DIMENSION: 8, AI_PROVIDER: "mock" } as unknown as WorkerEnv; /** > CHUNK_BODY_THRESHOLD (1600) → multi-chunk page. */ const LONG_BODY = "The secret launch code is Zanzibar-7. " + "The factory's quarterly target is 4,000 widgets by Q4. ".repeat(40); const JOB: EmbedChunksJobData = { type: "embed:chunks", workspaceId: "ws1", docType: "page", refId: "pg1", chunkIds: [], }; // --------------------------------------------------------------------------- // chunkDocId // --------------------------------------------------------------------------- describe("chunkDocId", () => { it("reconstructs :#", () => { expect(chunkDocId("page", "pg1", "c0")).toBe("page:pg1#c0"); expect(chunkDocId("record", "r9", "c3")).toBe("record:r9#c3"); }); }); // --------------------------------------------------------------------------- // handleEmbedChunks — the real embed path // --------------------------------------------------------------------------- describe("handleEmbedChunks (TASK-0125 real path)", () => { it("embeds whole doc + every chunk in ONE gateway call, merges vectors, upserts rows", async () => { const { prisma, upsert, deleteMany } = fakePrisma({ page: pageRow(LONG_BODY) }); const { search, updateDoc } = fakeSearch(); const { gateway, embed } = fakeGateway(8); const res = await handleEmbedChunks({ prisma, env: ENV, search, job: JOB, jobId: "j-1", gateway }); expect(res.skipped).toBe(false); expect(res.blocked).toBeUndefined(); expect(res.model).toBe("mock-embed"); expect(res.dimension).toBe(8); expect(embed).toHaveBeenCalledTimes(1); // batched — ONE call for the whole item expect(res.embedded).toBeGreaterThan(1); // whole doc + ≥ 2 chunks // chunk-id enumeration parity: the refs are page:pg1 then page:pg1#c expect(res.chunkRefs[0]).toBe("page:pg1"); expect(res.chunkRefs[1]).toBe("page:pg1#c0"); const n = res.embedded; expect(n).toBe(res.chunkRefs.length); for (let i = 0; i < n - 1; i++) expect(res.chunkRefs[i + 1]).toBe(`page:pg1#c${i}`); // vector merged onto each content doc with the model stamp expect(updateDoc).toHaveBeenCalledTimes(n); const firstCall = updateDoc.mock.calls[0]![0] as string; const firstPatch = updateDoc.mock.calls[0]![1] as { vector: number[]; vectorModel: string }; expect(firstCall).toBe("page:pg1"); expect(firstPatch.vector).toHaveLength(8); expect(firstPatch.vectorModel).toBe("mock-embed"); expect(updateDoc.mock.calls[n - 1]![0]).toBe(`page:pg1#c${n - 2}`); // registry row per chunkRef, keyed [workspaceId, chunkRef] expect(upsert).toHaveBeenCalledTimes(n); const firstUpsert = upsert.mock.calls[0]![0] as { where: { workspaceId_chunkRef: { workspaceId: string; chunkRef: string } }; create: { workspaceId: string; chunkRef: string; vectorId: string; model: string; dimension: number; chunkText: string }; }; expect(firstUpsert.where.workspaceId_chunkRef).toEqual({ workspaceId: "ws1", chunkRef: "page:pg1" }); expect(firstUpsert.create.vectorId).toBe("page:pg1"); expect(firstUpsert.create.model).toBe("mock-embed"); expect(firstUpsert.create.dimension).toBe(8); expect(firstUpsert.create.chunkText.length).toBeGreaterThan(0); // the prune pass runs but keeps every ref (NOT-in) — nothing stale to go expect(deleteMany).toHaveBeenCalledTimes(1); const where = (deleteMany.mock.calls[0]![0] as { where: Record }).where; expect((where.NOT as { chunkRef: { in: string[] } }).chunkRef.in).toEqual(res.chunkRefs); }); it("short body (≤ threshold) embeds the whole doc ONLY", async () => { const { prisma } = fakePrisma({ page: pageRow("The secret launch code is Zanzibar-7.") }); const { search, updateDoc } = fakeSearch(); const { gateway, embed } = fakeGateway(8); const res = await handleEmbedChunks({ prisma, env: ENV, search, job: JOB, jobId: "j-2", gateway }); expect(res.embedded).toBe(1); expect(res.chunkRefs).toEqual(["page:pg1"]); expect(embed).toHaveBeenCalledTimes(1); expect(updateDoc).toHaveBeenCalledTimes(1); expect(updateDoc.mock.calls[0]![0]).toBe("page:pg1"); }); it("source vanished → prunes registry rows, skips cleanly", async () => { const { prisma, deleteMany } = fakePrisma({ page: null }); const { search, updateDoc } = fakeSearch(); const { gateway, embed } = fakeGateway(8); const res = await handleEmbedChunks({ prisma, env: ENV, search, job: JOB, jobId: "j-3", gateway }); expect(res.skipped).toBe(true); expect(res.reason).toBe("source vanished"); expect(res.embedded).toBe(0); expect(embed).not.toHaveBeenCalled(); expect(updateDoc).not.toHaveBeenCalled(); expect(deleteMany).toHaveBeenCalledTimes(1); const where = deleteMany.mock.calls[0]![0] as { where: { workspaceId: string; OR: { chunkRef: string | { startsWith: string } }[] }; }; expect(where.where.workspaceId).toBe("ws1"); // exact whole-doc + #-prefixed chunks (no sibling-prefix collisions) expect(where.where.OR).toEqual([ { chunkRef: "page:pg1" }, { chunkRef: { startsWith: "page:pg1#" } }, ]); }); it("prune pass: stale registry rows are deleted, current chunkRefs kept", async () => { const { prisma, deleteMany } = fakePrisma({ page: pageRow(LONG_BODY) }); const { search } = fakeSearch(); const { gateway } = fakeGateway(8); const res = await handleEmbedChunks({ prisma, env: ENV, search, job: JOB, jobId: "j-4", gateway }); expect(deleteMany).toHaveBeenCalledTimes(1); const where = (deleteMany.mock.calls[0]![0] as { where: Record }).where; // exact whole-doc + #-prefixed chunks (no sibling-prefix collisions) expect(where.OR).toEqual([ { chunkRef: "page:pg1" }, { chunkRef: { startsWith: "page:pg1#" } }, ]); // keep list == exactly the refs just embedded (whole doc + c0..cN) expect((where.NOT as { chunkRef: { in: string[] } }).chunkRef.in).toEqual(res.chunkRefs); }); it("source with zero chunks after shrink → prune keeps only the whole doc", async () => { const { prisma, deleteMany } = fakePrisma({ page: pageRow("short body only") }); const { search } = fakeSearch(); const { gateway } = fakeGateway(8); await handleEmbedChunks({ prisma, env: ENV, search, job: JOB, jobId: "j-6", gateway }); const where = (deleteMany.mock.calls[0]![0] as { where: Record }).where; expect((where.NOT as { chunkRef: { in: string[] } }).chunkRef.in).toEqual(["page:pg1"]); }); it("dimension mismatch → BLOCKS the write + emits ai.reembed_required (no upsert, no merge)", async () => { const { prisma, upsert, auditCreate } = fakePrisma({ page: pageRow(LONG_BODY) }); const { search, updateDoc } = fakeSearch(); const { gateway } = fakeGateway(1536, "new-model-dim1536"); const res = await handleEmbedChunks({ prisma, env: ENV, search, job: JOB, jobId: "j-5", gateway }); expect(res.skipped).toBe(true); expect(res.blocked).toBe(true); expect(res.reason).toBe("dimension_mismatch"); expect(res.dimension).toBe(1536); expect(updateDoc).not.toHaveBeenCalled(); expect(upsert).not.toHaveBeenCalled(); expect(auditCreate).toHaveBeenCalledTimes(1); const audit = auditCreate.mock.calls[0]![0] as { data: { action: string; metadata: { reason: string } }; }; expect(audit.data.action).toBe("ai.reembed_required"); expect(audit.data.metadata.reason).toBe("dimension_mismatch"); }); }); // a long body always expands to ≥ 2 chunks (it's 4x the 800-char budget) function LongBodyCount(): number { return 4; // c0..c3 (see derivation below — asserted against deriveItemChunks) } function resChunkRefsFor(n: number): string[] { return Array.from({ length: n }, (_, i) => `page:pg1#c${i}`); } // --------------------------------------------------------------------------- // deriveItemChunks — enumeration parity with the indexer // --------------------------------------------------------------------------- describe("deriveItemChunks", () => { it("returns whole doc + c0..cN-1 in indexer order (page)", async () => { const { prisma } = fakePrisma({ page: pageRow(LONG_BODY) }); const chunks = await deriveItemChunks(prisma, "page", "pg1"); expect(chunks).not.toBeNull(); const ids = chunks!.map((c) => c.docId); expect(ids[0]).toBe("page:pg1"); for (let i = 1; i < ids.length; i++) expect(ids[i]).toBe(`page:pg1#c${i - 1}`); // long body → strictly more chunks than the whole doc expect(ids.length).toBeGreaterThan(2); // the whole-doc text is the search body (title + block text) expect(chunks![0]!.text).toContain("Zanzibar-7"); expect(chunks![0]!.text).toContain("Magic page"); // chunk texts are the chunked body expect(chunks![1]!.text.length).toBeGreaterThan(0); expect(chunks!.every((c) => c.workspaceId === "ws1")).toBe(true); }); it("returns null for a trashed page", async () => { const { prisma } = fakePrisma({ page: pageRow("x", { trashedAt: NOW }) }); expect(await deriveItemChunks(prisma, "page", "pg1")).toBeNull(); }); }); // --------------------------------------------------------------------------- // handleReembed — the nightly sweep (parity verification) // --------------------------------------------------------------------------- describe("handleReembed (nightly sweep)", () => { const row = (i: number, overrides: Record = {}) => ({ id: `row-${i}`, workspaceId: "ws1", chunkRef: `page:pg1#c${i}`, chunkText: `chunk text ${i}`, vectorId: `page:pg1#c${i}`, model: "mock-embed", dimension: 8, metadata: null, createdAt: NOW, ...overrides, }); it("re-embeds every row in batches, merges vectors, refreshes model/dimension", async () => { const rows = Array.from({ length: 5 }, (_, i) => row(i)); const { prisma, update: embUpdate, del } = fakePrisma({ embeddings: rows }); const { search, updateDoc } = fakeSearch(); const { gateway, embed } = fakeGateway(8, "mock-embed-v2"); const res = await handleReembed({ prisma, env: ENV, search, jobId: "r-1", gateway }); expect(res.total).toBe(5); expect(res.embedded).toBe(5); expect(res.failed).toBe(0); expect(res.driftAlert).toBe(false); expect(res.blocked).toBeUndefined(); expect(res.model).toBe("mock-embed-v2"); expect(embed).toHaveBeenCalledTimes(1); // 5 < batch of 100 expect(updateDoc).toHaveBeenCalledTimes(5); expect(updateDoc.mock.calls[0]![1]).toMatchObject({ vectorModel: "mock-embed-v2" }); expect(embUpdate).toHaveBeenCalledTimes(5); expect(del).not.toHaveBeenCalled(); }); it("orphan rows (vanish index doc) are pruned + counted as failed (drift alerts at > 2%)", async () => { const rows = Array.from({ length: 10 }, (_, i) => row(i)); const { prisma, del, auditCreate } = fakePrisma({ embeddings: rows }); const { search } = fakeSearch((docId) => docId === "page:pg1#c3" ? new Error("Not Found 404") : undefined, ); const { gateway } = fakeGateway(8); const res = await handleReembed({ prisma, env: ENV, search, jobId: "r-2", gateway }); expect(res.total).toBe(10); expect(res.embedded).toBe(9); expect(res.failed).toBe(1); expect(del).toHaveBeenCalledTimes(1); // the orphan row is deleted expect(res.driftAlert).toBe(true); // 1/10 = 10% > 2% threshold expect(auditCreate).toHaveBeenCalledTimes(1); }); it("drift > 2% emits ai.reembed_required (count parity)", async () => { const rows = Array.from({ length: 10 }, (_, i) => row(i)); const { prisma, auditCreate } = fakePrisma({ embeddings: rows }); // 3 of 10 fail → 30% drift > 2% const { search } = fakeSearch((docId) => { const n = Number(docId.split("#c")[1]); if (n < 3) return new Error("404 gone"); return undefined; }); const { gateway } = fakeGateway(8); const res = await handleReembed({ prisma, env: ENV, search, jobId: "r-3", gateway }); expect(res.driftAlert).toBe(true); expect(auditCreate).toHaveBeenCalledTimes(1); const audit = auditCreate.mock.calls[0]![0] as { data: { action: string; metadata: { reason: string } }; }; expect(audit.data.action).toBe("ai.reembed_required"); expect(audit.data.metadata.reason).toBe("count_parity_drift"); }); it("dimension mismatch on the probe batch BLOCKS the whole run", async () => { const rows = Array.from({ length: 5 }, (_, i) => row(i)); const { prisma, update: embUpdate, auditCreate } = fakePrisma({ embeddings: rows }); const { search, updateDoc } = fakeSearch(); const { gateway } = fakeGateway(1536, "new-model-dim1536"); const res = await handleReembed({ prisma, env: ENV, search, jobId: "r-4", gateway }); expect(res.blocked).toBe(true); expect(res.embedded).toBe(0); expect(updateDoc).not.toHaveBeenCalled(); expect(embUpdate).not.toHaveBeenCalled(); expect(auditCreate).toHaveBeenCalledTimes(1); }); it("empty registry → no-op", async () => { const { prisma } = fakePrisma({ embeddings: [] }); const { search } = fakeSearch(); const { gateway, embed } = fakeGateway(8); const res = await handleReembed({ prisma, env: ENV, search, jobId: "r-5", gateway }); expect(res).toEqual({ total: 0, embedded: 0, failed: 0, driftAlert: false }); expect(embed).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // payload validation // --------------------------------------------------------------------------- describe("aiJobSchema — embed:chunks / embed:reembed validation", () => { it("accepts a valid embed:chunks payload (chunkIds optional — re-derived)", () => { expect(aiJobSchema.safeParse(JOB).success).toBe(true); // chunkIds is informational now (the handler re-derives) — [] is valid const parsed = aiJobSchema.parse(JOB) as { type: string; chunkIds: string[] }; expect(parsed.type).toBe("embed:chunks"); expect(parsed.chunkIds).toEqual([]); }); it("accepts embed:reembed (no fields)", () => { expect(aiJobSchema.safeParse({ type: "embed:reembed" }).success).toBe(true); }); it("rejects an unknown docType (only page/record are chunkable)", () => { expect(aiJobSchema.safeParse({ ...JOB, docType: "comment" }).success).toBe(false); }); it("rejects a missing workspaceId", () => { const { workspaceId: _drop, ...rest } = JOB; expect(aiJobSchema.safeParse(rest).success).toBe(false); }); it("coexists with the other variants in the union", () => { expect(aiJobSchema.safeParse({ type: "embed", workspaceId: "ws1", texts: ["x"] }).success).toBe(true); expect(aiJobSchema.safeParse({ type: "usage.rollup" }).success).toBe(true); }); });