apiClient.test.js 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  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. search,
  10. changePassword,
  11. } from "./apiClient.js";
  12. beforeEach(() => {
  13. // Restore mocks between tests to avoid cross-test pollution.
  14. vi.restoreAllMocks();
  15. // In these unit tests we stub the global fetch implementation.
  16. // This allows us to validate:
  17. // - request defaults (credentials/cache)
  18. // - URL building
  19. // - error mapping
  20. global.fetch = vi.fn();
  21. });
  22. describe("lib/frontend/apiClient", () => {
  23. it("apiFetch uses credentials=include and cache=no-store", async () => {
  24. fetch.mockResolvedValue(
  25. new Response(JSON.stringify({ ok: true }), {
  26. status: 200,
  27. headers: { "Content-Type": "application/json" },
  28. }),
  29. );
  30. await apiFetch("/api/health");
  31. expect(fetch).toHaveBeenCalledTimes(1);
  32. const [url, init] = fetch.mock.calls[0];
  33. expect(url).toBe("/api/health");
  34. expect(init.credentials).toBe("include");
  35. expect(init.cache).toBe("no-store");
  36. });
  37. it("apiFetch serializes JSON bodies and sets Content-Type", async () => {
  38. fetch.mockResolvedValue(
  39. new Response(JSON.stringify({ ok: true }), {
  40. status: 200,
  41. headers: { "Content-Type": "application/json" },
  42. }),
  43. );
  44. await login({ username: "u", password: "p" });
  45. const [, init] = fetch.mock.calls[0];
  46. expect(init.method).toBe("POST");
  47. expect(init.headers.Accept).toBe("application/json");
  48. expect(init.headers["Content-Type"]).toBe("application/json");
  49. expect(init.body).toBe(JSON.stringify({ username: "u", password: "p" }));
  50. });
  51. it("apiFetch throws ApiClientError for standardized backend error payloads", async () => {
  52. fetch.mockResolvedValue(
  53. new Response(
  54. JSON.stringify({
  55. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  56. }),
  57. { status: 401, headers: { "Content-Type": "application/json" } },
  58. ),
  59. );
  60. await expect(apiFetch("/api/branches")).rejects.toMatchObject({
  61. name: "ApiClientError",
  62. status: 401,
  63. code: "AUTH_UNAUTHENTICATED",
  64. message: "Unauthorized",
  65. });
  66. });
  67. it("apiFetch maps network failures to CLIENT_NETWORK_ERROR", async () => {
  68. fetch.mockRejectedValue(new Error("connection refused"));
  69. try {
  70. await apiFetch("/api/branches");
  71. throw new Error("Expected apiFetch to throw");
  72. } catch (err) {
  73. expect(err).toBeInstanceOf(ApiClientError);
  74. expect(err.code).toBe("CLIENT_NETWORK_ERROR");
  75. expect(err.status).toBe(0);
  76. }
  77. });
  78. it("getFiles builds the expected query string", async () => {
  79. fetch.mockResolvedValue(
  80. new Response(
  81. JSON.stringify({
  82. branch: "NL01",
  83. year: "2024",
  84. month: "10",
  85. day: "23",
  86. files: [],
  87. }),
  88. { status: 200, headers: { "Content-Type": "application/json" } },
  89. ),
  90. );
  91. await getFiles("NL01", "2024", "10", "23");
  92. const [url] = fetch.mock.calls[0];
  93. // We do not rely on param ordering beyond URLSearchParams defaults.
  94. expect(url).toContain("/api/files?");
  95. expect(url).toContain("branch=NL01");
  96. expect(url).toContain("year=2024");
  97. expect(url).toContain("month=10");
  98. expect(url).toContain("day=23");
  99. });
  100. it("getMe calls /api/auth/me and returns the parsed payload", async () => {
  101. // /api/auth/me returns 200 with { user: null } or { user: {...} }.
  102. // For this unit test we only need to ensure:
  103. // - correct endpoint
  104. // - correct method
  105. // - response is returned as parsed JSON
  106. fetch.mockResolvedValue(
  107. new Response(JSON.stringify({ user: null }), {
  108. status: 200,
  109. headers: { "Content-Type": "application/json" },
  110. }),
  111. );
  112. const res = await getMe();
  113. expect(res).toEqual({ user: null });
  114. expect(fetch).toHaveBeenCalledTimes(1);
  115. const [url, init] = fetch.mock.calls[0];
  116. expect(url).toBe("/api/auth/me");
  117. expect(init.method).toBe("GET");
  118. // Ensure our global defaults are still enforced for session-based calls.
  119. expect(init.credentials).toBe("include");
  120. expect(init.cache).toBe("no-store");
  121. });
  122. it("changePassword calls /api/auth/change-password with POST and JSON body", async () => {
  123. fetch.mockResolvedValue(
  124. new Response(JSON.stringify({ ok: true }), {
  125. status: 200,
  126. headers: { "Content-Type": "application/json" },
  127. }),
  128. );
  129. await changePassword({
  130. currentPassword: "OldPassword123",
  131. newPassword: "StrongPassword123",
  132. });
  133. expect(fetch).toHaveBeenCalledTimes(1);
  134. const [url, init] = fetch.mock.calls[0];
  135. expect(url).toBe("/api/auth/change-password");
  136. expect(init.method).toBe("POST");
  137. expect(init.headers.Accept).toBe("application/json");
  138. expect(init.headers["Content-Type"]).toBe("application/json");
  139. expect(init.body).toBe(
  140. JSON.stringify({
  141. currentPassword: "OldPassword123",
  142. newPassword: "StrongPassword123",
  143. }),
  144. );
  145. expect(init.credentials).toBe("include");
  146. expect(init.cache).toBe("no-store");
  147. });
  148. it("search builds the expected query string for branch scope", async () => {
  149. fetch.mockResolvedValue(
  150. new Response(JSON.stringify({ items: [], nextCursor: null }), {
  151. status: 200,
  152. headers: { "Content-Type": "application/json" },
  153. }),
  154. );
  155. await search({ q: "bridgestone", branch: "NL01", limit: 100 });
  156. expect(fetch).toHaveBeenCalledTimes(1);
  157. const [url, init] = fetch.mock.calls[0];
  158. const u = new URL(url, "http://localhost");
  159. expect(u.pathname).toBe("/api/search");
  160. expect(u.searchParams.get("q")).toBe("bridgestone");
  161. expect(u.searchParams.get("branch")).toBe("NL01");
  162. expect(u.searchParams.get("limit")).toBe("100");
  163. expect(init.method).toBe("GET");
  164. expect(init.credentials).toBe("include");
  165. expect(init.cache).toBe("no-store");
  166. });
  167. it("search supports multi scope + branches + cursor", async () => {
  168. fetch.mockResolvedValue(
  169. new Response(JSON.stringify({ items: [], nextCursor: null }), {
  170. status: 200,
  171. headers: { "Content-Type": "application/json" },
  172. }),
  173. );
  174. await search({
  175. q: " reifen ",
  176. scope: "multi",
  177. branches: ["NL06", "NL20"],
  178. cursor: "abc",
  179. });
  180. const [url] = fetch.mock.calls[0];
  181. const u = new URL(url, "http://localhost");
  182. expect(u.pathname).toBe("/api/search");
  183. expect(u.searchParams.get("q")).toBe("reifen");
  184. expect(u.searchParams.get("scope")).toBe("multi");
  185. expect(u.searchParams.get("branches")).toBe("NL06,NL20");
  186. expect(u.searchParams.get("cursor")).toBe("abc");
  187. });
  188. });