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