314 lines
12 KiB
TypeScript
314 lines
12 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 { 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<string, unknown>,
|
|
defaults: EventBusPluginConfig
|
|
) {
|
|
return createPluginContext<EventBusPluginConfig>({
|
|
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<string, unknown>(),
|
|
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<string, unknown>();
|
|
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<string, unknown>();
|
|
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<string, unknown>();
|
|
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<string, unknown>();
|
|
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<string, unknown>();
|
|
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<string, unknown>();
|
|
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<string, unknown>();
|
|
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<string, unknown>();
|
|
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<string, unknown>();
|
|
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<string, unknown>();
|
|
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);
|
|
});
|
|
});
|