Files
2026-05-27 23:29:53 +01:00

291 lines
9.6 KiB
TypeScript

import { createTestHost } from "@suaveplan/api-host/testing";
import type { Principal } from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
buildRoutes,
createInMemoryLedger,
EXAMPLE_CODES,
EXAMPLE_INVOICE_CREATED_TOPIC,
examplePluginModule,
USERS_USER_DELETED_TOPIC,
} from "../../src/index.js";
import { flushTestMetrics } from "../setup/polyfills.js";
const PRINCIPAL: Principal = {
id: "p-int",
scopes: ["example:read", "example:write"] as unknown as Principal["scopes"],
claims: {},
};
const TENANT_ID = "t-int" as TenantId;
let host: Awaited<ReturnType<typeof createTestHost>>;
let ledger: ReturnType<typeof createInMemoryLedger>;
beforeEach(async () => {
ledger = createInMemoryLedger();
host = await createTestHost({
seed: {
principal: PRINCIPAL,
tenants: [
{
id: TENANT_ID,
slug: "tenant-int",
status: "active",
createdAt: new Date("2026-01-01T00:00:00Z").toISOString(),
},
],
secrets: {
EXAMPLE_STRIPE_KEY: "sk_test_int",
EXAMPLE_HMAC_SIGNING_KEY: "hmac_test_int",
EXAMPLE_WEBHOOK_TOKEN: "webhook_test_int",
},
},
});
await host.installFixturePlugin({
spec: examplePluginModule,
routes: buildRoutes({
ledger,
publisher: {
publish(topic, payload) {
host.adapters.eventBus.publish(topic, payload);
},
},
}),
});
});
afterEach(async () => {
await host.dispose();
});
const URL_PREFIX = "http://test.local";
describe("api-plugin-example — full integration flow", () => {
it("plugin boots and serves /example/health", {
timeout: 30000,
}, async () => {
const res = await host.fetch(
new Request(`${URL_PREFIX}/example/health`)
);
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
});
it("REST surface — public bypasses auth", { timeout: 30000 }, async () => {
const res = await host.fetchAs(
null,
new Request(`${URL_PREFIX}/example/public/ping`)
);
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
});
it("REST surface — RBAC denies missing scope", {
timeout: 30000,
}, async () => {
const noScope: Principal = {
id: "z",
scopes: [] as unknown as Principal["scopes"],
claims: {},
};
const res = await host.fetchAs(
noScope,
new Request(`${URL_PREFIX}/example/invoices`)
);
expect(res.status).toBe(403);
const body = (await res.json()) as { code: string };
expect(body.code).toBe("RBAC_MISSING_SCOPE");
});
it("REST surface — Zod rejects malformed body", {
timeout: 30000,
}, async () => {
const res = await host.fetch(
new Request(`${URL_PREFIX}/example/invoices`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ customerId: "c-1" }),
})
);
expect(res.status).toBe(400);
});
it("REST surface — happy path persists + emits event + increments metric", {
timeout: 30000,
}, async () => {
const eventWait = host.waitForEvent(
EXAMPLE_INVOICE_CREATED_TOPIC,
5000
);
const res = await host.fetch(
new Request(`${URL_PREFIX}/example/invoices`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
customerId: "c-1",
amountCents: 1500,
currency: "USD",
}),
})
);
expect(res.status).toBe(200);
const created = (await res.json()) as { id: string; status: string };
expect(created.status).toBe("open");
const event = (await eventWait) as { id: string; amountCents: number };
expect(event.id).toBe(created.id);
expect(event.amountCents).toBe(1500);
});
it("REST surface — finalise transitions to paid + records OTel metrics", {
timeout: 30000,
}, async () => {
await host.fetch(
new Request(`${URL_PREFIX}/example/invoices`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
customerId: "c-1",
amountCents: 1,
currency: "USD",
}),
})
);
const finalise = await host.fetch(
new Request(`${URL_PREFIX}/example/invoices/inv-1/finalise`, {
method: "POST",
})
);
expect(finalise.status).toBe(200);
const out = (await finalise.json()) as { status: string };
expect(out.status).toBe("paid");
const metricsCollected = await flushTestMetrics();
const allMetrics = metricsCollected.flatMap((rm) =>
rm.scopeMetrics.flatMap((sm) => sm.metrics)
);
const names = new Set(allMetrics.map((m) => m.descriptor.name));
expect(names.has("example.invoices_finalised_total")).toBe(true);
expect(names.has("example.stripe.call_duration_ms")).toBe(true);
expect(names.has("example.outstanding_invoice_count")).toBe(true);
});
it("REST surface — streaming delivers chunked NDJSON", {
timeout: 30000,
}, async () => {
const res = await host.fetch(
new Request(`${URL_PREFIX}/example/stream/large-report`)
);
expect(res.status).toBe(200);
const text = await res.text();
const lines = text.trim().split("\n");
expect(lines.length).toBe(3);
});
it("custom error code surfaces with manifest-mapped status", {
timeout: 30000,
}, async () => {
const res = await host.fetch(
new Request(`${URL_PREFIX}/example/subscription-required`)
);
expect(res.status).toBe(EXAMPLE_CODES.SUBSCRIPTION_INACTIVE ? 409 : 0);
expect(res.status).toBe(409);
});
it("INVOICE_NOT_FOUND surfaces 404", { timeout: 30000 }, async () => {
const res = await host.fetch(
new Request(`${URL_PREFIX}/example/invoices/missing`)
);
expect(res.status).toBe(404);
});
it("INVALID_INVOICE_STATE surfaces 422 on double-finalise", {
timeout: 30000,
}, async () => {
await host.fetch(
new Request(`${URL_PREFIX}/example/invoices`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
customerId: "c",
amountCents: 1,
currency: "USD",
}),
})
);
await host.fetch(
new Request(`${URL_PREFIX}/example/invoices/inv-1/finalise`, {
method: "POST",
})
);
const res = await host.fetch(
new Request(`${URL_PREFIX}/example/invoices/inv-1/finalise`, {
method: "POST",
})
);
expect(res.status).toBe(422);
});
it("subscriber handler runs on incoming users.user.deleted", {
timeout: 30000,
}, async () => {
const sink = { seen: new Set<string>() };
host.adapters.eventBus.on(USERS_USER_DELETED_TOPIC, (raw) => {
const p = raw as { userId: string };
sink.seen.add(p.userId);
});
host.adapters.eventBus.publish(USERS_USER_DELETED_TOPIC, {
userId: "u-1",
tenantId: null,
});
await host.waitForEvent(USERS_USER_DELETED_TOPIC, 5000);
expect(sink.seen.has("u-1")).toBe(true);
});
it("every declared secret reads successfully", {
timeout: 30000,
}, async () => {
const secrets = host.adapters.host.secrets;
const a = await secrets.require("EXAMPLE_STRIPE_KEY");
const b = await secrets.require("EXAMPLE_HMAC_SIGNING_KEY");
const c = await secrets.require("EXAMPLE_WEBHOOK_TOKEN");
expect(a).toBe("sk_test_int");
expect(b).toBe("hmac_test_int");
expect(c).toBe("webhook_test_int");
});
it("response-validation route returns violating payload with ?failResponse=1", {
timeout: 30000,
}, async () => {
const res = await host.fetch(
new Request(`${URL_PREFIX}/_/test/fail-response?failResponse=1`)
);
expect(res.status).toBe(200);
const body = (await res.json()) as { status: string };
// Intentionally violates the schema — exercises the warn-mode
// path. Enforce mode would require host-side responseValidation
// wiring which is not yet present in the test-host.
expect(body.status).toBe("exploded");
});
it("response-validation route returns valid payload without the flag", {
timeout: 30000,
}, async () => {
const res = await host.fetch(
new Request(`${URL_PREFIX}/_/test/fail-response`)
);
expect(res.status).toBe(200);
const body = (await res.json()) as { status: string };
expect(body.status).toBe("open");
});
});