316 lines
9.8 KiB
TypeScript
316 lines
9.8 KiB
TypeScript
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<string, SearchIndex>;
|
|
emitted: EmittedEvent[];
|
|
app: ReturnType<typeof createSearchRoutes>;
|
|
serviceConfig: SearchServiceConfig;
|
|
} {
|
|
const indexes = new Map<string, SearchIndex>();
|
|
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<typeof createSearchRoutes>,
|
|
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<typeof createSearchRoutes>
|
|
): Promise<void> {
|
|
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);
|
|
});
|
|
});
|