1
41import { afterAll, beforeAll, describe, expect, it } from "vitest";
42import { execSync } from "node:child_process";
43import { SearchClient, contentMapping, type SearchDoc } from "@factory/search";
44import { createTestApp, type TestAppHandle } from "./helpers/api";
45import { authHeaders, createWorkspace, createUser } from "./helpers/seed";
46
47
48const SEARCH_URL = process.env.SEARCH_URL ?? "http://localhost:9200";
49
50
51function dockerAvailable(): boolean {
52 try {
53 execSync("docker info", { stdio: "ignore", timeout: 8_000 });
54 return true;
55 } catch {
56 return false;
57 }
58}
59
60
61async function opensearchUp(url: string): Promise<boolean> {
62 try {
63 const res = await fetch(`${url.replace(/\/+$/, "")}/_cluster/health`, {
64 signal: AbortSignal.timeout(5_000),
65 });
66 return res.ok;
67 } catch {
68 return false;
69 }
70}
71
72
77const stackReady = dockerAvailable() && (await opensearchUp(SEARCH_URL));
78
79
81const RUN = Date.now().toString(36);
82
83const json = async (res: Response): Promise<Record<string, unknown>> =>
84 (await res.json().catch(() => ({}))) as Record<string, unknown>;
85
86
87function makeDoc(
88 over: Partial<SearchDoc> & Pick<SearchDoc, "docId" | "type" | "id" | "title" | "workspaceId">,
89): SearchDoc {
90 return {
91 body: "quarterly forecast and budget review",
92 grants: { public: false, workspace: true, user: false, teams: [], users: [] },
93 archived: false,
94 url: `/w/${over.workspaceId}/p/${over.id}`,
95 createdAt: "2025-01-01T00:00:00.000Z",
96 updatedAt: "2025-06-01T00:00:00.000Z",
97 ...over,
98 };
99}
100
101let app: TestAppHandle;
102let client: SearchClient;
103let ws1 = "";
104let ws2 = "";
105let docA = "";
106let docB = "";
107let authA: Record<string, string> = {};
108
109describe.skipIf(!stackReady)(
110 "Search integration (TASK-0097 S3)",
111 () => {
112 beforeAll(async () => {
113 app = await createTestApp();
114 client = new SearchClient({ baseUrl: SEARCH_URL });
115
116
117
118
119 const mapping = contentMapping() as {
120 settings: Record<string, unknown>;
121 mappings: Record<string, unknown>;
122 };
123
124
125
126
127
128
129 if (!(await client.indexExists("factory-content"))) {
130 await client.createIndexIfMissing("factory-content", mapping);
131 }
132
133
134
135 const userA = await createUser(app.request, { name: "Search Owner A" });
136 ws1 = (await createWorkspace(app.request, authHeaders(userA.accessToken!), "Search WS A"))
137 .workspaceId;
138 authA = authHeaders(userA.accessToken!);
139 const userB = await createUser(app.request, { name: "Search Owner B" });
140 ws2 = (await createWorkspace(app.request, authHeaders(userB.accessToken!), "Search WS B"))
141 .workspaceId;
142
143
144
145
146 docA = `page:${RUN}-a1`;
147 docB = `page:${RUN}-b1`;
148 await client.indexDocs([
149 makeDoc({
150 docId: docA,
151 type: "page",
152 id: `${RUN}-a1`,
153 title: "Q3 Forecast",
154 workspaceId: ws1,
155 }),
156 makeDoc({
157 docId: docB,
158 type: "page",
159 id: `${RUN}-b1`,
160 title: "Q3 Forecast",
161 workspaceId: ws2,
162 }),
163 ]);
164 await client.refresh();
165 }, 180_000);
166 afterAll(async () => {
167 await app?.close();
168 });
169
170 it("POST /search returns the caller's own workspace doc (docId/type/title)", async () => {
171 const h = authA;
172 const res = await app.request("POST", "/api/v1/search", {
173 headers: h,
174 body: { q: "forecast" },
175 });
176 expect(res.status).toBe(200);
177 const body = await json(res);
178 expect(typeof body.tookMs).toBe("number");
179 expect("nextCursor" in body).toBe(true);
180 const data = (body.data as Array<Record<string, unknown>>) ?? [];
181 const mine = data.find((d) => d.docId === docA);
182 expect(mine, "the caller's own workspace doc must be in data").toBeTruthy();
183 expect(mine).toMatchObject({
184 docId: docA,
185 type: "page",
186 title: "Q3 Forecast",
187 workspaceId: ws1,
188 });
189 });
190
191 it("tenant isolation: another workspace's doc never appears for the caller", async () => {
192 const h = authA;
193 const res = await app.request("POST", "/api/v1/search", {
194 headers: h,
195 body: { q: "forecast" },
196 });
197 expect(res.status).toBe(200);
198 const data = ((await json(res)).data as Array<Record<string, unknown>>) ?? [];
199 const foreign = data.find((d) => d.docId === docB || d.workspaceId === ws2);
200 expect(
201 foreign,
202 "a doc from a workspace the caller is not a member of must NOT appear",
203 ).toBeUndefined();
204 });
205
206 it("explicit workspaceId the caller is NOT a member of → 404 NOT_FOUND (no leak)", async () => {
207 const h = authA;
208 const res = await app.request("POST", "/api/v1/search", {
209 headers: h,
210 body: { q: "forecast", workspaceId: ws2 },
211 });
212 expect(res.status).toBe(404);
213 const body = await json(res);
214 expect((body.error as { code?: string })?.code).toBe("NOT_FOUND");
215 });
216
217 it("validation: limit 51 → 400 VALIDATION_ERROR, limit 50 → 200", async () => {
218 const h = authA;
219 const bad = await app.request("POST", "/api/v1/search", {
220 headers: h,
221 body: { q: "forecast", limit: 51 },
222 });
223 expect(bad.status).toBe(400);
224 expect(((await json(bad)).error as { code?: string })?.code).toBe("VALIDATION_ERROR");
225
226 const ok = await app.request("POST", "/api/v1/search", {
227 headers: h,
228 body: { q: "forecast", limit: 50 },
229 });
230 expect(ok.status).toBe(200);
231 });
232
233 it("T-155 sortBy=createdAt orders newest-first (deterministic, direct index)", async () => {
234 const h = authA;
235
236
237
238
239
240
241 const token = `sortfix${RUN}x`;
242 const createdAt = [
243 "2025-01-15T00:00:00.000Z",
244 "2025-03-15T00:00:00.000Z",
245 "2025-05-15T00:00:00.000Z",
246 ];
247 const ids = [`${RUN}-s1`, `${RUN}-s2`, `${RUN}-s3`];
248 await client.indexDocs(
249 createdAt.map((d, i) =>
250 makeDoc({
251 docId: `page:${ids[i]}`,
252 type: "page",
253 id: ids[i]!,
254 title: token,
255 workspaceId: ws1,
256 createdAt: d,
257 }),
258 ),
259 );
260 await client.refresh();
261
262
263 const sorted = await app.request("POST", "/api/v1/search", {
264 headers: h,
265 body: { q: token, workspaceId: ws1, sortBy: "createdAt", limit: 50 },
266 });
267 expect(sorted.status).toBe(200);
268 const sdata = ((await json(sorted)).data as Array<Record<string, unknown>>) ?? [];
269 expect(sdata.map((d) => d.id)).toEqual([ids[2], ids[1], ids[0]]);
270
271
272 const upd = await app.request("POST", "/api/v1/search", {
273 headers: h,
274 body: { q: token, workspaceId: ws1, sortBy: "updatedAt", limit: 50 },
275 });
276 expect(upd.status).toBe(200);
277 expect(((await json(upd)).data as unknown[]).length).toBe(3);
278
279
280 const rel = await app.request("POST", "/api/v1/search", {
281 headers: h,
282 body: { q: token, workspaceId: ws1, limit: 50 },
283 });
284 expect(rel.status).toBe(200);
285 const rids = ((await json(rel)).data as Array<Record<string, unknown>>)
286 .map((d) => d.id as string)
287 .sort();
288 expect(rids).toEqual([...ids].sort());
289 });
290
291 it("T-155 invalid sortBy → 400 VALIDATION_ERROR", async () => {
292 const h = authA;
293 const bad = await app.request("POST", "/api/v1/search", {
294 headers: h,
295 body: { q: "forecast", sortBy: "not-a-sort" },
296 });
297 expect(bad.status).toBe(400);
298 expect(((await json(bad)).error as { code?: string })?.code).toBe("VALIDATION_ERROR");
299 });
300 },
301 180_000,
302);
303