Files
suaveplan/packages/api-stack/api/api-plugin-event-bus/tests/integration/http-routes.test.ts
2026-05-27 23:29:53 +01:00

203 lines
7.1 KiB
TypeScript

import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createEventBusPlugin } from "../../src/plugin/plugin.js";
import type { EventBusService } from "../../src/services/event-bus-service.types.js";
import type { EventBusPluginConfig } from "../../src/types/types.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
async function bootHttp(): Promise<{
app: OpenAPIHono;
service: EventBusService;
}> {
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "ev-test", version: "0.1.0" },
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
// For HTTP tests we keep `requireAuthForRoutes: false` so the
// tests don't require @suaveplan/api-plugin-auth at boot.
const plugin = createEventBusPlugin({
requireAuthForRoutes: false,
mountRoutes: true,
});
// Tenant slot — populate before the request-bus middleware reads it.
app.use("*", async (c, next) => {
c.set("tenant" as never, { id: "acme" } as never);
await next();
});
const ctx = createPluginContext<EventBusPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as EventBusPluginConfig,
services,
});
await plugin.onInit?.(ctx);
return {
app,
service: services.get("eventBusService") as EventBusService,
};
}
describe("HTTP routes end-to-end against the real plugin lifecycle", () => {
it("POST /event-bus/publish returns delivered + dropped", {
timeout: 30_000,
}, async () => {
const { app, service } = await bootHttp();
let received: unknown = null;
service.subscribe(
"order.created",
(p) => {
received = p;
},
{ tenantId: "acme" }
);
const res = await app.request("/event-bus/publish", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
topic: "order.created",
payload: { id: "o1" },
}),
});
expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(400);
const body = (await res.json()) as {
success: boolean;
data: { delivered: number; dropped: boolean };
};
expect(body.success).toBe(true);
expect(body.data.delivered).toBe(1);
expect(body.data.dropped).toBe(false);
expect(received).toEqual({ id: "o1" });
});
it("POST /event-bus/publish rejects invalid topic", {
timeout: 30_000,
}, async () => {
const { app } = await bootHttp();
const res = await app.request("/event-bus/publish", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ topic: "Bad Topic", payload: {} }),
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
it("GET /event-bus/subscriptions lists tenant-owned subscriptions", {
timeout: 30_000,
}, async () => {
const { app, service } = await bootHttp();
service.subscribe("a", () => undefined, { tenantId: "acme" });
service.subscribe("b", () => undefined, { tenantId: "acme" });
service.subscribe("c", () => undefined, { tenantId: "other" });
const res = await app.request("/event-bus/subscriptions");
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { subscriptions: Array<{ topic: string }> };
};
const topics = body.data.subscriptions.map((s) => s.topic).sort();
expect(topics).toEqual(["a", "b"]);
});
it("DELETE /event-bus/subscriptions/:id removes own subscription", {
timeout: 30_000,
}, async () => {
const { app, service } = await bootHttp();
const handle = service.subscribe("x", () => undefined, {
tenantId: "acme",
});
const res = await app.request(`/event-bus/subscriptions/${handle.id}`, {
method: "DELETE",
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { unsubscribed: boolean };
};
expect(body.data.unsubscribed).toBe(true);
});
it("DELETE returns 404 for unknown id", { timeout: 30_000 }, async () => {
const { app } = await bootHttp();
const res = await app.request("/event-bus/subscriptions/missing", {
method: "DELETE",
});
expect(res.status).toBe(404);
});
it("DELETE returns 404 for foreign-tenant id (no id leak)", {
timeout: 30_000,
}, async () => {
const { app, service } = await bootHttp();
// Create a subscription owned by 'other'.
const handle = service.subscribe("x", () => undefined, {
tenantId: "other",
});
const res = await app.request(`/event-bus/subscriptions/${handle.id}`, {
method: "DELETE",
});
expect(res.status).toBe(404);
});
it("POST /publish accepts idempotencyKey + timeoutMs + traceId in the body", {
timeout: 30_000,
}, async () => {
const { app, service } = await bootHttp();
let received: unknown = null;
service.subscribe(
"evt",
(p) => {
received = p;
},
{ tenantId: "acme" }
);
const res = await app.request("/event-bus/publish", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
topic: "evt",
payload: { id: "x" },
idempotencyKey: "k1",
timeoutMs: 2000,
traceId: "trace-1",
}),
});
expect(res.status).toBeLessThan(400);
expect(received).toEqual({ id: "x" });
});
it("POST /publish surfaces listener errors in the response", {
timeout: 30_000,
}, async () => {
const { app, service } = await bootHttp();
service.subscribe(
"broken",
() => {
throw new Error("listener-blew-up");
},
{ tenantId: "acme" }
);
const res = await app.request("/event-bus/publish", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ topic: "broken", payload: {} }),
});
const body = (await res.json()) as {
data: { errors: Array<{ message: string }> };
};
expect(body.data.errors.length).toBe(1);
expect(body.data.errors[0]?.message).toContain("listener-blew-up");
});
});