main
ts 202 lines 6.45 KB
Raw
1 import { APIClientError, apiClient } from "@/lib/apiClient";
2 import { writeAdminAuthToken } from "@/lib/adminAuthToken";
3 import { beforeEach, describe, expect, it, vi } from "vitest";
4
5 function jsonResponse(payload: unknown, init?: ResponseInit): Response {
6 return new Response(JSON.stringify(payload), {
7 status: 200,
8 headers: { "Content-Type": "application/json" },
9 ...init,
10 });
11 }
12
13 describe("apiClient", () => {
14 const fetchMock = vi.fn();
15
16 beforeEach(() => {
17 vi.unstubAllEnvs();
18 fetchMock.mockReset();
19 vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
20 localStorage.clear();
21 });
22
23 it("returns data when API envelope is ok", async () => {
24 fetchMock.mockResolvedValueOnce(
25 jsonResponse({ ok: true, data: { value: 42 } }),
26 );
27
28 const data = await apiClient.get<{ value: number }>("/api/test");
29
30 expect(data).toEqual({ value: 42 });
31 const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
32 expect(init.method).toBe("GET");
33 expect(init.credentials).toBe("same-origin");
34 expect(init.headers).toEqual({ Accept: "application/json" });
35 });
36
37 it("preserves non-API base URL subpaths", async () => {
38 vi.stubEnv("VITE_PORTAL_API_BASE_URL", "https://portal.example.com/custom");
39 fetchMock.mockResolvedValueOnce(
40 jsonResponse({ ok: true, data: { status: "ok" } }),
41 );
42
43 await apiClient.get("/ui/state");
44
45 expect(fetchMock.mock.calls[0]?.[0]).toBe("https://portal.example.com/custom/ui/state");
46 });
47
48 it("treats an API base URL suffix as the public edge root", async () => {
49 vi.stubEnv("VITE_PORTAL_API_BASE_URL", "https://portal.example.com/api");
50 fetchMock.mockResolvedValueOnce(
51 jsonResponse({ ok: true, data: { status: "ok" } }),
52 );
53
54 await apiClient.get("/ui/state");
55
56 expect(fetchMock.mock.calls[0]?.[0]).toBe("https://portal.example.com/ui/state");
57 });
58
59 it("rejects successful non-envelope JSON payloads", async () => {
60 fetchMock.mockResolvedValueOnce(jsonResponse({ direct: true }));
61
62 await expect(apiClient.get<{ direct: boolean }>("/api/test")).rejects.toMatchObject({
63 name: "APIClientError",
64 status: 200,
65 code: "invalid_envelope",
66 } satisfies Partial<APIClientError>);
67 });
68
69 it("throws APIClientError for server-side envelope failures", async () => {
70 fetchMock.mockResolvedValueOnce(
71 jsonResponse(
72 { ok: false, error: { code: "forbidden", message: "Denied" } },
73 { status: 403, statusText: "Forbidden" },
74 ),
75 );
76
77 await expect(apiClient.get("/api/test")).rejects.toMatchObject({
78 name: "APIClientError",
79 status: 403,
80 code: "forbidden",
81 message: "Denied",
82 } satisfies Partial<APIClientError>);
83 });
84
85 it("rejects structured non-envelope errors as invalid envelopes", async () => {
86 fetchMock.mockResolvedValueOnce(
87 jsonResponse(
88 { code: "lease_rejected", message: "failed to register lease" },
89 { status: 409, statusText: "Conflict" },
90 ),
91 );
92
93 await expect(apiClient.get("/api/test")).rejects.toMatchObject({
94 name: "APIClientError",
95 status: 409,
96 code: "invalid_envelope",
97 } satisfies Partial<APIClientError>);
98 });
99
100 it("throws invalid_envelope when a failed response has no parseable error payload", async () => {
101 fetchMock.mockResolvedValueOnce(
102 jsonResponse({ detail: "not wrapped" }, { status: 400 }),
103 );
104
105 await expect(apiClient.get("/api/test")).rejects.toMatchObject({
106 name: "APIClientError",
107 status: 400,
108 code: "invalid_envelope",
109 } satisfies Partial<APIClientError>);
110 });
111
112 it("treats empty responses as invalid envelopes", async () => {
113 fetchMock.mockResolvedValueOnce(new Response("", { status: 200 }));
114
115 await expect(apiClient.get("/api/test")).rejects.toMatchObject({
116 name: "APIClientError",
117 status: 200,
118 code: "invalid_envelope",
119 } satisfies Partial<APIClientError>);
120 });
121
122 it("throws invalid_json when response body is not parseable JSON", async () => {
123 fetchMock.mockResolvedValueOnce(
124 new Response("not-json", {
125 status: 200,
126 headers: { "Content-Type": "application/json" },
127 }),
128 );
129
130 await expect(apiClient.get("/api/test")).rejects.toMatchObject({
131 name: "APIClientError",
132 status: 200,
133 code: "invalid_json",
134 } satisfies Partial<APIClientError>);
135 });
136
137 it("maps fetch failures to network_error", async () => {
138 fetchMock.mockRejectedValueOnce(new Error("network down"));
139
140 await expect(apiClient.get("/api/test")).rejects.toMatchObject({
141 name: "APIClientError",
142 status: 0,
143 code: "network_error",
144 message: "Network request failed",
145 } satisfies Partial<APIClientError>);
146 });
147
148 it("maps AbortError failures to aborted", async () => {
149 fetchMock.mockRejectedValueOnce(new DOMException("Aborted", "AbortError"));
150
151 await expect(apiClient.get("/api/test")).rejects.toMatchObject({
152 name: "APIClientError",
153 status: 0,
154 code: "aborted",
155 message: "Request was aborted",
156 } satisfies Partial<APIClientError>);
157 });
158
159 it("sends JSON bodies for post", async () => {
160 fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, data: {} }));
161 await apiClient.post("/api/post", { id: 1 });
162
163 const postInit = fetchMock.mock.calls[0]?.[1] as RequestInit;
164 expect(postInit.method).toBe("POST");
165 expect(postInit.body).toBe(JSON.stringify({ id: 1 }));
166 expect(postInit.headers).toEqual({
167 Accept: "application/json",
168 "Content-Type": "application/json",
169 });
170 });
171
172 it("sends bearer token for admin API calls", async () => {
173 writeAdminAuthToken("admin-token");
174 fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, data: {} }));
175
176 await apiClient.post("/api/admin/auth/logout");
177
178 const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
179 expect(init.credentials).toBe("same-origin");
180 expect(init.headers).toEqual({
181 Accept: "application/json",
182 Authorization: "Bearer admin-token",
183 });
184 });
185
186 it("sends bearer token for policy API calls", async () => {
187 writeAdminAuthToken("admin-token");
188 fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, data: {} }));
189
190 await apiClient.post("/ui/policy/leases", {
191 identity_key: "relay:0x1",
192 is_approved: true,
193 });
194
195 const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
196 expect(init.headers).toEqual({
197 Accept: "application/json",
198 Authorization: "Bearer admin-token",
199 "Content-Type": "application/json",
200 });
201 });
202 });