Initial Commit
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user