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