apiClient.test.js 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. /* @vitest-environment node */
  2. import { describe, it, expect, vi, beforeEach } from "vitest";
  3. import { apiFetch, ApiClientError, getFiles, login } from "./apiClient.js";
  4. beforeEach(() => {
  5. vi.restoreAllMocks();
  6. global.fetch = vi.fn();
  7. });
  8. describe("lib/frontend/apiClient", () => {
  9. it("apiFetch uses credentials=include and cache=no-store", async () => {
  10. fetch.mockResolvedValue(
  11. new Response(JSON.stringify({ ok: true }), {
  12. status: 200,
  13. headers: { "Content-Type": "application/json" },
  14. })
  15. );
  16. await apiFetch("/api/health");
  17. expect(fetch).toHaveBeenCalledTimes(1);
  18. const [url, init] = fetch.mock.calls[0];
  19. expect(url).toBe("/api/health");
  20. expect(init.credentials).toBe("include");
  21. expect(init.cache).toBe("no-store");
  22. });
  23. it("apiFetch serializes JSON bodies and sets Content-Type", async () => {
  24. fetch.mockResolvedValue(
  25. new Response(JSON.stringify({ ok: true }), {
  26. status: 200,
  27. headers: { "Content-Type": "application/json" },
  28. })
  29. );
  30. await login({ username: "u", password: "p" });
  31. const [, init] = fetch.mock.calls[0];
  32. expect(init.method).toBe("POST");
  33. expect(init.headers.Accept).toBe("application/json");
  34. expect(init.headers["Content-Type"]).toBe("application/json");
  35. expect(init.body).toBe(JSON.stringify({ username: "u", password: "p" }));
  36. });
  37. it("apiFetch throws ApiClientError for standardized backend error payloads", async () => {
  38. fetch.mockResolvedValue(
  39. new Response(
  40. JSON.stringify({
  41. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  42. }),
  43. { status: 401, headers: { "Content-Type": "application/json" } }
  44. )
  45. );
  46. await expect(apiFetch("/api/branches")).rejects.toMatchObject({
  47. name: "ApiClientError",
  48. status: 401,
  49. code: "AUTH_UNAUTHENTICATED",
  50. message: "Unauthorized",
  51. });
  52. });
  53. it("apiFetch maps network failures to CLIENT_NETWORK_ERROR", async () => {
  54. fetch.mockRejectedValue(new Error("connection refused"));
  55. try {
  56. await apiFetch("/api/branches");
  57. throw new Error("Expected apiFetch to throw");
  58. } catch (err) {
  59. expect(err).toBeInstanceOf(ApiClientError);
  60. expect(err.code).toBe("CLIENT_NETWORK_ERROR");
  61. expect(err.status).toBe(0);
  62. }
  63. });
  64. it("getFiles builds the expected query string", async () => {
  65. fetch.mockResolvedValue(
  66. new Response(
  67. JSON.stringify({
  68. branch: "NL01",
  69. year: "2024",
  70. month: "10",
  71. day: "23",
  72. files: [],
  73. }),
  74. { status: 200, headers: { "Content-Type": "application/json" } }
  75. )
  76. );
  77. await getFiles("NL01", "2024", "10", "23");
  78. const [url] = fetch.mock.calls[0];
  79. // We do not rely on param ordering beyond URLSearchParams defaults.
  80. expect(url).toContain("/api/files?");
  81. expect(url).toContain("branch=NL01");
  82. expect(url).toContain("year=2024");
  83. expect(url).toContain("month=10");
  84. expect(url).toContain("day=23");
  85. });
  86. });