Four real surfaces wired to tenant-registry (settings, settings/api-keys CRUD, audit pagination, products live entitlements), five forward-looking empty states with CTAs. 56 vitest tests + 10 Playwright canaries. lib/format.ts consolidates date helpers. Refs: M10.1
321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
import {
|
|
createAPIKey,
|
|
createTenant,
|
|
fetchAPIKeys,
|
|
fetchAudit,
|
|
fetchCatalog,
|
|
fetchEntitlements,
|
|
fetchTenantBySlug,
|
|
requestProduct,
|
|
revokeAPIKey,
|
|
startTrial,
|
|
type Tenant,
|
|
} from "./tenant-registry";
|
|
|
|
const SAMPLE: Tenant = {
|
|
id: "00000000-0000-0000-0000-000000000001",
|
|
slug: "acme",
|
|
name: "Acme Inc.",
|
|
status: "active",
|
|
kind: "customer",
|
|
plan: "professional",
|
|
created_at: "2026-05-18T22:00:00Z",
|
|
updated_at: "2026-05-18T22:00:00Z",
|
|
};
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
const originalRegistryUrl = process.env.TENANT_REGISTRY_URL;
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
process.env.TENANT_REGISTRY_URL = originalRegistryUrl;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
process.env.TENANT_REGISTRY_URL = "http://test:1234";
|
|
});
|
|
|
|
function mockJSON(status: number, body: unknown) {
|
|
return vi.fn<typeof fetch>(async () =>
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
}
|
|
|
|
describe("fetchTenantBySlug", () => {
|
|
test("200 → parsed tenant", async () => {
|
|
globalThis.fetch = mockJSON(200, SAMPLE);
|
|
expect(await fetchTenantBySlug("acme")).toEqual(SAMPLE);
|
|
});
|
|
test("404 → null", async () => {
|
|
globalThis.fetch = mockJSON(404, {});
|
|
expect(await fetchTenantBySlug("nope")).toBeNull();
|
|
});
|
|
test("500 → throws", async () => {
|
|
globalThis.fetch = mockJSON(500, {});
|
|
await expect(fetchTenantBySlug("acme")).rejects.toThrow(/500/);
|
|
});
|
|
test("default base URL", async () => {
|
|
delete process.env.TENANT_REGISTRY_URL;
|
|
const spy = mockJSON(200, SAMPLE);
|
|
globalThis.fetch = spy;
|
|
await fetchTenantBySlug("acme");
|
|
expect(spy.mock.calls[0]![0]).toBe("http://localhost:8090/v1/tenants/by-slug/acme");
|
|
});
|
|
test("encodes slug", async () => {
|
|
const spy = mockJSON(404, {});
|
|
globalThis.fetch = spy;
|
|
await fetchTenantBySlug("a/b c");
|
|
expect(spy.mock.calls[0]![0]).toBe("http://test:1234/v1/tenants/by-slug/a%2Fb%20c");
|
|
});
|
|
});
|
|
|
|
describe("fetchCatalog", () => {
|
|
test("returns items[]", async () => {
|
|
globalThis.fetch = mockJSON(200, {
|
|
items: [
|
|
{ key: "certifai", name: "CERTifAI", description: "x", plans_required: [], supports_trial: true },
|
|
],
|
|
});
|
|
const list = await fetchCatalog();
|
|
expect(list).toHaveLength(1);
|
|
expect(list[0].key).toBe("certifai");
|
|
});
|
|
test("non-200 throws", async () => {
|
|
globalThis.fetch = mockJSON(500, {});
|
|
await expect(fetchCatalog()).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("fetchEntitlements", () => {
|
|
test("happy path", async () => {
|
|
globalThis.fetch = mockJSON(200, {
|
|
items: [{ tenant_id: "t1", product: "certifai", enabled: true, config: {} }],
|
|
});
|
|
expect(await fetchEntitlements("t1")).toHaveLength(1);
|
|
});
|
|
test("404 → []", async () => {
|
|
globalThis.fetch = mockJSON(404, {});
|
|
expect(await fetchEntitlements("t1")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("requestProduct", () => {
|
|
test("202 → ok", async () => {
|
|
globalThis.fetch = mockJSON(202, { status: "accepted" });
|
|
expect(await requestProduct("t1", "certifai")).toEqual({ ok: true });
|
|
});
|
|
test("404 maps to tenant_not_found", async () => {
|
|
globalThis.fetch = mockJSON(404, {});
|
|
expect(await requestProduct("t1", "certifai")).toEqual({
|
|
ok: false,
|
|
error: "tenant_not_found",
|
|
});
|
|
});
|
|
test("400 maps to invalid_input", async () => {
|
|
globalThis.fetch = mockJSON(400, {});
|
|
expect(await requestProduct("t1", "x")).toEqual({ ok: false, error: "invalid_input" });
|
|
});
|
|
test("unexpected status surfaces with code", async () => {
|
|
globalThis.fetch = mockJSON(503, {});
|
|
expect(await requestProduct("t1", "x")).toEqual({ ok: false, error: "unexpected_503" });
|
|
});
|
|
});
|
|
|
|
describe("startTrial", () => {
|
|
test("201 → entitlement", async () => {
|
|
globalThis.fetch = mockJSON(201, {
|
|
tenant_id: "t1", product: "certifai", enabled: true, config: { source: "trial" },
|
|
});
|
|
const res = await startTrial("t1", "certifai");
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) expect(res.entitlement.product).toBe("certifai");
|
|
});
|
|
test("400 maps to invalid_input", async () => {
|
|
globalThis.fetch = mockJSON(400, {});
|
|
expect(await startTrial("t1", "x")).toEqual({ ok: false, error: "invalid_input" });
|
|
});
|
|
});
|
|
|
|
describe("createTenant", () => {
|
|
test("201 returns tenant", async () => {
|
|
globalThis.fetch = mockJSON(201, {
|
|
tenant: SAMPLE,
|
|
invite_url: "http://mock/invite",
|
|
});
|
|
const res = await createTenant({ slug: "x", name: "X", admin_email: "a@b.test" });
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) {
|
|
expect(res.tenant.slug).toBe("acme");
|
|
expect(res.invite_url).toBe("http://mock/invite");
|
|
}
|
|
});
|
|
test("409 maps to slug_taken", async () => {
|
|
globalThis.fetch = mockJSON(409, {});
|
|
expect(await createTenant({ slug: "x", name: "X" })).toEqual({
|
|
ok: false,
|
|
error: "slug_taken",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("coverage gaps", () => {
|
|
test("startTrial 404 maps to tenant_not_found", async () => {
|
|
globalThis.fetch = mockJSON(404, {});
|
|
expect(await startTrial("t1", "x")).toEqual({
|
|
ok: false,
|
|
error: "tenant_not_found",
|
|
});
|
|
});
|
|
test("startTrial unexpected status surfaces with code", async () => {
|
|
globalThis.fetch = mockJSON(503, {});
|
|
expect(await startTrial("t1", "x")).toEqual({ ok: false, error: "unexpected_503" });
|
|
});
|
|
test("createTenant 400 maps to invalid_input", async () => {
|
|
globalThis.fetch = mockJSON(400, {});
|
|
expect(await createTenant({ slug: "x", name: "X" })).toEqual({
|
|
ok: false,
|
|
error: "invalid_input",
|
|
});
|
|
});
|
|
test("createTenant unexpected status surfaces with code", async () => {
|
|
globalThis.fetch = mockJSON(500, {});
|
|
expect(await createTenant({ slug: "x", name: "X" })).toEqual({
|
|
ok: false,
|
|
error: "unexpected_500",
|
|
});
|
|
});
|
|
test("req() handles 204 with null data", async () => {
|
|
// Use a verb that returns 204 — none of our endpoints do, but make sure
|
|
// the helper handles it. Simulate via fetchEntitlements with 204.
|
|
globalThis.fetch = vi.fn<typeof fetch>(async () => new Response(null, { status: 204 }));
|
|
await expect(fetchEntitlements("t1")).rejects.toThrow();
|
|
});
|
|
test("fetchCatalog with no data throws", async () => {
|
|
globalThis.fetch = vi.fn<typeof fetch>(async () =>
|
|
new Response("not-json", { status: 200, headers: { "content-type": "text/plain" } }),
|
|
);
|
|
await expect(fetchCatalog()).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("fetchAPIKeys", () => {
|
|
test("happy path", async () => {
|
|
globalThis.fetch = mockJSON(200, {
|
|
items: [
|
|
{ id: "1", tenant_id: "t1", name: "k1", scopes: [], prefix: "bp_a", created_at: "x" },
|
|
],
|
|
});
|
|
const list = await fetchAPIKeys("t1");
|
|
expect(list).toHaveLength(1);
|
|
});
|
|
test("404 → []", async () => {
|
|
globalThis.fetch = mockJSON(404, {});
|
|
expect(await fetchAPIKeys("t1")).toEqual([]);
|
|
});
|
|
test("non-200 throws", async () => {
|
|
globalThis.fetch = mockJSON(500, {});
|
|
await expect(fetchAPIKeys("t1")).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("createAPIKey", () => {
|
|
test("201 returns plaintext", async () => {
|
|
globalThis.fetch = mockJSON(201, {
|
|
api_key: { id: "1", tenant_id: "t1", name: "k", scopes: [], prefix: "bp_a", created_at: "x" },
|
|
plaintext: "bp_abc123",
|
|
});
|
|
const res = await createAPIKey({ tenant_id: "t1", name: "k" });
|
|
expect(res.ok).toBe(true);
|
|
if (res.ok) expect(res.plaintext).toBe("bp_abc123");
|
|
});
|
|
test("404 → tenant_not_found", async () => {
|
|
globalThis.fetch = mockJSON(404, {});
|
|
expect(await createAPIKey({ tenant_id: "t1", name: "k" })).toEqual({
|
|
ok: false,
|
|
error: "tenant_not_found",
|
|
});
|
|
});
|
|
test("400 → invalid_input", async () => {
|
|
globalThis.fetch = mockJSON(400, {});
|
|
expect(await createAPIKey({ tenant_id: "t1", name: "" })).toEqual({
|
|
ok: false,
|
|
error: "invalid_input",
|
|
});
|
|
});
|
|
test("409 → name_taken", async () => {
|
|
globalThis.fetch = mockJSON(409, {});
|
|
expect(await createAPIKey({ tenant_id: "t1", name: "k" })).toEqual({
|
|
ok: false,
|
|
error: "name_taken",
|
|
});
|
|
});
|
|
test("unexpected status", async () => {
|
|
globalThis.fetch = mockJSON(500, {});
|
|
expect(await createAPIKey({ tenant_id: "t1", name: "k" })).toEqual({
|
|
ok: false,
|
|
error: "unexpected_500",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("revokeAPIKey", () => {
|
|
test("204 → ok", async () => {
|
|
globalThis.fetch = vi.fn<typeof fetch>(async () => new Response(null, { status: 204 }));
|
|
expect(await revokeAPIKey("k1")).toEqual({ ok: true });
|
|
});
|
|
test("404 → not_found", async () => {
|
|
globalThis.fetch = mockJSON(404, {});
|
|
expect(await revokeAPIKey("k1")).toEqual({ ok: false, error: "not_found" });
|
|
});
|
|
test("unexpected status", async () => {
|
|
globalThis.fetch = mockJSON(500, {});
|
|
expect(await revokeAPIKey("k1")).toEqual({ ok: false, error: "unexpected_500" });
|
|
});
|
|
});
|
|
|
|
describe("fetchAudit", () => {
|
|
test("happy path with filters", async () => {
|
|
const spy = mockJSON(200, {
|
|
items: [{ id: 1, action: "tenant.created", created_at: "x" }],
|
|
next_cursor: 1,
|
|
});
|
|
globalThis.fetch = spy;
|
|
const res = await fetchAudit({
|
|
tenant_id: "t1",
|
|
product: "certifai",
|
|
actor_id: "u1",
|
|
action: "tenant.created",
|
|
since: "2026-05-01T00:00:00Z",
|
|
until: "2026-05-20T00:00:00Z",
|
|
limit: 50,
|
|
cursor: 10,
|
|
});
|
|
expect(res.items).toHaveLength(1);
|
|
expect(res.next_cursor).toBe(1);
|
|
const url = String(spy.mock.calls[0]![0]);
|
|
expect(url).toContain("tenant_id=t1");
|
|
expect(url).toContain("product=certifai");
|
|
expect(url).toContain("actor_id=u1");
|
|
expect(url).toContain("action=tenant.created");
|
|
expect(url).toContain("limit=50");
|
|
expect(url).toContain("cursor=10");
|
|
});
|
|
test("no filters", async () => {
|
|
const spy = mockJSON(200, { items: [] });
|
|
globalThis.fetch = spy;
|
|
const res = await fetchAudit({});
|
|
expect(res.items).toEqual([]);
|
|
const url = String(spy.mock.calls[0]![0]);
|
|
expect(url).toBe("http://test:1234/v1/audit?");
|
|
});
|
|
test("non-200 throws", async () => {
|
|
globalThis.fetch = mockJSON(500, {});
|
|
await expect(fetchAudit({ tenant_id: "t1" })).rejects.toThrow();
|
|
});
|
|
});
|