diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index 4ab2d8d..77240a2 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -63,13 +63,15 @@ export async function api( options: { method?: string; body?: unknown; retry?: boolean } = {}, ): Promise { const { accessToken } = useAuth.getState(); + const method = options.method ?? "GET"; + const hasBodySlot = method !== "GET" && method !== "HEAD"; const res = await fetch(`${API_BASE}${path}`, { - method: options.method ?? "GET", + method, headers: { "content-type": "application/json", ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}), }, - body: options.body != null ? JSON.stringify(options.body) : undefined, + body: hasBodySlot ? JSON.stringify(options.body ?? {}) : undefined, }); if (res.status === 401 && options.retry !== false) { diff --git a/apps/mobile/test/api.test.ts b/apps/mobile/test/api.test.ts new file mode 100644 index 0000000..62b859d --- /dev/null +++ b/apps/mobile/test/api.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +vi.mock("expo-constants", () => ({ + default: { + expoConfig: { + extra: { apiBaseUrl: "http://test.example" }, + }, + }, +})); + +vi.mock("../src/lib/auth", () => ({ + useAuth: { + getState: () => ({ accessToken: "test-token" }), + }, +})); + +import { api } from "../src/lib/api"; + +describe("api() helper", () => { + it("GET without body sends no body", async () => { + fetchMock.mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ ok: true }), + } as Response); + + await api("/v1/scans/123/start", { method: "GET" }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://test.example/v1/scans/123/start", + expect.objectContaining({ method: "GET", body: undefined }), + ); + }); + + it("POST without body sends '{}'", async () => { + fetchMock.mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ ok: true }), + } as Response); + + await api("/v1/scans/123/start", { method: "POST" }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://test.example/v1/scans/123/start", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "content-type": "application/json" }), + body: "{}", + }), + ); + }); + + it("POST with body stringifies the body", async () => { + fetchMock.mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ ok: true }), + } as Response); + + await api("/v1/scans/123/start", { method: "POST", body: { foo: "bar" } }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://test.example/v1/scans/123/start", + expect.objectContaining({ body: '{"foo":"bar"}' }), + ); + }); + + it("PUT without body sends '{}'", async () => { + fetchMock.mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ ok: true }), + } as Response); + + await api("/v1/items/abc", { method: "PUT" }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://test.example/v1/items/abc", + expect.objectContaining({ method: "PUT", body: "{}" }), + ); + }); + + it("HEAD without body sends no body", async () => { + fetchMock.mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ ok: true }), + } as Response); + + await api("/v1/health", { method: "HEAD" }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://test.example/v1/health", + expect.objectContaining({ method: "HEAD", body: undefined }), + ); + }); +});