1
8import { describe, expect, it, vi, beforeEach } from "vitest";
9import type { PrismaClient } from "@prisma/client";
10import { SearchClient, chunkText, DEFAULT_CHUNK_OPTIONS, type SearchDoc } from "@factory/search";
11import { ApiError } from "@factory/shared/errors";
12import type { WorkerEnv } from "../config.js";
13import {
14 buildGrantSnapshot,
15 workspaceFloorSnapshot,
16 pageScopes,
17 type GrantContext
18} from "../processors/search-index/grants.js";
19import {
20 buildPageDoc,
21 buildBlockDocs,
22 buildUserDoc,
23 contentHash as builderHash,
24 upsertMirror,
25 deleteMirror,
26 mirrorRefId,
27 blockText,
28 renderPropertyValue,
29 expandChunkDocs,
30 CHUNK_BODY_THRESHOLD
31} from "../processors/search-index/builders.js";
32import {
33 handleSearchEvent,
34 removeDoc,
35 runReconcile,
36 runReindex,
37 ensureContentIndex,
38 recordSearchDlq,
39 indexDocs,
40 type SearchIndexOps
41} from "../processors/search-index/processor.js";
42
43vi.mock("../queues.js", () => {
44 const calls: { queue: string; data: Record<string, unknown> }[] = [];
45 return {
46 QUEUES: {
47 files: "files",
48 searchIndex: "search-index",
49 webhooks: "webhooks",
50 automations: "automations",
51 email: "email",
52 ai: "ai",
53 maintenance: "maintenance"
54 },
55 enqueue: vi.fn(async (_c: unknown, queue: string, data: Record<string, unknown>) => {
56 calls.push({ queue, data });
57 return { id: "job-1" };
58 }),
59 enqueueWithId: vi.fn(async () => ({ id: "job-2" })),
60 __calls: calls
61 };
62});
63import { enqueue, QUEUES } from "../queues.js";
64
65
66
67
68
69const ENV = {
70 SEARCH_VECTOR_DIMENSION: 1536,
71 SEARCH_KNN_M: 16,
72 SEARCH_KNN_EF_CONSTRUCTION: 100,
73 WORKER_CONCURRENCY: 2
74} as unknown as WorkerEnv;
75
76const NOW = new Date("2026-01-02T03:04:05.000Z");
77
78interface FakePrisma {
79 prisma: PrismaClient;
80 page: { findUnique: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn> };
81 searchDocument: {
82 findMany: ReturnType<typeof vi.fn>;
83 upsert: ReturnType<typeof vi.fn>;
84 deleteMany: ReturnType<typeof vi.fn>;
85 };
86 permission: { findMany: ReturnType<typeof vi.fn> };
87 user: { findUnique: ReturnType<typeof vi.fn> };
88 workspace: { findMany: ReturnType<typeof vi.fn> };
89}
90
91function fakePrisma(): FakePrisma {
92 const page = {
93 findUnique: vi.fn(async () => null),
94 findMany: vi.fn(async () => [])
95 };
96 const searchDocument = {
97 findMany: vi.fn(async () => []),
98 upsert: vi.fn(async (a: { create: Record<string, unknown> }) => ({ id: "sd-1", ...a.create })),
99 deleteMany: vi.fn(async () => ({ count: 1 }))
100 };
101 const permission = { findMany: vi.fn(async () => []) };
102 const user = { findUnique: vi.fn(async () => null) };
103 const workspace = { findMany: vi.fn(async () => []) };
104 const prisma = {
105 page,
106 block: { findUnique: vi.fn(async () => null) },
107 database: { findUnique: vi.fn(async () => null), findMany: vi.fn(async () => []) },
108 databaseRecord: { findUnique: vi.fn(async () => null), findMany: vi.fn(async () => []) },
109 comment: { findUnique: vi.fn(async () => null), findMany: vi.fn(async () => []) },
110 file: { findUnique: vi.fn(async () => null), findMany: vi.fn(async () => []) },
111 user,
112 share: { findMany: vi.fn(async () => []) },
113 permission,
114 searchDocument,
115 workspace,
116 workspaceMember: { findMany: vi.fn(async () => []) }
117 };
118 return {
119 prisma: prisma as unknown as PrismaClient,
120 page,
121 searchDocument,
122 permission,
123 user,
124 workspace
125 };
126}
127
128interface FakeSearch {
129 ops: SearchIndexOps;
130 indexed: SearchDoc[][];
131 deleted: string[];
132 updated: { docId: string; doc: Record<string, unknown> }[];
133 xadd: ReturnType<typeof vi.fn>;
134 docExists: ReturnType<typeof vi.fn>;
135 deleteChunksOf: ReturnType<typeof vi.fn>;
136}
137
138function fakeSearch(prisma: PrismaClient): FakeSearch {
139 const indexed: SearchDoc[][] = [];
140 const deleted: string[] = [];
141 const updated: { docId: string; doc: Record<string, unknown> }[] = [];
142 const xadd = vi.fn(async () => "1-1");
143 const docExists = vi.fn(async (ids: string[]) => new Set(ids));
144 const deleteChunksOf = vi.fn(async () => 0);
145 const search = {
146 indexDocs: vi.fn(async (docs: SearchDoc[]) => {
147 indexed.push(docs);
148 return { indexed: docs.length };
149 }),
150 deleteDoc: vi.fn(async (id: string) => {
151 deleted.push(id);
152 }),
153 deleteChunksOf,
154 updateDoc: vi.fn(async (docId: string, doc: Record<string, unknown>) => {
155 updated.push({ docId, doc });
156 }),
157 docExists,
158 indexExists: vi.fn(async () => false),
159
160
161 isAlias: vi.fn(async () => true),
162 deleteIndex: vi.fn(async () => {}),
163 createIndexIfMissing: vi.fn(async () => true),
164 createAlias: vi.fn(async () => {}),
165 getVectorDimension: vi.fn(async () => null),
166 refresh: vi.fn(async () => {})
167 } as unknown as SearchClient;
168 const ops: SearchIndexOps = {
169 search,
170 prisma,
171 connection: { xadd } as unknown as SearchIndexOps["connection"],
172 env: ENV
173 };
174 return { ops, indexed, deleted, updated, xadd, docExists, deleteChunksOf };
175}
176
177function allIndexed(fs: FakeSearch): SearchDoc[] {
178 return fs.indexed.flat();
179}
180
181const basePage = () => ({
182 id: "pg1",
183 workspaceId: "ws1",
184 teamId: null,
185 title: "Roadmap",
186 icon: null,
187 description: "The plan",
188 isPrivate: false,
189 archivedAt: null,
190 trashedAt: null,
191 deletedAt: null,
192 createdBy: "u1",
193 createdAt: NOW,
194 updatedAt: NOW,
195 blocks: [
196 {
197 id: "b1",
198 type: "PARAGRAPH",
199 content: { text: "Ship search" },
200 properties: {},
201 position: "a0",
202 deletedAt: null,
203 createdBy: "u1",
204 createdAt: NOW,
205 updatedAt: NOW
206 }
207 ]
208});
209
210beforeEach(() => {
211 vi.clearAllMocks();
212});
213
214
215
216
217
218describe("buildGrantSnapshot (mirrors effectiveLevel ≥ VIEW)", () => {
219 const ctx: GrantContext = {
220 workspaceId: "ws1",
221 teamId: null,
222 scopes: pageScopes("ws1", "pg1", null),
223 isPrivate: false,
224 shares: []
225 };
226
227 it("public page: workspace-visible, no public", async () => {
228 const prisma = fakePrisma();
229 const snap = await buildGrantSnapshot(prisma.prisma as never, ctx, NOW);
230 expect(snap).toEqual({ public: false, workspace: true, user: false, teams: [], users: [] });
231 });
232
233 it("private page: workspace-invisible unless an explicit user grant applies", async () => {
234 const prisma = fakePrisma();
235 prisma.permission.findMany.mockResolvedValue([
236 { granteeType: "user", granteeId: "u9", resourceType: "page", resourceId: "pg1", level: "VIEW" },
237
238 { granteeType: "user", granteeId: "u7", resourceType: "page", resourceId: "other", level: "EDIT" },
239
240 { granteeType: "share", granteeId: "s1", resourceType: "page", resourceId: "pg1", level: "VIEW" }
241 ]);
242 const snap = await buildGrantSnapshot(prisma.prisma as never, { ...ctx, isPrivate: true }, NOW);
243 expect(snap.workspace).toBe(false);
244 expect(snap.users).toEqual(["u9"]);
245 expect(snap.user).toBe(true);
246 });
247
248 it("private page with NO explicit grants: the CREATOR is still a viewer (TASK-0100 S5)", async () => {
249
250
251
252 const prisma = fakePrisma();
253 prisma.permission.findMany.mockResolvedValue([]);
254 const snap = await buildGrantSnapshot(
255 prisma.prisma as never,
256 { ...ctx, isPrivate: true, creatorId: "owner1" },
257 NOW
258 );
259 expect(snap.workspace).toBe(false);
260 expect(snap.users).toEqual(["owner1"]);
261 expect(snap.user).toBe(true);
262 });
263
264 it("active public share (allowSearchIndexing, unexpired) ⇒ public", async () => {
265 const prisma = fakePrisma();
266 const snap = await buildGrantSnapshot(
267 prisma.prisma as never,
268 { ...ctx, shares: [{ scope: "PUBLIC", allowSearchIndexing: true, expiresAt: new Date("2030-01-01") }] },
269 NOW
270 );
271 expect(snap.public).toBe(true);
272 });
273
274 it("expired / non-indexable public share ⇒ not public", async () => {
275 const prisma = fakePrisma();
276 const snap = await buildGrantSnapshot(
277 prisma.prisma as never,
278 {
279 ...ctx,
280 shares: [
281 { scope: "PUBLIC", allowSearchIndexing: true, expiresAt: new Date("2020-01-01") },
282 { scope: "PUBLIC", allowSearchIndexing: false, expiresAt: null }
283 ]
284 },
285 NOW
286 );
287 expect(snap.public).toBe(false);
288 });
289
290 it("workspaceFloorSnapshot: workspace-visible unless private", () => {
291 expect(workspaceFloorSnapshot({ isPrivate: false, shares: [] }).workspace).toBe(true);
292 expect(workspaceFloorSnapshot({ isPrivate: true, shares: [] }).workspace).toBe(false);
293 });
294});
295
296
297
298
299
300describe("buildPageDoc / buildBlockDocs", () => {
301 it("composes title + description + block text, grants, url, docId", async () => {
302 const { prisma, page } = fakePrisma();
303 page.findUnique.mockResolvedValue(basePage());
304 const deps = { prisma, now: () => NOW };
305 const doc = await buildPageDoc(deps, "pg1");
306 expect(doc).not.toBeNull();
307 expect(doc!.docId).toBe("page:pg1");
308 expect(doc!.title).toBe("Roadmap");
309 expect(doc!.body).toBe("Roadmap\nThe plan\nShip search");
310 expect(doc!.grants.workspace).toBe(true);
311 expect(doc!.archived).toBe(false);
312 expect(doc!.url).toBe("/w/ws1/page/pg1");
313 expect(doc!.workspaceId).toBe("ws1");
314
315 const blocks = await buildBlockDocs(deps, "pg1");
316 expect(blocks).toHaveLength(1);
317 expect(blocks[0]!.docId).toBe("block:b1");
318 expect(blocks[0]!.body).toBe("Ship search");
319 expect(blocks[0]!.pageId).toBe("pg1");
320 expect(blocks[0]!.blockId).toBe("b1");
321 });
322
323 it("trashed page ⇒ null (removed from the index; arch §1.1 / TASK-0096 §5)", async () => {
324 const { prisma, page } = fakePrisma();
325 page.findUnique.mockResolvedValue({ ...basePage(), trashedAt: NOW });
326 const doc = await buildPageDoc({ prisma, now: () => NOW }, "pg1");
327 expect(doc).toBeNull();
328 const blocks = await buildBlockDocs({ prisma, now: () => NOW }, "pg1");
329 expect(blocks).toEqual([]);
330 });
331
332 it("purged page (deletedAt) ⇒ null (caller deletes)", async () => {
333 const { prisma, page } = fakePrisma();
334 page.findUnique.mockResolvedValue({ ...basePage(), deletedAt: NOW });
335 await expect(buildPageDoc({ prisma, now: () => NOW }, "pg1")).resolves.toBeNull();
336 await expect(buildBlockDocs({ prisma, now: () => NOW }, "pg1")).resolves.toEqual([]);
337 });
338
339 it("blocks with no text are not separate docs", async () => {
340 const { prisma, page } = fakePrisma();
341 page.findUnique.mockResolvedValue({
342 ...basePage(),
343 blocks: [{ ...basePage().blocks[0]!, content: {} }, { ...basePage().blocks[0]!, id: "b2", content: { text: "x" } }]
344 });
345 const blocks = await buildBlockDocs({ prisma, now: () => NOW }, "pg1");
346 expect(blocks.map((b) => b.id)).toEqual(["b2"]);
347 });
348});
349
350describe("chunk emission (TASK-0099 §3 — page/record bodies over threshold)", () => {
351
352
353
354 const para = "lorem ipsum dolor sit amet consectetur adipiscing ".repeat(9).trim();
355 const LONG = Array.from({ length: 18 }, (_, i) => `Section ${i}: ${para}`).join("\n\n");
356 const bigPage = () => ({ ...basePage(), blocks: [{ ...basePage().blocks[0]!, content: { text: LONG } }] });
357
358 it("a long page body ⇒ 1 whole doc + N chunk docs (N ≥ 5, overlapping)", async () => {
359 const { prisma, page } = fakePrisma();
360 page.findUnique.mockResolvedValue(bigPage());
361 const whole = (await buildPageDoc({ prisma, now: () => NOW }, "pg1"))!;
362 expect(whole.body.length).toBeGreaterThan(CHUNK_BODY_THRESHOLD);
363 expect(whole.body.length).toBeGreaterThan(5000);
364
365 const expanded = expandChunkDocs(whole);
366
367 const expectedChunks = chunkText(whole.body, DEFAULT_CHUNK_OPTIONS);
368 expect(expectedChunks.length).toBeGreaterThanOrEqual(5);
369 expect(expanded).toHaveLength(expectedChunks.length + 1);
370 expect(expanded[0]).toBe(whole);
371
372 for (let i = 0; i < expectedChunks.length; i++) {
373 const c = expanded[i + 1]!;
374 expect(c.type).toBe("chunk");
375 expect(c.docId).toBe(`page:pg1#c${i}`);
376 expect(c.id).toBe("pg1");
377 expect(c.chunkId).toBe(`c${i}`);
378 expect(c.chunkIndex).toBe(i);
379 expect(c.parentDocId).toBe("page:pg1");
380 expect(c.body).toBe(expectedChunks[i]!.text);
381
382 expect(c.grants).toEqual(whole.grants);
383 expect(c.workspaceId).toBe(whole.workspaceId);
384 expect(c.url).toBe(whole.url);
385 }
386
387
388
389
390
391
392
393 const chunkDocs = expanded.slice(1);
394 const overlap = DEFAULT_CHUNK_OPTIONS.overlapChars;
395 for (let i = 0; i < chunkDocs.length - 1; i++) {
396 const tail = chunkDocs[i]!.body.slice(-overlap);
397 expect(chunkDocs[i + 1]!.body.startsWith(tail)).toBe(true);
398 }
399 });
400
401 it("a short body ⇒ NO chunk docs (whole doc only)", async () => {
402 const whole = {
403 docId: "page:pg1",
404 type: "page" as const,
405 id: "pg1",
406 workspaceId: "ws1",
407 title: "T",
408 body: "x".repeat(200),
409 grants: { public: false, workspace: true, user: false, teams: [], users: [] },
410 archived: false,
411 url: "/w/ws1/page/pg1",
412 createdAt: "t",
413 updatedAt: "t"
414 };
415 expect(expandChunkDocs(whole)).toEqual([whole]);
416 });
417
418 it("non-chunkable types (block/comment/…) ⇒ unchanged, single doc", async () => {
419 const doc = {
420 docId: "comment:c1",
421 type: "comment" as const,
422 id: "c1",
423 workspaceId: "ws1",
424 title: "C",
425 body: "y".repeat(5000),
426 grants: { public: false, workspace: true, user: false, teams: [], users: [] },
427 archived: false,
428 url: "/w/ws1",
429 createdAt: "t",
430 updatedAt: "t"
431 };
432 expect(expandChunkDocs(doc)).toEqual([doc]);
433 });
434
435 it("indexDocs writes chunk docs to the index but NOT to the mirror", async () => {
436 const { prisma, page, searchDocument } = fakePrisma();
437 page.findUnique.mockResolvedValue(bigPage());
438 const whole = (await buildPageDoc({ prisma, now: () => NOW }, "pg1"))!;
439 const fs = fakeSearch(prisma);
440
441 await indexDocs(fs.ops, [whole]);
442 const indexed = allIndexed(fs);
443 expect(indexed.filter((d) => d.type === "chunk").length).toBeGreaterThan(0);
444 expect(indexed.filter((d) => d.type === "chunk").every((d) => d.parentDocId === "page:pg1")).toBe(true);
445
446 expect(searchDocument.upsert).toHaveBeenCalledTimes(1);
447 const mirrored = searchDocument.upsert.mock.calls[0]![0] as { create: { type: string; refId: string } };
448 expect(mirrored.create.type).toBe("page");
449 expect(mirrored.create.refId).toBe("pg1");
450 });
451
452 it("indexDocs clears the parent's stale chunks before writing (shrink/grow safe)", async () => {
453 const { prisma, page } = fakePrisma();
454 page.findUnique.mockResolvedValue(bigPage());
455 const whole = (await buildPageDoc({ prisma, now: () => NOW }, "pg1"))!;
456 const fs = fakeSearch(prisma);
457 await indexDocs(fs.ops, [whole]);
458 expect(fs.deleteChunksOf).toHaveBeenCalledTimes(1);
459 expect(fs.deleteChunksOf).toHaveBeenCalledWith(["page:pg1"]);
460 });
461
462 it("TASK-0125: indexDocs enqueues embed:chunks for page/record only — and survives a failed enqueue", async () => {
463 const { prisma, page } = fakePrisma();
464 page.findUnique.mockResolvedValue(bigPage());
465 const whole = (await buildPageDoc({ prisma, now: () => NOW }, "pg1"))!;
466 const blockDoc = {
467 docId: "block:b1",
468 type: "block" as const,
469 id: "b1",
470 workspaceId: "ws1",
471 title: "b",
472 body: "x",
473 grants: { public: false, workspace: true, user: false, teams: [], users: [] },
474 archived: false,
475 url: "/w/ws1",
476 createdAt: "t",
477 updatedAt: "t"
478 };
479 const enqueueEmbed = vi.fn(async () => {});
480 const fs = fakeSearch(prisma);
481 fs.ops.enqueueEmbed = enqueueEmbed;
482 await indexDocs(fs.ops, [whole, blockDoc]);
483
484 expect(enqueueEmbed).toHaveBeenCalledTimes(1);
485 expect(enqueueEmbed).toHaveBeenCalledWith({ workspaceId: "ws1", docType: "page", refId: "pg1" });
486
487
488 const fs2 = fakeSearch(prisma);
489 fs2.ops.enqueueEmbed = vi.fn(async () => {
490 throw new Error("redis down");
491 });
492 await expect(indexDocs(fs2.ops, [whole])).resolves.toMatchObject({ indexed: expect.any(Number) });
493 });
494
495 it("a record body over threshold is chunked too (parent-driven, #c<n>)", async () => {
496 const whole = {
497 docId: "record:r1",
498 type: "record" as const,
499 id: "r1",
500 workspaceId: "ws1",
501 title: "R",
502 body: LONG,
503 grants: { public: false, workspace: true, user: false, teams: [], users: [] },
504 archived: false,
505 url: "/w/ws1/databases/d1/r1",
506 createdAt: "t",
507 updatedAt: "t"
508 };
509 const expanded = expandChunkDocs(whole);
510 expect(expanded.length).toBe(chunkText(LONG, DEFAULT_CHUNK_OPTIONS).length + 1);
511 expect(expanded[1]!.type).toBe("chunk");
512 expect(expanded[1]!.docId).toBe("record:r1#c0");
513 expect(expanded[1]!.parentDocId).toBe("record:r1");
514 });
515});
516
517describe("buildUserDoc (per-workspace doc)", () => {
518 it("stamps docId user:<ws>:<uid> and grants every workspace member", async () => {
519 const { prisma, user } = fakePrisma();
520 user.findUnique.mockResolvedValue({
521 id: "u1",
522 name: "Ada",
523 displayName: "Ada L.",
524 email: "ada@x.io",
525 status: "ACTIVE",
526 updatedAt: NOW
527 });
528 const doc = await buildUserDoc({ prisma, now: () => NOW }, "u1", "ws1");
529 expect(doc!.docId).toBe("user:ws1:u1");
530 expect(doc!.id).toBe("u1");
531 expect(doc!.title).toBe("Ada L.");
532 expect(doc!.grants.workspace).toBe(true);
533 expect(doc!.url).toBe("/w/ws1");
534 expect(mirrorRefId(doc!)).toBe("ws1:u1");
535 });
536
537 it("suspended account ⇒ null", async () => {
538 const { prisma, user } = fakePrisma();
539 user.findUnique.mockResolvedValue({ id: "u1", name: "Ada", displayName: null, email: "a@x.io", status: "SUSPENDED", updatedAt: NOW });
540 await expect(buildUserDoc({ prisma, now: () => NOW }, "u1", "ws1")).resolves.toBeNull();
541 });
542});
543
544describe("contentHash / mirrorRefId", () => {
545 const g = { public: false, workspace: true, user: false, teams: [], users: [] };
546 it("is deterministic and sensitive to content/grants", () => {
547 const a = builderHash({ title: "t", body: "b", grants: g, archived: false });
548 const b = builderHash({ title: "t", body: "b", grants: g, archived: false });
549 const c = builderHash({ title: "t", body: "B", grants: g, archived: false });
550 const d = builderHash({ title: "t", body: "b", grants: { ...g, public: true }, archived: false });
551 expect(a).toBe(b);
552 expect(a).not.toBe(c);
553 expect(a).not.toBe(d);
554 expect(a).toMatch(/^[0-9a-f]{40}$/);
555 });
556 it("mirrorRefId: plain id except user (workspace-scoped)", () => {
557 expect(mirrorRefId({ type: "page", id: "p1", workspaceId: "ws1" })).toBe("p1");
558 expect(mirrorRefId({ type: "user", id: "u1", workspaceId: "ws1" })).toBe("ws1:u1");
559 expect(mirrorRefId({ type: "user", id: "u1", workspaceId: undefined })).toBe("u1");
560 });
561});
562
563describe("blockText / renderPropertyValue", () => {
564 it("blockText: text | parts[] | none", () => {
565 expect(blockText({ text: "hi" })).toBe("hi");
566 expect(blockText({ parts: [{ text: "a" }, { text: "b" }] })).toBe("ab");
567 expect(blockText({})).toBe("");
568 expect(blockText(null)).toBe("");
569 });
570 it("renderPropertyValue: relations → linked titles", () => {
571 const rel = new Map([["r1", "Linked Task"]]);
572 expect(renderPropertyValue("RELATION", ["r1", "r2"], rel)).toBe("Linked Task, r2");
573 expect(renderPropertyValue("TEXT", null)).toBe("");
574 expect(renderPropertyValue("NUMBER", 42)).toBe("42");
575 });
576});
577
578describe("upsertMirror / deleteMirror", () => {
579 it("upsert stores the grant snapshot + contentHash (create)", async () => {
580 const { prisma, searchDocument } = fakePrisma();
581 const doc: SearchDoc = {
582 docId: "page:pg1",
583 type: "page",
584 id: "pg1",
585 workspaceId: "ws1",
586 title: "Roadmap",
587 body: "b",
588 grants: { public: false, workspace: true, user: false, teams: [], users: [] },
589 archived: false,
590 url: "/w/ws1/page/pg1",
591 createdAt: NOW.toISOString(),
592 updatedAt: NOW.toISOString()
593 };
594 const res = await upsertMirror(prisma as never, doc);
595 expect(res.contentHash).toBe(builderHash(doc));
596 expect(searchDocument.upsert).toHaveBeenCalledWith(
597 expect.objectContaining({
598 where: { type_refId: { type: "page", refId: "pg1" } },
599 create: expect.objectContaining({
600 permissions: doc.grants,
601 contentHash: res.contentHash,
602 metadata: expect.objectContaining({ contentHash: res.contentHash, url: doc.url })
603 })
604 })
605 );
606 });
607
608 it("deleteMirror uses (type, refId) with workspace-scoped user refId", async () => {
609 const { prisma, searchDocument } = fakePrisma();
610 await deleteMirror(prisma as never, "user", "u1", "ws1");
611 expect(searchDocument.deleteMany).toHaveBeenCalledWith({
612 where: { type: "user", refId: "ws1:u1" }
613 });
614 await deleteMirror(prisma as never, "page", "pg1");
615 expect(searchDocument.deleteMany).toHaveBeenLastCalledWith({
616 where: { type: "page", refId: "pg1" }
617 });
618 });
619});
620
621
622
623
624
625describe("handleSearchEvent", () => {
626 it("page.created indexes the page doc + block docs", async () => {
627 const { prisma, page } = fakePrisma();
628 const fs = fakeSearch(prisma);
629 page.findUnique.mockResolvedValue(basePage());
630 await handleSearchEvent(fs.ops, { event: "page.created", payload: { workspaceId: "ws1", pageId: "pg1" } });
631 const docs = allIndexed(fs);
632 expect(docs.map((d) => d.docId).sort()).toEqual(["block:b1", "page:pg1"]);
633 expect(docs.find((d) => d.docId === "page:pg1")!.grants.workspace).toBe(true);
634 });
635
636 it("page.deleted permanent ⇒ deletes page + mirror-enumerated blocks", async () => {
637 const { prisma, page, searchDocument } = fakePrisma();
638 const fs = fakeSearch(prisma);
639
640 page.findUnique.mockResolvedValue(null);
641 searchDocument.findMany.mockResolvedValue([{ refId: "b1" }, { refId: "b2" }]);
642 await handleSearchEvent(fs.ops, {
643 event: "page.deleted",
644 payload: { workspaceId: "ws1", pageId: "pg1", permanent: true, pageIds: ["pg1", "pg2"] }
645 });
646 expect(fs.deleted).toContain("page:pg1");
647 expect(fs.deleted).toContain("page:pg2");
648 expect(fs.deleted).toContain("block:b1");
649 expect(fs.deleted).toContain("block:b2");
650 });
651
652 it("page.deleted (trash) removes the doc from the index (restore re-adds via reconcile)", async () => {
653 const { prisma, page, searchDocument } = fakePrisma();
654 const fs = fakeSearch(prisma);
655
656 page.findUnique.mockResolvedValue({ ...basePage(), trashedAt: NOW });
657
658
659 searchDocument.findMany.mockResolvedValue([{ refId: "b1" }]);
660 await handleSearchEvent(fs.ops, {
661 event: "page.deleted",
662 payload: { workspaceId: "ws1", pageId: "pg1", permanent: false, pageIds: ["pg1"] }
663 });
664 expect(fs.deleted).toContain("page:pg1");
665 expect(fs.deleted).toContain("block:b1");
666 expect(allIndexed(fs)).toEqual([]);
667 });
668
669 it("permission.changed ⇒ workspace resync enqueued (never targeted)", async () => {
670 const { prisma } = fakePrisma();
671 const fs = fakeSearch(prisma);
672 await handleSearchEvent(fs.ops, {
673 event: "permission.changed",
674 payload: { workspaceId: "ws1", userId: "u2", role: "MEMBER" }
675 });
676 expect(enqueue).toHaveBeenCalledWith(fs.ops.connection, QUEUES.maintenance, {
677 type: "workspace-resync",
678 workspaceId: "ws1"
679 });
680 expect(fs.indexed).toEqual([]);
681 });
682
683 it("comment.deleted ⇒ doc removed", async () => {
684 const { prisma, searchDocument } = fakePrisma();
685 const fs = fakeSearch(prisma);
686 searchDocument.deleteMany.mockResolvedValue({ count: 1 });
687 await handleSearchEvent(fs.ops, {
688 event: "comment.deleted",
689 payload: { workspaceId: "ws1", commentId: "c1" }
690 });
691 expect(fs.deleted).toEqual(["comment:c1"]);
692 expect(searchDocument.deleteMany).toHaveBeenCalled();
693 });
694
695 it("user.register stamps the per-workspace user doc", async () => {
696 const { prisma, user } = fakePrisma();
697 const fs = fakeSearch(prisma);
698 user.findUnique.mockResolvedValue({
699 id: "u1",
700 name: "Ada",
701 displayName: null,
702 email: "ada@x.io",
703 status: "ACTIVE",
704 updatedAt: NOW
705 });
706 await handleSearchEvent(fs.ops, {
707 event: "user.register",
708 payload: { userId: "u1", workspaceId: "ws1" }
709 });
710 expect(allIndexed(fs).map((d) => d.docId)).toEqual(["user:ws1:u1"]);
711 });
712
713 it("unknown source row (poison) ⇒ logs, does not throw", async () => {
714 const { prisma } = fakePrisma();
715 const fs = fakeSearch(prisma);
716 await expect(
717 handleSearchEvent(fs.ops, { event: "database.created", payload: { workspaceId: "ws1", databaseId: "gone" } })
718 ).resolves.toBeUndefined();
719 expect(fs.indexed).toEqual([]);
720 });
721
722 it("transport failure ⇒ rethrows (entry stays pending)", async () => {
723 const { prisma, page } = fakePrisma();
724 const fs = fakeSearch(prisma);
725 page.findUnique.mockResolvedValue(basePage());
726 fs.ops.search.indexDocs = vi.fn(async () => {
727 throw new Error("opensearch down");
728 }) as never;
729 await expect(
730 handleSearchEvent(fs.ops, { event: "page.updated", payload: { pageId: "pg1" } })
731 ).rejects.toThrow("opensearch down");
732 });
733
734 it("event not in the search subscription ⇒ no-op", async () => {
735 const { prisma } = fakePrisma();
736 const fs = fakeSearch(prisma);
737 await handleSearchEvent(fs.ops, { event: "notification.created", payload: { id: "n1" } });
738 expect(fs.indexed).toEqual([]);
739 expect(fs.deleted).toEqual([]);
740 });
741});
742
743describe("removeDoc", () => {
744 it("tolerates 404 (idempotent delete)", async () => {
745 const { prisma } = fakePrisma();
746 const fs = fakeSearch(prisma);
747 fs.ops.search.deleteDoc = vi.fn(async () => {
748 const { ApiError, ErrorCodes } = await import("@factory/shared/errors");
749 throw new ApiError(ErrorCodes.SEARCH_UNAVAILABLE, { message: "404", status: 404 });
750 }) as never;
751 await expect(removeDoc(fs.ops, "page", "pg1", "ws1")).resolves.toBeUndefined();
752 });
753});
754
755
756
757
758
759describe("runReconcile (contentHash sweep)", () => {
760 it("repairs drifted docs and removes orphan mirror rows", async () => {
761 const { prisma, page, searchDocument } = fakePrisma();
762 const fs = fakeSearch(prisma);
763
764 const inSyncDoc = await (async () => {
765 page.findUnique.mockResolvedValueOnce(basePage());
766 return buildPageDoc({ prisma, now: () => NOW }, "pg1");
767 })();
768 page.findUnique.mockReset();
769 page.findUnique.mockImplementation(async (a: { where: { id: string } }) =>
770 a.where.id === "pg1" ? basePage() : a.where.id === "pg2" ? { ...basePage(), id: "pg2", title: "Changed" } : null
771 );
772 const inSyncHash = builderHash(inSyncDoc!);
773 searchDocument.findMany.mockResolvedValue([
774 { type: "page", refId: "pg1", contentHash: inSyncHash, indexedAt: NOW },
775 { type: "page", refId: "pg2", contentHash: "stale", indexedAt: NOW },
776 { type: "page", refId: "pg3", contentHash: "stale", indexedAt: NOW }
777 ]);
778 const res = await runReconcile(fs.ops, "ws1");
779
780 expect(res.checked).toBe(3);
781 expect(res.repaired).toBe(1);
782 expect(res.removed).toBe(1);
783 expect(fs.deleted).toContain("page:pg3");
784 });
785
786 it("re-indexes a doc LOST from the index (healthy mirror, missing in OpenSearch)", async () => {
787 const { prisma, page, searchDocument } = fakePrisma();
788 const fs = fakeSearch(prisma);
789 page.findUnique.mockResolvedValue(basePage());
790
791 const hash = builderHash((await buildPageDoc({ prisma, now: () => NOW }, "pg1"))!);
792 searchDocument.findMany.mockResolvedValue([
793 { type: "page", refId: "pg1", contentHash: hash, indexedAt: NOW }
794 ]);
795
796 fs.docExists.mockImplementation(async (ids: string[]) => new Set(ids.filter((id) => id !== "page:pg1")));
797 const res = await runReconcile(fs.ops, "ws1");
798 expect(res.repaired).toBe(1);
799 expect(allIndexed(fs).map((d) => d.docId)).toContain("page:pg1");
800 });
801
802 it("re-adds a RESTORED page via the source→index direction (no mirror row)", async () => {
803 const { prisma, page, searchDocument } = fakePrisma();
804 const fs = fakeSearch(prisma);
805
806
807 searchDocument.findMany.mockResolvedValue([]);
808 page.findMany.mockResolvedValue([{ id: "pg1" }]);
809 page.findUnique.mockResolvedValue(basePage());
810
811 fs.docExists.mockResolvedValue(new Set());
812 const res = await runReconcile(fs.ops, "ws1");
813
814 expect(res.repaired).toBe(2);
815 expect(allIndexed(fs).map((d) => d.docId).sort()).toEqual(["block:b1", "page:pg1"]);
816 });
817});
818
819describe("runReindex (full (re)index, T-156 org scoping)", () => {
820 it("no orgId → reindexes ALL workspaces", async () => {
821 const { prisma, workspace } = fakePrisma();
822 const fs = fakeSearch(prisma);
823 workspace.findMany.mockResolvedValue([
824 { id: "ws1" },
825 { id: "ws2" }
826 ]);
827 const res = await runReindex(fs.ops);
828 expect(workspace.findMany).toHaveBeenCalledWith({
829 where: {},
830 select: { id: true }
831 });
832 expect(res.workspaces).toBe(2);
833 });
834
835 it("orgId → ONLY that org's workspaces are reindexed (other orgs untouched)", async () => {
836 const { prisma, workspace } = fakePrisma();
837 const fs = fakeSearch(prisma);
838 workspace.findMany.mockResolvedValue([
839 { id: "ws1" },
840 { id: "ws2" }
841 ]);
842 const res = await runReindex(fs.ops, undefined, "org1");
843 expect(workspace.findMany).toHaveBeenCalledWith({
844 where: { organizationId: "org1" },
845 select: { id: true }
846 });
847
848
849 workspace.findMany.mockResolvedValue([{ id: "ws3" }]);
850 await runReindex(fs.ops, undefined, "org2");
851 expect(workspace.findMany).toHaveBeenLastCalledWith({
852 where: { organizationId: "org2" },
853 select: { id: true }
854 });
855
856 workspace.findMany.mockResolvedValue([]);
857 const empty = await runReindex(fs.ops, undefined, "org-empty");
858 expect(empty.workspaces).toBe(0);
859 expect(empty.docs).toBe(0);
860 });
861});
862
863describe("ensureContentIndex", () => {
864 it("creates the index + alias when missing (real SearchClient, fake fetch)", async () => {
865 const calls: { path: string; method: string }[] = [];
866 const fetchImpl = (vi.fn(async (url: string | URL | Request) => {
867 const u = String(url);
868 const method = u.includes("_bulk") ? "POST" : u.endsWith("_aliases") ? "POST" : u.includes("factory-content") ? "PUT" : "GET";
869 calls.push({ path: u.replace("http://os:9200", ""), method });
870 if (u.endsWith("/factory-content-v1")) {
871
872 if (calls.filter((c) => c.path === "/factory-content-v1").length === 1) {
873 return new Response("nf", { status: 404 });
874 }
875 return new Response(JSON.stringify({ mappings: { properties: { vector: { dimension: 1536 } } } }), { status: 200 });
876 }
877 return new Response(JSON.stringify({}), { status: 200 });
878 }) as unknown) as typeof fetch;
879 const { prisma } = fakePrisma();
880 const search = new SearchClient({ baseUrl: "http://os:9200", fetchImpl });
881 const ops: SearchIndexOps = { search, prisma, connection: { xadd: vi.fn() } as never, env: ENV };
882 const res = await ensureContentIndex(ops);
883 expect(res.created).toBe(true);
884 const paths = calls.map((c) => c.path);
885 expect(paths).toContain("/factory-content-v1");
886 expect(paths).toContain("/_aliases");
887 });
888
889 it("leaves a dimension-mismatched index alone (rollover warning path)", async () => {
890 const { prisma } = fakePrisma();
891 const search = {
892 indexExists: vi.fn(async () => true),
893 getVectorDimension: vi.fn(async () => 768),
894 createIndexIfMissing: vi.fn(),
895 createAlias: vi.fn()
896 } as unknown as SearchClient;
897 const ops: SearchIndexOps = { search, prisma, connection: { xadd: vi.fn() } as never, env: ENV };
898 const res = await ensureContentIndex(ops);
899 expect(res.created).toBe(false);
900 expect(search.createIndexIfMissing).not.toHaveBeenCalled();
901 expect(search.createAlias).not.toHaveBeenCalled();
902 });
903});
904
905describe("recordSearchDlq", () => {
906 it("xadds a MAXLEN-capped DLQ entry", async () => {
907 const { prisma } = fakePrisma();
908 const fs = fakeSearch(prisma);
909 const job = { id: "job-9", data: { type: "reindex", workspaceId: "ws1" } } as never;
910 await recordSearchDlq(fs.ops.connection, job, new Error("boom"));
911 expect(fs.xadd).toHaveBeenCalledTimes(1);
912
913
914
915 const args = fs.xadd.mock.calls[0]! as unknown as string[];
916 expect(args[0]).toBe("search-index:dlq");
917 expect(args).toContain("MAXLEN");
918 expect(args).toContain("~");
919 expect(args[4]).toBe("*");
920 expect(args).toContain("job-9");
921 expect(args).toContain("boom");
922 });
923});
924