apiClient.test.js 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  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("adminDeleteUser calls DELETE /api/admin/users/:id", async () => {
  129. fetch.mockResolvedValue(
  130. new Response(JSON.stringify({ ok: true }), {
  131. status: 200,
  132. headers: { "Content-Type": "application/json" },
  133. }),
  134. );
  135. await adminDeleteUser("507f1f77bcf86cd799439099");
  136. const [url, init] = fetch.mock.calls[0];
  137. expect(url).toBe("/api/admin/users/507f1f77bcf86cd799439099");
  138. expect(init.method).toBe("DELETE");
  139. });
  140. it("adminResetUserPassword calls POST /api/admin/users/:id", async () => {
  141. fetch.mockResolvedValue(
  142. new Response(JSON.stringify({ ok: true, temporaryPassword: "x" }), {
  143. status: 200,
  144. headers: { "Content-Type": "application/json" },
  145. }),
  146. );
  147. await adminResetUserPassword("507f1f77bcf86cd799439099");
  148. const [url, init] = fetch.mock.calls[0];
  149. expect(url).toBe("/api/admin/users/507f1f77bcf86cd799439099");
  150. expect(init.method).toBe("POST");
  151. });
  152. });