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 { EVENT_BUS_CODES, EventBusError } from "../error-codes/error-codes.js"; import { resetEventBusInstruments } from "../otel/instruments.js"; import type { EventBusService } from "../services/event-bus-service.types.js"; import type { EventBusPluginConfig } from "../types/types.js"; import { createEventBusPlugin } from "./plugin.js"; const noopLogger = { debug: () => undefined, info: () => undefined, warn: () => undefined, error: () => undefined, }; function makeContext( services: Map, defaults: EventBusPluginConfig ) { return createPluginContext({ logger: noopLogger, eventBus: new EventBus(), signal: new AbortController().signal, config: defaults, services, }); } describe("createEventBusPlugin", () => { it("createEventBusPlugin resolves defaults and returns a typed PluginDescriptor", { timeout: 5000, }, () => { const plugin = createEventBusPlugin({}); expect(plugin.name).toBe("event-bus"); expect(plugin.version).toBe("0.1.0"); const defaults = plugin.defaults as EventBusPluginConfig; expect(defaults.routePrefix).toBe("/event-bus"); expect(defaults.mountRoutes).toBe(true); expect(defaults.requireAuthForRoutes).toBe(true); expect(defaults.tenantIsolation).toBe(true); expect(defaults.backpressure.policy).toBe("drop-oldest"); expect(defaults.backpressure.capacity).toBe(1024); expect(defaults.idempotency.enabled).toBe(false); expect(defaults.maxTopicLength).toBe(256); }); it("createEventBusPlugin throws EVENT_BUS.INVALID_INPUT for invalid options", { timeout: 5000, }, () => { try { createEventBusPlugin({ routePrefix: "no-leading-slash", } as never); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(EventBusError); expect((err as EventBusError).eventBusCode).toBe( EVENT_BUS_CODES.INVALID_INPUT ); } }); }); describe("createEventBusPlugin.onInit", () => { it("onInit throws SERVICE_NOT_AVAILABLE when the app slot is missing", { timeout: 30_000, }, async () => { resetEventBusInstruments(); const plugin = createEventBusPlugin({ mountRoutes: false }); const ctx = makeContext( new Map(), plugin.defaults as EventBusPluginConfig ); try { await plugin.onInit?.(ctx); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(EventBusError); expect((err as EventBusError).eventBusCode).toBe( EVENT_BUS_CODES.SERVICE_NOT_AVAILABLE ); } }); it("onInit registers eventBusService in the ServiceRegistry", { timeout: 30_000, }, async () => { resetEventBusInstruments(); const services = new Map(); services.set("app", new OpenAPIHono()); const plugin = createEventBusPlugin({ mountRoutes: false }); const ctx = makeContext( services, plugin.defaults as EventBusPluginConfig ); await plugin.onInit?.(ctx); const svc = services.get("eventBusService") as EventBusService; expect(svc).toBeDefined(); expect(typeof svc.publish).toBe("function"); expect(typeof svc.subscribe).toBe("function"); }); it("onInit installs request-bus middleware that exposes ScopedEventBusHandle", { timeout: 30_000, }, async () => { resetEventBusInstruments(); const app = new OpenAPIHono(); const services = new Map(); services.set("app", app); const plugin = createEventBusPlugin({ mountRoutes: false, requireAuthForRoutes: false, }); await plugin.onInit?.( makeContext(services, plugin.defaults as EventBusPluginConfig) ); let observedTenant: string | null | undefined; app.get("/probe", (c) => { const handle = c.get("eventBus"); observedTenant = handle?.tenantId ?? null; return c.text("ok"); }); const res = await app.request("/probe"); expect(res.status).toBe(200); expect(observedTenant).toBeNull(); }); it("onInit mounts routes onto OpenAPIRegistry when present", { timeout: 30_000, }, async () => { resetEventBusInstruments(); const app = new OpenAPIHono(); const registry = createOpenAPIRegistry({ info: { title: "test", version: "0.1.0" }, }); const services = new Map(); services.set("app", app); services.set("openAPIRegistry", registry); const plugin = createEventBusPlugin({ requireAuthForRoutes: false, }); await plugin.onInit?.( makeContext(services, plugin.defaults as EventBusPluginConfig) ); expect(registry.getRoutesByPlugin("event-bus")).toHaveLength(3); }); it("mountRoutes=false skips route registration", { timeout: 30_000, }, async () => { resetEventBusInstruments(); const app = new OpenAPIHono(); const registry = createOpenAPIRegistry({ info: { title: "test", version: "0.1.0" }, }); const services = new Map(); services.set("app", app); services.set("openAPIRegistry", registry); const plugin = createEventBusPlugin({ mountRoutes: false, requireAuthForRoutes: false, }); await plugin.onInit?.( makeContext(services, plugin.defaults as EventBusPluginConfig) ); expect(registry.getRoutesByPlugin("event-bus")).toHaveLength(0); }); it("onInit throws ROUTE_PREFIX_COLLISION when routes already occupy the prefix", { timeout: 30_000, }, async () => { resetEventBusInstruments(); const app = new OpenAPIHono(); const registry = createOpenAPIRegistry({ info: { title: "test", version: "0.1.0" }, }); registry.registerRoute({ method: "get", path: "/event-bus/x", tags: [], pluginName: "other", }); const services = new Map(); services.set("app", app); services.set("openAPIRegistry", registry); const plugin = createEventBusPlugin({ requireAuthForRoutes: false, }); try { await plugin.onInit?.( makeContext(services, plugin.defaults as EventBusPluginConfig) ); throw new Error("expected throw"); } catch (err) { expect(err).toBeInstanceOf(EventBusError); expect((err as EventBusError).eventBusCode).toBe( EVENT_BUS_CODES.ROUTE_PREFIX_COLLISION ); } }); it("mounts onto Hono only when openAPIRegistry is absent", { timeout: 30_000, }, async () => { resetEventBusInstruments(); const app = new OpenAPIHono(); const services = new Map(); services.set("app", app); const plugin = createEventBusPlugin({ requireAuthForRoutes: false, }); await plugin.onInit?.( makeContext(services, plugin.defaults as EventBusPluginConfig) ); // The publish route should now answer on /event-bus/publish. const res = await app.request("/event-bus/publish", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ topic: "order.created", payload: {} }), }); // Should be 2xx (handler ran). expect(res.status).toBeGreaterThanOrEqual(200); expect(res.status).toBeLessThan(400); }); it("onClose clears the cached service", { timeout: 30_000 }, async () => { resetEventBusInstruments(); const services = new Map(); services.set("app", new OpenAPIHono()); const plugin = createEventBusPlugin({ mountRoutes: false }); await plugin.onInit?.( makeContext(services, plugin.defaults as EventBusPluginConfig) ); await plugin.onClose?.( makeContext(services, plugin.defaults as EventBusPluginConfig) ); // The state-internal cleared field is private; we observe via // the lack of throw and the descriptor's metadata. expect(plugin.name).toBe("event-bus"); }); it("mounts under custom routePrefix '/'", { timeout: 30_000 }, async () => { resetEventBusInstruments(); const app = new OpenAPIHono(); const services = new Map(); services.set("app", app); const plugin = createEventBusPlugin({ mountRoutes: true, requireAuthForRoutes: false, routePrefix: "/", }); await plugin.onInit?.( makeContext(services, plugin.defaults as EventBusPluginConfig) ); // Publish reachable at top-level /publish. const res = await app.request("/publish", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ topic: "x", payload: {} }), }); expect(res.status).toBeGreaterThanOrEqual(200); expect(res.status).toBeLessThan(400); }); it("requireAuthForRoutes=true installs requireAuth() under the prefix", { timeout: 30_000, }, async () => { resetEventBusInstruments(); const app = new OpenAPIHono(); const registry = createOpenAPIRegistry({ info: { title: "test", version: "0.1.0" }, }); const services = new Map(); services.set("app", app); services.set("openAPIRegistry", registry); const plugin = createEventBusPlugin({ mountRoutes: true, requireAuthForRoutes: true, }); await plugin.onInit?.( makeContext(services, plugin.defaults as EventBusPluginConfig) ); // Anonymous request — requireAuth() raises AuthError because // `c.get("user")` is null. The default Hono error handler maps // that to 500 (the api error-handler middleware would map to // 401, but the bare app does not have it wired). Either way the // request did not succeed — the gate fired. const res = await app.request("/event-bus/subscriptions"); expect(res.status).not.toBe(200); }); it("requireAuthForRoutes=true with root prefix mounts /* gate", { timeout: 30_000, }, async () => { resetEventBusInstruments(); const app = new OpenAPIHono(); const services = new Map(); services.set("app", app); const plugin = createEventBusPlugin({ mountRoutes: true, requireAuthForRoutes: true, routePrefix: "/", }); await plugin.onInit?.( makeContext(services, plugin.defaults as EventBusPluginConfig) ); const res = await app.request("/subscriptions"); expect(res.status).not.toBe(200); }); });