apiClient.test.js 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  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. adminListUsers,
  12. adminCreateUser,
  13. adminUpdateUser,
  14. adminDeleteUser,
  15. adminResetUserPassword,
  16. } from "./apiClient.js";
  17. beforeEach(() => {
  18. vi.restoreAllMocks();
  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. 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 maps standardized backend errors into ApiClientError", async () => {
  36. fetch.mockResolvedValue(
  37. new Response(
  38. JSON.stringify({
  39. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  40. }),
  41. { status: 401, headers: { "Content-Type": "application/json" } },
  42. ),
  43. );
  44. await expect(apiFetch("/api/branches")).rejects.toMatchObject({
  45. name: "ApiClientError",
  46. status: 401,
  47. code: "AUTH_UNAUTHENTICATED",
  48. });
  49. });
  50. it("apiFetch maps network failures to CLIENT_NETWORK_ERROR", async () => {
  51. fetch.mockRejectedValue(new Error("connection refused"));
  52. try {
  53. await apiFetch("/api/branches");
  54. throw new Error("Expected apiFetch to throw");
  55. } catch (err) {
  56. expect(err).toBeInstanceOf(ApiClientError);
  57. expect(err.code).toBe("CLIENT_NETWORK_ERROR");
  58. expect(err.status).toBe(0);
  59. }
  60. });
  61. it("getFiles builds the expected query string", async () => {
  62. fetch.mockResolvedValue(
  63. new Response(JSON.stringify({ files: [] }), {
  64. status: 200,
  65. headers: { "Content-Type": "application/json" },
  66. }),
  67. );
  68. await getFiles("NL01", "2024", "10", "23");
  69. const [url] = fetch.mock.calls[0];
  70. expect(url).toContain("/api/files?");
  71. expect(url).toContain("branch=NL01");
  72. expect(url).toContain("year=2024");
  73. expect(url).toContain("month=10");
  74. expect(url).toContain("day=23");
  75. });
  76. it("getMe calls /api/auth/me", async () => {
  77. fetch.mockResolvedValue(
  78. new Response(JSON.stringify({ user: null }), {
  79. status: 200,
  80. headers: { "Content-Type": "application/json" },
  81. }),
  82. );
  83. const res = await getMe();
  84. expect(res).toEqual({ user: null });
  85. const [url, init] = fetch.mock.calls[0];
  86. expect(url).toBe("/api/auth/me");
  87. expect(init.method).toBe("GET");
  88. });
  89. it("search builds expected query string", async () => {
  90. fetch.mockResolvedValue(
  91. new Response(JSON.stringify({ items: [], nextCursor: null }), {
  92. status: 200,
  93. headers: { "Content-Type": "application/json" },
  94. }),
  95. );
  96. await search({ q: "x", branch: "NL01", limit: 100 });
  97. const [url] = fetch.mock.calls[0];
  98. const u = new URL(url, "http://localhost");
  99. expect(u.pathname).toBe("/api/search");
  100. expect(u.searchParams.get("q")).toBe("x");
  101. expect(u.searchParams.get("branch")).toBe("NL01");
  102. expect(u.searchParams.get("limit")).toBe("100");
  103. });
  104. it("changePassword calls POST /api/auth/change-password", async () => {
  105. fetch.mockResolvedValue(
  106. new Response(JSON.stringify({ ok: true }), {
  107. status: 200,
  108. headers: { "Content-Type": "application/json" },
  109. }),
  110. );
  111. await changePassword({ currentPassword: "a", newPassword: "b" });
  112. const [url, init] = fetch.mock.calls[0];
  113. expect(url).toBe("/api/auth/change-password");
  114. expect(init.method).toBe("POST");
  115. });
  116. it("adminUpdateUser calls PATCH /api/admin/users/:id", async () => {
  117. fetch.mockResolvedValue(
  118. new Response(JSON.stringify({ ok: true }), {
  119. status: 200,
  120. headers: { "Content-Type": "application/json" },
  121. }),
  122. );
  123. await adminUpdateUser("507f1f77bcf86cd799439011", { role: "admin" });
  124. const [url, init] = fetch.mock.calls[0];
  125. expect(url).toBe("/api/admin/users/507f1f77bcf86cd799439011");
  126. expect(init.method).toBe("PATCH");
  127. });
  128. it("adminListUsers includes sort in query string", async () => {
  129. fetch.mockResolvedValue(
  130. new Response(JSON.stringify({ items: [], nextCursor: null }), {
  131. status: 200,
  132. headers: { "Content-Type": "application/json" },
  133. }),
  134. );
  135. await adminListUsers({ q: "dev", sort: "role_rights", limit: 50 });
  136. const [url, init] = fetch.mock.calls[0];
  137. const u = new URL(url, "http://localhost");
  138. expect(u.pathname).toBe("/api/admin/users");
  139. expect(u.searchParams.get("q")).toBe("dev");
  140. expect(u.searchParams.get("sort")).toBe("role_rights");
  141. expect(u.searchParams.get("limit")).toBe("50");
  142. expect(init.method).toBe("GET");
  143. });
  144. it("adminDeleteUser calls DELETE /api/admin/users/:id", async () => {
  145. fetch.mockResolvedValue(
  146. new Response(JSON.stringify({ ok: true }), {
  147. status: 200,
  148. headers: { "Content-Type": "application/json" },
  149. }),
  150. );
  151. await adminDeleteUser("507f1f77bcf86cd799439099");
  152. const [url, init] = fetch.mock.calls[0];
  153. expect(url).toBe("/api/admin/users/507f1f77bcf86cd799439099");
  154. expect(init.method).toBe("DELETE");
  155. });
  156. it("adminResetUserPassword calls POST /api/admin/users/:id", async () => {
  157. fetch.mockResolvedValue(
  158. new Response(JSON.stringify({ ok: true, temporaryPassword: "x" }), {
  159. status: 200,
  160. headers: { "Content-Type": "application/json" },
  161. }),
  162. );
  163. await adminResetUserPassword("507f1f77bcf86cd799439099");
  164. const [url, init] = fetch.mock.calls[0];
  165. expect(url).toBe("/api/admin/users/507f1f77bcf86cd799439099");
  166. expect(init.method).toBe("POST");
  167. });
  168. });