import { describe, expect, it } from "@suaveplan/testing/runner"; import { DEFAULT_SEARCH_CONFIG, type SearchServiceConfig, } from "../service/service.js"; import type { SearchIndex } from "../types/types.js"; import { createSearchRoutes, type SearchRouteEvent } from "./build-routes.js"; interface EmittedEvent { type: SearchRouteEvent["type"]; payload: SearchRouteEvent["payload"]; } function makeDeps(): { indexes: Map; emitted: EmittedEvent[]; app: ReturnType; serviceConfig: SearchServiceConfig; } { const indexes = new Map(); const emitted: EmittedEvent[] = []; const serviceConfig = { ...DEFAULT_SEARCH_CONFIG }; const app = createSearchRoutes({ indexes, serviceConfig, emitEvent: (event) => { emitted.push({ type: event.type, payload: event.payload }); }, }); return { indexes, emitted, app, serviceConfig }; } async function jsonRequest( app: ReturnType, method: string, path: string, body?: unknown ): Promise<{ status: number; body: unknown }> { const init: RequestInit = { method, headers: { "content-type": "application/json" }, }; if (body !== undefined) { init.body = JSON.stringify(body); } const res = await app.request(path, init); const text = await res.text(); return { status: res.status, body: text.length > 0 ? JSON.parse(text) : undefined, }; } async function createPostsIndex( app: ReturnType ): Promise { const r = await jsonRequest(app, "POST", "/indexes", { name: "posts", fields: [ { name: "title", weight: 2, facet: false }, { name: "body", weight: 1, facet: false }, { name: "tag", weight: 1, facet: true }, ], analyzer: "standard", }); if (r.status !== 201) { throw new Error(`Failed to create posts index: ${JSON.stringify(r)}`); } } describe("POST /query", () => { it("POST /query returns 200 and emits search:query:executed", { timeout: 5000, }, async () => { const { app, emitted } = makeDeps(); await createPostsIndex(app); await jsonRequest(app, "POST", "/indexes/posts/documents", { id: "1", fields: { title: "Cat" }, }); const r = await jsonRequest(app, "POST", "/query", { query: "cat", index: "posts", }); expect(r.status).toBe(200); expect(emitted.some((e) => e.type === "search:query:executed")).toBe( true ); }); it("POST /query returns 404 when the index does not exist", { timeout: 5000, }, async () => { const { app } = makeDeps(); const r = await jsonRequest(app, "POST", "/query", { query: "x", index: "missing", }); expect(r.status).toBe(404); }); it("POST /query returns 400 on schema rejection", { timeout: 5000, }, async () => { const { app } = makeDeps(); const r = await jsonRequest(app, "POST", "/query", { query: "", index: "x", }); expect(r.status).toBe(400); }); }); describe("POST /suggest", () => { it("POST /suggest returns 200 with suggestions", { timeout: 5000, }, async () => { const { app } = makeDeps(); await createPostsIndex(app); await jsonRequest(app, "POST", "/indexes/posts/documents", { id: "1", fields: { title: "running runner" }, }); const r = await jsonRequest(app, "POST", "/suggest", { query: "run", index: "posts", limit: 10, }); expect(r.status).toBe(200); }); it("POST /suggest returns 404 for unknown index", { timeout: 5000, }, async () => { const { app } = makeDeps(); const r = await jsonRequest(app, "POST", "/suggest", { query: "run", index: "nope", limit: 10, }); expect(r.status).toBe(404); }); }); describe("GET /indexes", () => { it("returns the index list", { timeout: 5000 }, async () => { const { app } = makeDeps(); await createPostsIndex(app); const r = await jsonRequest(app, "GET", "/indexes"); expect(r.status).toBe(200); const body = r.body as { items: unknown[]; total: number }; expect(body.total).toBe(1); expect(body.items.length).toBe(1); }); }); describe("POST /indexes", () => { it("POST /indexes creates an index and returns 201", { timeout: 5000, }, async () => { const { app, emitted } = makeDeps(); await createPostsIndex(app); expect(emitted.some((e) => e.type === "search:index:created")).toBe( true ); }); it("returns 400 on duplicate", { timeout: 5000 }, async () => { const { app } = makeDeps(); await createPostsIndex(app); const r = await jsonRequest(app, "POST", "/indexes", { name: "posts", fields: [{ name: "title" }], analyzer: "standard", }); expect(r.status).toBe(400); }); }); describe("GET /indexes/:name", () => { it("returns 200 with details", { timeout: 5000 }, async () => { const { app } = makeDeps(); await createPostsIndex(app); const r = await jsonRequest(app, "GET", "/indexes/posts"); expect(r.status).toBe(200); }); it("returns 404 for missing", { timeout: 5000 }, async () => { const { app } = makeDeps(); const r = await jsonRequest(app, "GET", "/indexes/missing"); expect(r.status).toBe(404); }); }); describe("DELETE /indexes/:name", () => { it("returns 200 on hit", { timeout: 5000 }, async () => { const { app, emitted } = makeDeps(); await createPostsIndex(app); const r = await jsonRequest(app, "DELETE", "/indexes/posts"); expect(r.status).toBe(200); expect(emitted.some((e) => e.type === "search:index:deleted")).toBe( true ); }); it("DELETE /indexes/:name returns 404 for an unknown index", { timeout: 5000, }, async () => { const { app } = makeDeps(); const r = await jsonRequest(app, "DELETE", "/indexes/missing"); expect(r.status).toBe(404); }); }); describe("POST /indexes/:name/documents", () => { it("returns 201 on success", { timeout: 5000 }, async () => { const { app, emitted } = makeDeps(); await createPostsIndex(app); const r = await jsonRequest(app, "POST", "/indexes/posts/documents", { id: "1", fields: { title: "hello" }, }); expect(r.status).toBe(201); expect(emitted.some((e) => e.type === "search:index:updated")).toBe( true ); }); it("returns 404 when the index is missing", { timeout: 5000 }, async () => { const { app } = makeDeps(); const r = await jsonRequest(app, "POST", "/indexes/nope/documents", { id: "1", fields: {}, }); expect(r.status).toBe(404); }); }); describe("POST /indexes/:name/bulk", () => { it("returns 200 with indexed count", { timeout: 5000 }, async () => { const { app, emitted } = makeDeps(); await createPostsIndex(app); const r = await jsonRequest(app, "POST", "/indexes/posts/bulk", { index: "posts", documents: [ { id: "1", fields: { title: "a" } }, { id: "2", fields: { title: "b" } }, ], }); expect(r.status).toBe(200); const body = r.body as { indexed: number }; expect(body.indexed).toBe(2); expect( emitted.some( (e) => e.type === "search:index:updated" && (e.payload as { operation?: string }).operation === "bulk_indexed" ) ).toBe(true); }); it("returns 400 for missing index", { timeout: 5000 }, async () => { const { app } = makeDeps(); const r = await jsonRequest(app, "POST", "/indexes/nope/bulk", { index: "nope", documents: [{ id: "1", fields: {} }], }); expect(r.status).toBe(400); }); }); describe("DELETE /indexes/:name/documents/:docId", () => { it("DELETE /indexes/:name/documents/:docId returns 200 and emits search:index:updated", { timeout: 5000, }, async () => { const { app, emitted } = makeDeps(); await createPostsIndex(app); await jsonRequest(app, "POST", "/indexes/posts/documents", { id: "1", fields: { title: "a" }, }); const r = await jsonRequest( app, "DELETE", "/indexes/posts/documents/1" ); expect(r.status).toBe(200); expect( emitted.some( (e) => e.type === "search:index:updated" && (e.payload as { operation?: string }).operation === "document_removed" ) ).toBe(true); }); it("returns 404 when doc id absent", { timeout: 5000 }, async () => { const { app } = makeDeps(); await createPostsIndex(app); const r = await jsonRequest( app, "DELETE", "/indexes/posts/documents/missing" ); expect(r.status).toBe(404); }); it("returns 404 when target index does not exist", { timeout: 5000, }, async () => { const { app } = makeDeps(); const r = await jsonRequest(app, "DELETE", "/indexes/nope/documents/1"); expect(r.status).toBe(404); }); });