apiClient.test.js 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. /* @vitest-environment node */
  2. import { describe, it, expect, vi, beforeEach } from "vitest";
  3. import {
  4. apiFetch,
  5. ApiClientError,
  6. getFiles,
  7. login,
  8. getMe,
  9. } from "./apiClient.js";
  10. beforeEach(() => {
  11. // Restore mocks between tests to avoid cross-test pollution.
  12. vi.restoreAllMocks();
  13. // In these unit tests we stub the global fetch implementation.
  14. // This allows us to validate:
  15. // - request defaults (credentials/cache)
  16. // - URL building
  17. // - error mapping
  18. global.fetch = vi.fn();
  19. });
  20. describe("lib/frontend/apiClient", () => {
  21. it("apiFetch uses credentials=include and cache=no-store", async () => {
  22. fetch.mockResolvedValue(
  23. new Response(JSON.stringify({ ok: true }), {
  24. status: 200,
  25. headers: { "Content-Type": "application/json" },
  26. })
  27. );
  28. await apiFetch("/api/health");
  29. expect(fetch).toHaveBeenCalledTimes(1);
  30. const [url, init] = fetch.mock.calls[0];
  31. expect(url).toBe("/api/health");
  32. expect(init.credentials).toBe("include");
  33. expect(init.cache).toBe("no-store");
  34. });
  35. it("apiFetch serializes JSON bodies and sets Content-Type", async () => {
  36. fetch.mockResolvedValue(
  37. new Response(JSON.stringify({ ok: true }), {
  38. status: 200,
  39. headers: { "Content-Type": "application/json" },
  40. })
  41. );
  42. await login({ username: "u", password: "p" });
  43. const [, init] = fetch.mock.calls[0];
  44. expect(init.method).toBe("POST");
  45. expect(init.headers.Accept).toBe("application/json");
  46. expect(init.headers["Content-Type"]).toBe("application/json");
  47. expect(init.body).toBe(JSON.stringify({ username: "u", password: "p" }));
  48. });
  49. it("apiFetch throws ApiClientError for standardized backend error payloads", async () => {
  50. fetch.mockResolvedValue(
  51. new Response(
  52. JSON.stringify({
  53. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  54. }),
  55. { status: 401, headers: { "Content-Type": "application/json" } }
  56. )
  57. );
  58. await expect(apiFetch("/api/branches")).rejects.toMatchObject({
  59. name: "ApiClientError",
  60. status: 401,
  61. code: "AUTH_UNAUTHENTICATED",
  62. message: "Unauthorized",
  63. });
  64. });
  65. it("apiFetch maps network failures to CLIENT_NETWORK_ERROR", async () => {
  66. fetch.mockRejectedValue(new Error("connection refused"));
  67. try {
  68. await apiFetch("/api/branches");
  69. throw new Error("Expected apiFetch to throw");
  70. } catch (err) {
  71. expect(err).toBeInstanceOf(ApiClientError);
  72. expect(err.code).toBe("CLIENT_NETWORK_ERROR");
  73. expect(err.status).toBe(0);
  74. }
  75. });
  76. it("getFiles builds the expected query string", async () => {
  77. fetch.mockResolvedValue(
  78. new Response(
  79. JSON.stringify({
  80. branch: "NL01",
  81. year: "2024",
  82. month: "10",
  83. day: "23",
  84. files: [],
  85. }),
  86. { status: 200, headers: { "Content-Type": "application/json" } }
  87. )
  88. );
  89. await getFiles("NL01", "2024", "10", "23");
  90. const [url] = fetch.mock.calls[0];
  91. // We do not rely on param ordering beyond URLSearchParams defaults.
  92. expect(url).toContain("/api/files?");
  93. expect(url).toContain("branch=NL01");
  94. expect(url).toContain("year=2024");
  95. expect(url).toContain("month=10");
  96. expect(url).toContain("day=23");
  97. });
  98. it("getMe calls /api/auth/me and returns the parsed payload", async () => {
  99. // /api/auth/me returns 200 with { user: null } or { user: {...} }.
  100. // For this unit test we only need to ensure:
  101. // - correct endpoint
  102. // - correct method
  103. // - response is returned as parsed JSON
  104. fetch.mockResolvedValue(
  105. new Response(JSON.stringify({ user: null }), {
  106. status: 200,
  107. headers: { "Content-Type": "application/json" },
  108. })
  109. );
  110. const res = await getMe();
  111. expect(res).toEqual({ user: null });
  112. expect(fetch).toHaveBeenCalledTimes(1);
  113. const [url, init] = fetch.mock.calls[0];
  114. expect(url).toBe("/api/auth/me");
  115. expect(init.method).toBe("GET");
  116. // Ensure our global defaults are still enforced for session-based calls.
  117. expect(init.credentials).toBe("include");
  118. expect(init.cache).toBe("no-store");
  119. });
  120. });