From 0078084a2ba253b4b0e1f8f22e8b38dde998b54c Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Fri, 7 Aug 2026 15:39:46 +0700 Subject: [PATCH] field-fix: POST/PUT/PATCH without body sends '{}' in mobile api helper - api() in apps/mobile/src/lib/api.ts now sends JSON.stringify({}) for methods with a body slot when no body is provided, instead of sending undefined with content-type: application/json. - Fixes scan start crash (POST /v1/scans/:id/start) and latent resend-verification crash (POST /v1/auth/resend-verification). - Server strictness left intact; uploadImage unchanged. - Added unit tests for GET/HEAD/POST/PUT body behaviour. --- apps/mobile/src/lib/api.ts | 6 ++- apps/mobile/test/api.test.ts | 101 +++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/test/api.test.ts 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 }), + ); + }); +});