api.test.ts (10386B)
1 import { describe, it, expect, vi, beforeEach } from "vitest"; 2 import { api, ApiError, uploadAttachment } from "./api"; 3 4 beforeEach(() => { 5 vi.restoreAllMocks(); 6 vi.stubGlobal( 7 "fetch", 8 vi.fn().mockResolvedValue({ 9 ok: true, 10 json: async () => ({}), 11 }), 12 ); 13 }); 14 15 describe("api client", () => { 16 it("sends correct headers with credentials", async () => { 17 const mockFetch = vi.fn().mockResolvedValue({ 18 ok: true, 19 json: async () => ({ data: 1 }), 20 }); 21 vi.stubGlobal("fetch", mockFetch); 22 23 await api.get("/test"); 24 25 expect(mockFetch).toHaveBeenCalledWith( 26 "/api/test", 27 expect.objectContaining({ 28 credentials: "include", 29 headers: expect.objectContaining({ 30 "Content-Type": "application/json", 31 }), 32 }), 33 ); 34 }); 35 36 it("handles 401 by redirecting to /login", async () => { 37 const mockFetch = vi.fn().mockResolvedValue({ 38 ok: false, 39 status: 401, 40 json: async () => ({ error: "unauthorized" }), 41 }); 42 vi.stubGlobal("fetch", mockFetch); 43 44 const assignSpy = vi.fn(); 45 const originalLocation = window.location; 46 // eslint-disable-next-line @typescript-eslint/no-explicit-any 47 delete (window as any).location; 48 // eslint-disable-next-line @typescript-eslint/no-explicit-any 49 (window as any).location = { ...originalLocation, assign: assignSpy }; 50 51 await expect(api.get("/protected")).rejects.toThrow(); 52 expect(assignSpy).toHaveBeenCalledWith("/login"); 53 54 // Restore 55 // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 (window as any).location = originalLocation; 57 }); 58 59 it("handles network errors", async () => { 60 vi.stubGlobal( 61 "fetch", 62 vi.fn().mockRejectedValue(new TypeError("Failed to fetch")), 63 ); 64 65 await expect(api.get("/test")).rejects.toThrow("Failed to fetch"); 66 }); 67 68 it("passes JSON body correctly for POST", async () => { 69 const mockFetch = vi.fn().mockResolvedValue({ 70 ok: true, 71 json: async () => ({ created: true }), 72 }); 73 vi.stubGlobal("fetch", mockFetch); 74 75 await api.post("/create", { name: "test" }); 76 77 expect(mockFetch).toHaveBeenCalledWith( 78 "/api/create", 79 expect.objectContaining({ 80 method: "POST", 81 body: JSON.stringify({ name: "test" }), 82 }), 83 ); 84 }); 85 86 it("throws ApiError on non-ok response", async () => { 87 const mockFetch = vi.fn().mockResolvedValue({ 88 ok: false, 89 status: 500, 90 json: async () => ({ error: "server error" }), 91 }); 92 vi.stubGlobal("fetch", mockFetch); 93 94 await expect(api.get("/error")).rejects.toThrow(ApiError); 95 }); 96 97 it("ApiError contains status code", async () => { 98 const mockFetch = vi.fn().mockResolvedValue({ 99 ok: false, 100 status: 500, 101 json: async () => ({ error: "server error" }), 102 }); 103 vi.stubGlobal("fetch", mockFetch); 104 105 try { 106 await api.get("/error"); 107 expect.fail("should have thrown"); 108 } catch (e) { 109 expect(e).toBeInstanceOf(ApiError); 110 expect((e as ApiError).status).toBe(500); 111 } 112 }); 113 114 it("ApiError name is 'ApiError'", async () => { 115 const mockFetch = vi.fn().mockResolvedValue({ 116 ok: false, 117 status: 403, 118 json: async () => ({ error: "forbidden" }), 119 }); 120 vi.stubGlobal("fetch", mockFetch); 121 122 try { 123 await api.get("/forbidden"); 124 expect.fail("should have thrown"); 125 } catch (e) { 126 expect((e as ApiError).name).toBe("ApiError"); 127 } 128 }); 129 130 it("passes body for PATCH", async () => { 131 const mockFetch = vi.fn().mockResolvedValue({ 132 ok: true, 133 json: async () => ({}), 134 }); 135 vi.stubGlobal("fetch", mockFetch); 136 137 await api.patch("/update", { value: 42 }); 138 139 expect(mockFetch).toHaveBeenCalledWith( 140 "/api/update", 141 expect.objectContaining({ 142 method: "PATCH", 143 body: JSON.stringify({ value: 42 }), 144 }), 145 ); 146 }); 147 148 it("sends DELETE request", async () => { 149 const mockFetch = vi.fn().mockResolvedValue({ 150 ok: true, 151 json: async () => ({}), 152 }); 153 vi.stubGlobal("fetch", mockFetch); 154 155 await api.delete("/item/1"); 156 157 expect(mockFetch).toHaveBeenCalledWith( 158 "/api/item/1", 159 expect.objectContaining({ 160 method: "DELETE", 161 }), 162 ); 163 }); 164 165 it("GET uses correct method", async () => { 166 const mockFetch = vi.fn().mockResolvedValue({ 167 ok: true, 168 json: async () => ({ data: 1 }), 169 }); 170 vi.stubGlobal("fetch", mockFetch); 171 172 await api.get("/data"); 173 174 expect(mockFetch).toHaveBeenCalledWith( 175 "/api/data", 176 expect.objectContaining({ method: "GET" }), 177 ); 178 }); 179 180 it("non-401 error response includes error message from server", async () => { 181 const mockFetch = vi.fn().mockResolvedValue({ 182 ok: false, 183 status: 422, 184 json: async () => ({ error: "Validation failed" }), 185 }); 186 vi.stubGlobal("fetch", mockFetch); 187 188 try { 189 await api.get("/validate"); 190 expect.fail("should have thrown"); 191 } catch (e) { 192 expect(e).toBeInstanceOf(ApiError); 193 expect((e as ApiError).status).toBe(422); 194 expect((e as ApiError).message).toBe("Validation failed"); 195 } 196 }); 197 198 it("non-401 error with no error field uses 'Request failed'", async () => { 199 const mockFetch = vi.fn().mockResolvedValue({ 200 ok: false, 201 status: 502, 202 json: async () => ({}), 203 }); 204 vi.stubGlobal("fetch", mockFetch); 205 206 try { 207 await api.get("/bad-gateway"); 208 expect.fail("should have thrown"); 209 } catch (e) { 210 expect((e as ApiError).message).toBe("Request failed"); 211 } 212 }); 213 214 it("non-401 error handles json parse failure", async () => { 215 const mockFetch = vi.fn().mockResolvedValue({ 216 ok: false, 217 status: 500, 218 json: async () => { throw new Error("invalid json"); }, 219 }); 220 vi.stubGlobal("fetch", mockFetch); 221 222 try { 223 await api.get("/error"); 224 expect.fail("should have thrown"); 225 } catch (e) { 226 expect(e).toBeInstanceOf(ApiError); 227 expect((e as ApiError).message).toBe("Unknown error"); 228 } 229 }); 230 231 it("POST without body does not include body in request", async () => { 232 const mockFetch = vi.fn().mockResolvedValue({ 233 ok: true, 234 json: async () => ({}), 235 }); 236 vi.stubGlobal("fetch", mockFetch); 237 238 await api.post("/action"); 239 240 const callArgs = mockFetch.mock.calls[0][1]; 241 expect(callArgs.body).toBeUndefined(); 242 }); 243 244 it("PATCH without body does not include body in request", async () => { 245 const mockFetch = vi.fn().mockResolvedValue({ 246 ok: true, 247 json: async () => ({}), 248 }); 249 vi.stubGlobal("fetch", mockFetch); 250 251 await api.patch("/action"); 252 253 const callArgs = mockFetch.mock.calls[0][1]; 254 expect(callArgs.body).toBeUndefined(); 255 }); 256 257 it("returns parsed JSON on success", async () => { 258 const mockFetch = vi.fn().mockResolvedValue({ 259 ok: true, 260 json: async () => ({ result: "success" }), 261 }); 262 vi.stubGlobal("fetch", mockFetch); 263 264 const result = await api.get<{ result: string }>("/test"); 265 expect(result).toEqual({ result: "success" }); 266 }); 267 }); 268 269 describe("uploadAttachment", () => { 270 let originalXMLHttpRequest: typeof XMLHttpRequest; 271 272 beforeEach(() => { 273 originalXMLHttpRequest = global.XMLHttpRequest; 274 }); 275 276 afterEach(() => { 277 global.XMLHttpRequest = originalXMLHttpRequest; 278 }); 279 280 it("rejects on non-2xx status", async () => { 281 const mockXhr = { 282 open: vi.fn(), 283 send: vi.fn(), 284 setRequestHeader: vi.fn(), 285 addEventListener: vi.fn((event: string, cb: () => void) => { 286 if (event === "load") { 287 // Store the callback to call later 288 (mockXhr as any)._loadCb = cb; 289 } 290 }), 291 upload: { 292 addEventListener: vi.fn(), 293 }, 294 status: 400, 295 responseText: '{"error":"bad"}', 296 withCredentials: false, 297 }; 298 global.XMLHttpRequest = vi.fn(() => mockXhr) as unknown as typeof XMLHttpRequest; 299 300 const file = new File(["test"], "test.txt", { type: "text/plain" }); 301 const promise = uploadAttachment(file, "session-1"); 302 303 // Trigger load event 304 (mockXhr as any)._loadCb(); 305 306 await expect(promise).rejects.toThrow(ApiError); 307 }); 308 309 it("rejects on network error", async () => { 310 const mockXhr = { 311 open: vi.fn(), 312 send: vi.fn(), 313 setRequestHeader: vi.fn(), 314 addEventListener: vi.fn((event: string, cb: () => void) => { 315 if (event === "error") { 316 (mockXhr as any)._errorCb = cb; 317 } 318 }), 319 upload: { 320 addEventListener: vi.fn(), 321 }, 322 withCredentials: false, 323 }; 324 global.XMLHttpRequest = vi.fn(() => mockXhr) as unknown as typeof XMLHttpRequest; 325 326 const file = new File(["test"], "test.txt", { type: "text/plain" }); 327 const promise = uploadAttachment(file, "session-1"); 328 329 (mockXhr as any)._errorCb(); 330 331 await expect(promise).rejects.toThrow("Network error during upload"); 332 }); 333 334 it("calls onProgress during upload", async () => { 335 let progressCb: ((e: ProgressEvent) => void) | undefined; 336 const mockXhr = { 337 open: vi.fn(), 338 send: vi.fn(), 339 setRequestHeader: vi.fn(), 340 addEventListener: vi.fn(), 341 upload: { 342 addEventListener: vi.fn((event: string, cb: (e: ProgressEvent) => void) => { 343 if (event === "progress") progressCb = cb; 344 }), 345 }, 346 withCredentials: false, 347 }; 348 global.XMLHttpRequest = vi.fn(() => mockXhr) as unknown as typeof XMLHttpRequest; 349 350 const onProgress = vi.fn(); 351 const file = new File(["test"], "test.txt", { type: "text/plain" }); 352 uploadAttachment(file, "session-1", onProgress); 353 354 // Simulate progress event 355 progressCb!({ lengthComputable: true, loaded: 50, total: 100 } as ProgressEvent); 356 357 expect(onProgress).toHaveBeenCalledWith(50); 358 }); 359 360 it("sets withCredentials to true", async () => { 361 const mockXhr = { 362 open: vi.fn(), 363 send: vi.fn(), 364 setRequestHeader: vi.fn(), 365 addEventListener: vi.fn(), 366 upload: { addEventListener: vi.fn() }, 367 withCredentials: false, 368 }; 369 global.XMLHttpRequest = vi.fn(() => mockXhr) as unknown as typeof XMLHttpRequest; 370 371 const file = new File(["test"], "test.txt", { type: "text/plain" }); 372 uploadAttachment(file, "session-1"); 373 374 expect(mockXhr.withCredentials).toBe(true); 375 }); 376 }); 377 378 import { afterEach } from "vitest";