profileShare

rasmusjy / tasteprint

Read-only snapshot

No repository description.

main default branch 169 files Expires Sep 13, 2026, 9:06 AM
api.test.ts 2,932 bytes
1 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2 import { ApiError, api, clearSessionToken, getSessionToken, jsonBody, setSessionToken } from "./api";
3
4 describe("API client", () => {
5 beforeEach(() => {
6 localStorage.clear();
7 });
8
9 afterEach(() => {
10 vi.unstubAllGlobals();
11 });
12
13 it("adds the bearer token and JSON headers", async () => {
14 setSessionToken("secret-session");
15 const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
16 status: 200,
17 headers: { "Content-Type": "application/json" }
18 }));
19 vi.stubGlobal("fetch", fetchMock);
20
21 await expect(api<{ ok: boolean }>("/api/test", {
22 method: "POST",
23 ...jsonBody({ name: "Ramen" })
24 })).resolves.toEqual({ ok: true });
25
26 const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
27 const headers = request.headers as Headers;
28 expect(headers.get("Authorization")).toBe("Bearer secret-session");
29 expect(headers.get("Content-Type")).toBe("application/json");
30 expect(request.body).toBe('{"name":"Ramen"}');
31 });
32
33 it("preserves form data without adding a JSON content type", async () => {
34 const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ url: "/uploads/a.png" }), {
35 status: 201,
36 headers: { "Content-Type": "application/json" }
37 }));
38 vi.stubGlobal("fetch", fetchMock);
39 const form = new FormData();
40 form.append("file", new Blob(["image"]), "meal.png");
41
42 await api("/api/v1/media", { method: "POST", body: form });
43
44 const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
45 expect((request.headers as Headers).has("Content-Type")).toBe(false);
46 });
47
48 it("turns problem details into an ApiError", async () => {
49 vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
50 title: "Validation failed",
51 detail: "Check the highlighted fields.",
52 errors: { city: "must not be blank" }
53 }), { status: 400, headers: { "Content-Type": "application/problem+json" } })));
54
55 const result = api("/api/test");
56 await expect(result).rejects.toMatchObject({
57 name: "ApiError",
58 status: 400,
59 message: "Check the highlighted fields.",
60 errors: { city: "must not be blank" }
61 } satisfies Partial<ApiError>);
62 });
63
64 it("clears an expired session after a 401 response", async () => {
65 setSessionToken("expired");
66 vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("not-json", { status: 401 })));
67
68 await expect(api("/api/private")).rejects.toBeInstanceOf(ApiError);
69 expect(getSessionToken()).toBeNull();
70 });
71
72 it("handles an empty success response", async () => {
73 vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 })));
74 await expect(api<void>("/api/delete", { method: "DELETE" })).resolves.toBeUndefined();
75 clearSessionToken();
76 });
77 });
78