apiClient.test.js 5.3 KB

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