apiClient.test.js 4.4 KB

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