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.
This commit is contained in:
@@ -63,13 +63,15 @@ export async function api<T = unknown>(
|
|||||||
options: { method?: string; body?: unknown; retry?: boolean } = {},
|
options: { method?: string; body?: unknown; retry?: boolean } = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { accessToken } = useAuth.getState();
|
const { accessToken } = useAuth.getState();
|
||||||
|
const method = options.method ?? "GET";
|
||||||
|
const hasBodySlot = method !== "GET" && method !== "HEAD";
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
method: options.method ?? "GET",
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
|
...(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) {
|
if (res.status === 401 && options.retry !== false) {
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user