/** * AI Software Factory — Integration: Search (TASK-0097 S3) * * The search API (POST /search, GET /search/query, /search/suggest, and the * recent-search routes) against the REAL API + a REAL OpenSearch. It exercises * the full HTTP contract: the global auth guard, the Zod validation pipe, the * fail-closed caller-scope resolver, the tenant clause, and the 400/404/503 * error mapping (docs/api/search.md). * * ── RUNNER GATE (Docker + OpenSearch) — SELF-SKIPS WHEN THE STACK IS ABSENT ── * The suite needs TWO things: * 1. A Docker daemon (Testcontainers Postgres/Redis via createTestApp — the * same infra the other integration specs use), AND * 2. A reachable OpenSearch at the API's SEARCH_URL (default * http://localhost:9200) — the full dev/CI stack's search engine, NOT a * container this spec controls. The `factory-content` index is the * stack's SHARED index and the search-indexer worker is the only thing * that writes it in production, so THIS spec seeds it directly with * `@factory/search`'s `SearchClient` (no worker builder imports). * When either is absent (e.g. a Docker-less authoring host) the whole suite * SKIPs — an honest skip, not a fake pass. CI (the full stack up, OpenSearch * waited-for at :9200, `SEARCH_URL` set to it) runs it green. The gate is * evaluated at COLLECTION time (a sync Docker probe + one async OpenSearch * health probe, via a top-level `await` — the file is ESM) and feeds * `describe.skipIf`, so an absent stack never fails the suite. * * Covered: * - the caller's OWN workspace doc comes back in `data` (docId/type/title), * - tenant isolation: another workspace's doc NEVER appears for the caller, * and an explicit `workspaceId` the caller is not a member of → 404, * - validation: `limit` 51 → 400 VALIDATION_ERROR, `limit` 50 → 200. * * NOTE (recorded, TASK-0097 S3): the other root integration specs import the * shared helpers via `../helpers/*`, which resolves OUTSIDE the package * (`tests/helpers/` does not exist — the helpers live at * `tests/integration/helpers/`), so those specs currently fail to LOAD under * vitest (pre-existing, repo-wide; see the S3 report). This spec imports * them via `./helpers/*` — the path that resolves — so it is independently * CI-ready. If the suite-wide import/layout fix lands, adjust to match. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { execSync } from "node:child_process"; import { SearchClient, contentMapping, type SearchDoc } from "@factory/search"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { authHeaders, createWorkspace, createUser } from "./helpers/seed"; /** The OpenSearch the API talks to (API's SEARCH_URL default). */ const SEARCH_URL = process.env.SEARCH_URL ?? "http://localhost:9200"; /** Docker present? (sync probe — Testcontainers Postgres/Redis need it). */ function dockerAvailable(): boolean { try { execSync("docker info", { stdio: "ignore", timeout: 8_000 }); return true; } catch { return false; } } /** Is the OpenSearch at `url` (the API's SEARCH_URL) reachable? */ async function opensearchUp(url: string): Promise { try { const res = await fetch(`${url.replace(/\/+$/, "")}/_cluster/health`, { signal: AbortSignal.timeout(5_000), }); return res.ok; } catch { return false; } } /** * Collection-time stack gate (Docker + OpenSearch). A top-level `await` is * safe here — the file is ESM and Vitest awaits the module before collecting * its tests — so `describe.skipIf` below sees a settled boolean. */ const stackReady = dockerAvailable() && (await opensearchUp(SEARCH_URL)); /** Run-unique suffix — the shared `factory-content` index persists across * dev-stack runs, so doc ids must not collide between runs. */ const RUN = Date.now().toString(36); const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; /** Hand-built content doc (the same shape the worker's builder emits). */ function makeDoc( over: Partial & Pick, ): SearchDoc { return { body: "quarterly forecast and budget review", grants: { public: false, workspace: true, user: false, teams: [], users: [] }, archived: false, url: `/w/${over.workspaceId}/p/${over.id}`, createdAt: "2025-01-01T00:00:00.000Z", updatedAt: "2025-06-01T00:00:00.000Z", ...over, }; } let app: TestAppHandle; let client: SearchClient; let ws1 = ""; // user A's workspace let ws2 = ""; // user B's workspace let docA = ""; // docId of user A's indexed page let docB = ""; // docId of user B's indexed page let authA: Record = {}; // bearer header for user A (the ws1 owner) describe.skipIf(!stackReady)( "Search integration (TASK-0097 S3)", () => { beforeAll(async () => { app = await createTestApp(); client = new SearchClient({ baseUrl: SEARCH_URL }); // Ensure the single content index the API reads from exists with the // canonical mapping (keyword workspaceId/type/grants, text title/body). // CI pre-creates only `factory-docs`, so this is what makes the // `factory-content` target present (idempotent). const mapping = contentMapping() as { settings: Record; mappings: Record; }; // TASK-0125: `factory-content` may be an ALIAS (the dev stack + the // reindex rollover point it at `factory-content-vN`), so a bare // `PUT /factory-content` 400s (alias vs index). Create only when the // name is genuinely absent — `indexExists` resolves through aliases, so // this is idempotent for both the real-index and the alias cases (a // fresh CI stack still gets the plain index). if (!(await client.indexExists("factory-content"))) { await client.createIndexIfMissing("factory-content", mapping); } // Fixtures THROUGH THE API (real authorization layer): two users, each // owner of their own workspace (tenant isolation needs two tenants). const userA = await createUser(app.request, { name: "Search Owner A" }); ws1 = (await createWorkspace(app.request, authHeaders(userA.accessToken!), "Search WS A")) .workspaceId; authA = authHeaders(userA.accessToken!); const userB = await createUser(app.request, { name: "Search Owner B" }); ws2 = (await createWorkspace(app.request, authHeaders(userB.accessToken!), "Search WS B")) .workspaceId; // Hand-build + index one page per workspace into the SHARED index // (packages/search is the ONLY search dep here — no worker builder // imports), then refresh so they are immediately queryable. docA = `page:${RUN}-a1`; docB = `page:${RUN}-b1`; await client.indexDocs([ makeDoc({ docId: docA, type: "page", id: `${RUN}-a1`, title: "Q3 Forecast", workspaceId: ws1, }), makeDoc({ docId: docB, type: "page", id: `${RUN}-b1`, title: "Q3 Forecast", workspaceId: ws2, }), ]); await client.refresh(); }, 180_000); afterAll(async () => { await app?.close(); }); it("POST /search returns the caller's own workspace doc (docId/type/title)", async () => { const h = authA; const res = await app.request("POST", "/api/v1/search", { headers: h, body: { q: "forecast" }, }); expect(res.status).toBe(200); const body = await json(res); expect(typeof body.tookMs).toBe("number"); expect("nextCursor" in body).toBe(true); const data = (body.data as Array>) ?? []; const mine = data.find((d) => d.docId === docA); expect(mine, "the caller's own workspace doc must be in data").toBeTruthy(); expect(mine).toMatchObject({ docId: docA, type: "page", title: "Q3 Forecast", workspaceId: ws1, }); }); it("tenant isolation: another workspace's doc never appears for the caller", async () => { const h = authA; const res = await app.request("POST", "/api/v1/search", { headers: h, body: { q: "forecast" }, }); expect(res.status).toBe(200); const data = ((await json(res)).data as Array>) ?? []; const foreign = data.find((d) => d.docId === docB || d.workspaceId === ws2); expect( foreign, "a doc from a workspace the caller is not a member of must NOT appear", ).toBeUndefined(); }); it("explicit workspaceId the caller is NOT a member of → 404 NOT_FOUND (no leak)", async () => { const h = authA; const res = await app.request("POST", "/api/v1/search", { headers: h, body: { q: "forecast", workspaceId: ws2 }, }); expect(res.status).toBe(404); const body = await json(res); expect((body.error as { code?: string })?.code).toBe("NOT_FOUND"); }); it("validation: limit 51 → 400 VALIDATION_ERROR, limit 50 → 200", async () => { const h = authA; const bad = await app.request("POST", "/api/v1/search", { headers: h, body: { q: "forecast", limit: 51 }, }); expect(bad.status).toBe(400); expect(((await json(bad)).error as { code?: string })?.code).toBe("VALIDATION_ERROR"); const ok = await app.request("POST", "/api/v1/search", { headers: h, body: { q: "forecast", limit: 50 }, }); expect(ok.status).toBe(200); }); it("T-155 sortBy=createdAt orders newest-first (deterministic, direct index)", async () => { const h = authA; // Three docs in ws1 sharing a unique title token, with DISTINCT // createdAt — a query for the token matches all three, so the ordering // is what is under test. (updatedAt is identical for all → the // updatedAt branch is only asserted for 200 + count, not exact order.) // A single, spaceless, run-unique token — no pre-existing doc can match // it, so a query for it matches exactly these three. const token = `sortfix${RUN}x`; const createdAt = [ "2025-01-15T00:00:00.000Z", "2025-03-15T00:00:00.000Z", "2025-05-15T00:00:00.000Z", ]; const ids = [`${RUN}-s1`, `${RUN}-s2`, `${RUN}-s3`]; await client.indexDocs( createdAt.map((d, i) => makeDoc({ docId: `page:${ids[i]}`, type: "page", id: ids[i]!, title: token, workspaceId: ws1, createdAt: d, }), ), ); await client.refresh(); // sortBy=createdAt → newest first (s3, s2, s1). const sorted = await app.request("POST", "/api/v1/search", { headers: h, body: { q: token, workspaceId: ws1, sortBy: "createdAt", limit: 50 }, }); expect(sorted.status).toBe(200); const sdata = ((await json(sorted)).data as Array>) ?? []; expect(sdata.map((d) => d.id)).toEqual([ids[2], ids[1], ids[0]]); // sortBy=updatedAt → 200 + all three (order not asserted: identical updatedAt). const upd = await app.request("POST", "/api/v1/search", { headers: h, body: { q: token, workspaceId: ws1, sortBy: "updatedAt", limit: 50 }, }); expect(upd.status).toBe(200); expect(((await json(upd)).data as unknown[]).length).toBe(3); // Relevance (default) is unchanged: the same three docs, any order. const rel = await app.request("POST", "/api/v1/search", { headers: h, body: { q: token, workspaceId: ws1, limit: 50 }, }); expect(rel.status).toBe(200); const rids = ((await json(rel)).data as Array>) .map((d) => d.id as string) .sort(); expect(rids).toEqual([...ids].sort()); }); it("T-155 invalid sortBy → 400 VALIDATION_ERROR", async () => { const h = authA; const bad = await app.request("POST", "/api/v1/search", { headers: h, body: { q: "forecast", sortBy: "not-a-sort" }, }); expect(bad.status).toBe(400); expect(((await json(bad)).error as { code?: string })?.code).toBe("VALIDATION_ERROR"); }); }, 180_000, );