theo-agent-dashboard

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

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";