291 lines
9.6 KiB
TypeScript
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");
|
|
});
|
|
});
|