Initial Commit
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { OpenAPIHono } from "@hono/zod-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 bootPlugin() {
|
||||
const app = new OpenAPIHono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
const plugin = createEventBusPlugin({
|
||||
mountRoutes: false,
|
||||
requireAuthForRoutes: false,
|
||||
});
|
||||
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("tenant isolation — end-to-end against the real plugin lifecycle", () => {
|
||||
it("tenant A publish does not reach tenant B subscriber", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service } = await bootPlugin();
|
||||
const received: string[] = [];
|
||||
service.subscribe(
|
||||
"order.created",
|
||||
() => {
|
||||
received.push("B");
|
||||
},
|
||||
{ tenantId: "tenant-B" }
|
||||
);
|
||||
service.subscribe(
|
||||
"order.created",
|
||||
() => {
|
||||
received.push("A");
|
||||
},
|
||||
{ tenantId: "tenant-A" }
|
||||
);
|
||||
const r = await service.publish(
|
||||
"order.created",
|
||||
{ id: "o1" },
|
||||
{ tenantId: "tenant-A" }
|
||||
);
|
||||
expect(received).toEqual(["A"]);
|
||||
expect(r.delivered).toBe(1);
|
||||
expect(r.dropped).toBe(false);
|
||||
});
|
||||
|
||||
it("tenant B cannot unsubscribe tenant A's subscription", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service } = await bootPlugin();
|
||||
const handleA = service.subscribe("order.created", () => undefined, {
|
||||
tenantId: "tenant-A",
|
||||
});
|
||||
// tenant B attempt — throws TENANT_MISMATCH.
|
||||
expect(() => service.unsubscribe(handleA.id, "tenant-B")).toThrow();
|
||||
// tenant A's subscription still active.
|
||||
const received: number[] = [];
|
||||
service.subscribe(
|
||||
"order.created",
|
||||
() => {
|
||||
received.push(1);
|
||||
},
|
||||
{ tenantId: "tenant-A" }
|
||||
);
|
||||
await service.publish("order.created", {}, { tenantId: "tenant-A" });
|
||||
expect(received.length).toBe(1);
|
||||
});
|
||||
|
||||
it("listSubscriptions filters by calling tenant", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service } = await bootPlugin();
|
||||
service.subscribe("a", () => undefined, { tenantId: "tenant-A" });
|
||||
service.subscribe("a", () => undefined, { tenantId: "tenant-A" });
|
||||
service.subscribe("a", () => undefined, { tenantId: "tenant-B" });
|
||||
const aList = service.listSubscriptions({ tenantId: "tenant-A" });
|
||||
expect(aList.length).toBe(2);
|
||||
expect(aList.every((s) => s.tenantId === "tenant-A")).toBe(true);
|
||||
});
|
||||
|
||||
it("idempotent subscription fires handler once for duplicate publish", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service } = await bootPlugin();
|
||||
const seen: number[] = [];
|
||||
service.subscribe(
|
||||
"order.completed",
|
||||
() => {
|
||||
seen.push(1);
|
||||
},
|
||||
{
|
||||
tenantId: "tenant-A",
|
||||
idempotencyKey: (p) => String((p as { id: string }).id),
|
||||
}
|
||||
);
|
||||
await service.publish(
|
||||
"order.completed",
|
||||
{ id: "o1" },
|
||||
{ tenantId: "tenant-A" }
|
||||
);
|
||||
await service.publish(
|
||||
"order.completed",
|
||||
{ id: "o1" },
|
||||
{ tenantId: "tenant-A" }
|
||||
);
|
||||
await service.publish(
|
||||
"order.completed",
|
||||
{ id: "o2" },
|
||||
{ tenantId: "tenant-A" }
|
||||
);
|
||||
expect(seen.length).toBe(2);
|
||||
});
|
||||
|
||||
it("c.set('eventBus') handle round-trips publishes between handlers", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
// Register the tenant middleware BEFORE the plugin's
|
||||
// requestBus middleware so the latter observes the tenant
|
||||
// when it runs (Hono dispatches middleware in registration
|
||||
// order, so a tenant setter registered after the plugin
|
||||
// middleware would be invisible to it).
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("tenant" as never, { id: "tenant-A" } as never);
|
||||
await next();
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
const plugin = createEventBusPlugin({
|
||||
mountRoutes: false,
|
||||
requireAuthForRoutes: false,
|
||||
});
|
||||
const ctx = createPluginContext<EventBusPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as EventBusPluginConfig,
|
||||
services,
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
const service = services.get("eventBusService") as EventBusService;
|
||||
const received: unknown[] = [];
|
||||
service.subscribe(
|
||||
"ping",
|
||||
(p) => {
|
||||
received.push(p);
|
||||
},
|
||||
{ tenantId: "tenant-A" }
|
||||
);
|
||||
app.get("/publish", async (c) => {
|
||||
const handle = c.get("eventBus");
|
||||
await handle.publish("ping", { hello: 1 });
|
||||
return c.text("ok");
|
||||
});
|
||||
const res = await app.request("/publish");
|
||||
expect(res.status).toBe(200);
|
||||
expect(received).toEqual([{ hello: 1 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-event-bus.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meter resolved on first call binds to it.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
Reference in New Issue
Block a user