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(); 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({ 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"); }); });