Skip to content

Commit 110f688

Browse files
fix(api/offers): return Content-Type: application/json
The GET /api/offers route at packages/web/src/app/api/(server)/offers/route.ts returned a JSON body but the response was served with Content-Type: text/plain;charset=UTF-8 because new Response(JSON.stringify(offers)) does not set a content type. Node.js / Next.js default the content type to text/plain for a string body, even when the body is JSON. This is the only public API route in packages/web/src/app/api/(server)/ that uses new Response(JSON.stringify(...)) without setting Content-Type. Every other public JSON route — /api/health, /api/version, /api/blame, /api/source, /api/connections, /api/ee/audit, /api/ee/scoped_access_token — uses Response.json(...) or sets the header explicitly. The OpenAPI spec for /api/offers already declares the response as application/json, so the actual response was violating the documented contract. Strict API clients (CLI tools, OpenAPI-generated SDKs, third-party integrations) inspect Content-Type before parsing. A client that sees text/plain;charset=UTF-8 will refuse to parse the body even though it is valid JSON. The in-app getOffers client happens to work because response.json() ignores Content-Type, but that is a coincidence of the Web fetch API, not because the response is correct. Fix: add 'Content-Type': 'application/json' to the response headers. One-line change, body unchanged, no client broken. Also added route.test.ts with 3 vitest cases (status 200 + JSON body, Content-Type: application/json, Cache-Control header preserved). The OTel suite-load error and posthog import are mocked the same way other tests in this repo handle them. Fixes #1595
1 parent 4d21e8c commit 110f688

2 files changed

Lines changed: 73 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { NextRequest } from "next/server";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
4+
const mocks = vi.hoisted(() => ({
5+
env: {
6+
SOURCEBOT_INSTALL_ID: "test-install-id",
7+
},
8+
offers: [
9+
{ id: "team-monthly", name: "Team", price: { monthly: 100, yearly: 1000 } },
10+
{ id: "enterprise-monthly", name: "Enterprise", price: { monthly: 500, yearly: 5000 } },
11+
] as unknown,
12+
}));
13+
14+
vi.mock("@sourcebot/shared", () => ({
15+
env: mocks.env,
16+
createLogger: () => ({
17+
info: vi.fn(),
18+
warn: vi.fn(),
19+
error: vi.fn(),
20+
debug: vi.fn(),
21+
}),
22+
}));
23+
24+
vi.mock("@/features/billing/client", () => ({
25+
client: {
26+
offers: vi.fn().mockResolvedValue(mocks.offers),
27+
},
28+
}));
29+
30+
vi.mock("@opentelemetry/sdk-trace-base", () => ({
31+
getEnv: () => ({}),
32+
TraceIdRatioBasedSampler: vi.fn(),
33+
ParentBasedSampler: vi.fn(),
34+
AlwaysOnSampler: vi.fn(),
35+
AlwaysOffSampler: vi.fn(),
36+
}));
37+
38+
vi.mock("@/lib/posthog", () => ({
39+
captureEvent: vi.fn(),
40+
}));
41+
42+
import { GET } from "./route";
43+
44+
const makeRequest = () => new NextRequest("https://example.com/api/offers");
45+
46+
describe("GET /api/offers", () => {
47+
afterEach(() => {
48+
vi.clearAllMocks();
49+
});
50+
51+
it("returns a 200 with the offers as JSON", async () => {
52+
const response = await GET(makeRequest(), {});
53+
expect(response.status).toBe(200);
54+
55+
const body = await response.json();
56+
expect(body).toEqual(mocks.offers);
57+
});
58+
59+
it("sets Content-Type: application/json", async () => {
60+
const response = await GET(makeRequest(), {});
61+
// Next.js / Web Headers are case-insensitive, but be defensive
62+
const contentType = response.headers.get("Content-Type") ?? response.headers.get("content-type") ?? "";
63+
expect(contentType).toContain("application/json");
64+
});
65+
66+
it("preserves the public cache-control header", async () => {
67+
const response = await GET(makeRequest(), {});
68+
const cacheControl = response.headers.get("Cache-Control") ?? "";
69+
expect(cacheControl).toContain("public");
70+
expect(cacheControl).toContain("max-age=300");
71+
});
72+
});

packages/web/src/app/api/(server)/offers/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const GET = apiHandler(async () => {
1010

1111
return new Response(JSON.stringify(offers), {
1212
headers: {
13+
'Content-Type': 'application/json',
1314
'Cache-Control': 'public, max-age=300'
1415
}
1516
});

0 commit comments

Comments
 (0)