import { assertStringIncludes } from "@std/assert"; import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { AdminApiError, type AdminApiResponse, AdminApiSchemaError, type AdminApiTransport, adminApiChildCall, adminApiChildCreate, adminApiCustomRoute, adminApiDelete, adminApiGet, adminApiList, adminApiWrite, } from "#shared/admin-api-client.ts"; import { ADMIN_API_RESOURCES } from "#shared/admin-api-resources.ts"; const ATTRIBUTE = { id: 3, name: "Game Length", options: [{ attribute_id: 3, id: 8, sort_order: 0, text: "15-20 minutes" }], sort_order: 0, }; /** A transport that answers every call with one canned body and records the * requests, so the tests can pin the paths and methods the client builds. */ const stubTransport = ( body: unknown, status = 200, ): { calls: { method: string; path: string }[]; transport: AdminApiTransport; } => { const calls: { method: string; path: string }[] = []; const transport: AdminApiTransport = (options) => { calls.push({ method: options.method, path: options.path }); return Promise.resolve({ data: body, status } as AdminApiResponse); }; return { calls, transport }; }; describe("admin api client", () => { test("list refuses a resource that serves no list route", async () => { const { transport } = stubTransport({}); await expect( adminApiList(transport, ADMIN_API_RESOURCES.attendees), ).rejects.toThrow("admin api attendees serves no list route"); }); test("list builds the table's path and validates the envelope", async () => { const { calls, transport } = stubTransport({ attributes: [ATTRIBUTE], }); const listed = await adminApiList( transport, ADMIN_API_RESOURCES.attributes, ); expect(calls).toEqual([{ method: "GET", path: "/api/admin/attributes" }]); expect(listed.attributes[0]!.name).toBe("Game Length"); }); test("create sends the body and validates the single envelope", async () => { const { calls, transport } = stubTransport({ attribute: ATTRIBUTE }); const created = await adminApiWrite( transport, ADMIN_API_RESOURCES.attributes, "create", undefined, { name: "Game Length" }, ); expect(calls).toEqual([{ method: "POST", path: "/api/admin/attributes" }]); expect(created.attribute.id).toBe(3); }); test("update builds the id path", async () => { const updated = stubTransport({ attribute: ATTRIBUTE }); await adminApiWrite( updated.transport, ADMIN_API_RESOURCES.attributes, "update", 3, { name: "Renamed" }, ); expect(updated.calls).toEqual([ { method: "PUT", path: "/api/admin/attributes/3" }, ]); }); test("child verbs build the parent and child id paths", async () => { const created = stubTransport({ attribute: ATTRIBUTE }); await adminApiChildCreate( created.transport, ADMIN_API_RESOURCES.attributes, "options", 3, { text: "20-30 minutes" }, ); expect(created.calls).toEqual([ { method: "POST", path: "/api/admin/attributes/3/options" }, ]); }); test("get reads one row through the single envelope", async () => { const { calls, transport } = stubTransport({ attribute: ATTRIBUTE }); const got = await adminApiGet(transport, ADMIN_API_RESOURCES.attributes, 3); expect(calls).toEqual([{ method: "GET", path: "/api/admin/attributes/3" }]); expect(got.attribute.id).toBe(3); }); test("delete sends the confirmation body and validates the status answer", async () => { const { calls, transport } = stubTransport({ status: "ok" }); const deleted = await adminApiDelete( transport, ADMIN_API_RESOURCES.attributes, 3, { confirm_identifier: "Game Length" }, ); expect(calls).toEqual([ { method: "DELETE", path: "/api/admin/attributes/3" }, ]); expect(deleted.status).toBe("ok"); }); test("the child delete answers through the shared status envelope", async () => { const deleted = stubTransport({ status: "ok" }); const answer = await adminApiChildCall( deleted.transport, ADMIN_API_RESOURCES.attributes, "delete", "options", 3, 8, { confirm_identifier: "15-20 minutes" }, ); expect(deleted.calls).toEqual([ { method: "DELETE", path: "/api/admin/attributes/3/options/8" }, ]); expect(answer).toEqual({ status: "ok" }); }); test("a custom route sends the table's method and substituted id", async () => { const toggled = stubTransport({ status: "inactive" }); const answer = await adminApiCustomRoute( toggled.transport, ADMIN_API_RESOURCES.listings, "deactivate", 5, ); expect(toggled.calls).toEqual([ { method: "POST", path: "/api/admin/listings/5/deactivate" }, ]); expect(answer).toEqual({ status: "inactive" }); }); test("an unknown custom route throws instead of building a path", async () => { const failure = await adminApiCustomRoute( stubTransport({}).transport, ADMIN_API_RESOURCES.groups, "deactivate", 5, ).catch((error: unknown) => error); expect((failure as Error).message).toBe( "admin api groups has no deactivate custom route", ); }); test("a malformed response throws with the resource and field named", async () => { const { transport } = stubTransport({ attributes: [{ id: "not-a-number" }, { id: "also-bad" }], }); const failure = await adminApiList( transport, ADMIN_API_RESOURCES.attributes, ).catch((error: unknown) => error); expect(failure).toBeInstanceOf(Error); assertStringIncludes( (failure as Error).message, "admin api attributes response failed validation", ); // Two rejected fields read as one sentence joined by a semicolon. assertStringIncludes((failure as Error).message, "; attributes.1.id"); }); test("an informational status is refused like any non-2xx answer", async () => { const { transport } = stubTransport({ attributes: [ATTRIBUTE] }, 150); const failure = await adminApiList( transport, ADMIN_API_RESOURCES.attributes, ).catch((error: unknown) => error); expect(failure).toBeInstanceOf(AdminApiError); expect((failure as AdminApiError).status).toBe(150); }); test("a schema-invalid success keeps the served status", async () => { const { transport } = stubTransport({}, 200); const failure = await adminApiList( transport, ADMIN_API_RESOURCES.attributes, ).catch((error: unknown) => error); expect(failure).toBeInstanceOf(AdminApiSchemaError); expect((failure as AdminApiSchemaError).name).toBe("AdminApiSchemaError"); expect((failure as AdminApiSchemaError).status).toBe(200); assertStringIncludes( (failure as Error).message, "admin api attributes response failed validation", ); }); test("a non-2xx status throws AdminApiError with the server's message", async () => { const { transport } = stubTransport({ error: "kaboom" }, 418); const failure = await adminApiChildCreate( transport, ADMIN_API_RESOURCES.attributes, "options", 3, {}, ).catch((error: unknown) => error); expect(failure).toBeInstanceOf(AdminApiError); expect((failure as AdminApiError).name).toBe("AdminApiError"); expect((failure as Error).message).toBe("kaboom (status 418)"); }); test("a non-2xx answer without an error string names the status alone", async () => { const { transport } = stubTransport({ detail: "nope" }, 500); const failure = await adminApiList( transport, ADMIN_API_RESOURCES.attributes, ).catch((error: unknown) => error); expect(failure).toBeInstanceOf(AdminApiError); expect((failure as Error).message).toBe( "admin api request failed (status 500)", ); }); test("a 3xx or 304 status throws before the envelope validates", async () => { const redirected = stubTransport({ attributes: [ATTRIBUTE] }, 302); const failure = await adminApiList( redirected.transport, ADMIN_API_RESOURCES.attributes, ).catch((error: unknown) => error); expect(failure).toBeInstanceOf(AdminApiError); expect((failure as Error).message).toBe( "admin api request failed (status 302)", ); const notModified = stubTransport({ attributes: [ATTRIBUTE] }, 304); const cached = await adminApiList( notModified.transport, ADMIN_API_RESOURCES.attributes, ).catch((error: unknown) => error); expect(cached).toBeInstanceOf(AdminApiError); expect((cached as Error).message).toBe( "admin api request failed (status 304)", ); }); test("an unknown child key throws instead of building a path", async () => { const { transport } = stubTransport({ attribute: ATTRIBUTE }); const childFailure = await adminApiChildCreate( transport, ADMIN_API_RESOURCES.attributes, // The table has one child today; the runtime guard is for a caller // that names a child the entry does not declare. "nonexistent" as keyof typeof ADMIN_API_RESOURCES.attributes.children, 3, {}, ).catch((error: unknown) => error); expect((childFailure as Error).message).toBe( "admin api attributes has no nonexistent child", ); }); });