test/shared/admin-api-client.test.ts
Rama main · réplica de lectura del repositorio original.
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",
);
});
});