Skip to content

Commit c3666dd

Browse files
authored
feat: correct error codes in responses for /social/og/image (calcom#22015)
* fix error codes in og/image route * test * simplify
1 parent 6ed197d commit c3666dd

2 files changed

Lines changed: 232 additions & 69 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { NextRequest } from "next/server";
2+
import { describe, expect, test, vi, beforeEach } from "vitest";
3+
4+
import { GET } from "../route";
5+
6+
vi.mock("next/og", () => ({
7+
ImageResponse: vi.fn().mockImplementation(() => ({
8+
body: new ReadableStream({
9+
start(controller) {
10+
controller.enqueue(new Uint8Array([1, 2, 3, 4]));
11+
controller.close();
12+
},
13+
}),
14+
})),
15+
}));
16+
17+
vi.mock("@calcom/lib/OgImages", () => ({
18+
Meeting: vi.fn(() => null),
19+
App: vi.fn(() => null),
20+
Generic: vi.fn(() => null),
21+
}));
22+
23+
vi.mock("@calcom/lib/constants", () => ({
24+
WEBAPP_URL: "http://localhost:3000",
25+
}));
26+
27+
global.fetch = vi.fn();
28+
29+
function createNextRequest(url: string): NextRequest {
30+
const request = new Request(url, { method: "GET" });
31+
return new NextRequest(request);
32+
}
33+
34+
describe("GET /api/social/og/image", () => {
35+
beforeEach(() => {
36+
vi.resetAllMocks();
37+
38+
vi.mocked(global.fetch).mockResolvedValue({
39+
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
40+
} as any);
41+
});
42+
43+
describe("Validation errors (400 Bad Request)", () => {
44+
test("meeting type: returns 400 when required parameters are missing", async () => {
45+
const request = createNextRequest("http://example.com/api/social/og/image?type=meeting");
46+
const response = await GET(request);
47+
48+
expect(response.status).toBe(400);
49+
expect(response.headers.get("Content-Type")).toBe("application/json");
50+
51+
const errorData = await response.json();
52+
expect(errorData.error).toBe("Invalid parameters for meeting image");
53+
expect(errorData.message).toBe(
54+
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage"
55+
);
56+
});
57+
58+
test("app type: returns 400 when required parameters are missing", async () => {
59+
const request = createNextRequest("http://example.com/api/social/og/image?type=app");
60+
const response = await GET(request);
61+
62+
expect(response.status).toBe(400);
63+
const errorData = await response.json();
64+
expect(errorData.error).toBe("Invalid parameters for app image");
65+
expect(errorData.message).toBe("Required parameters: name, description, slug");
66+
});
67+
68+
test("generic type: returns 400 when required parameters are missing", async () => {
69+
const request = createNextRequest("http://example.com/api/social/og/image?type=generic");
70+
const response = await GET(request);
71+
72+
expect(response.status).toBe(400);
73+
const errorData = await response.json();
74+
expect(errorData.error).toBe("Invalid parameters for generic image");
75+
expect(errorData.message).toBe("Required parameters: title, description");
76+
});
77+
});
78+
79+
describe("Not found errors (404 Not Found)", () => {
80+
test("returns 404 when no type parameter is provided", async () => {
81+
const request = createNextRequest("http://example.com/api/social/og/image");
82+
const response = await GET(request);
83+
84+
expect(response.status).toBe(404);
85+
expect(await response.text()).toBe("What you're looking for is not here..");
86+
});
87+
88+
test("returns 404 when invalid type parameter is provided", async () => {
89+
const request = createNextRequest("http://example.com/api/social/og/image?type=invalid");
90+
const response = await GET(request);
91+
92+
expect(response.status).toBe(404);
93+
expect(await response.text()).toBe("What you're looking for is not here..");
94+
});
95+
});
96+
97+
describe("Server errors (500 Internal Server Error)", () => {
98+
test("returns 500 when font loading fails", async () => {
99+
vi.mocked(global.fetch).mockRejectedValue(new Error("Font loading failed"));
100+
101+
const request = createNextRequest(
102+
"http://example.com/api/social/og/image?type=meeting&title=Test&meetingProfileName=John"
103+
);
104+
const response = await GET(request);
105+
106+
expect(response.status).toBe(500);
107+
expect(await response.text()).toBe("Internal server error");
108+
});
109+
});
110+
});

apps/web/app/api/social/og/image/route.tsx

Lines changed: 122 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ImageResponse } from "next/og";
22
import type { NextRequest } from "next/server";
33
import type { SatoriOptions } from "satori";
4-
import { z } from "zod";
4+
import { z, ZodError } from "zod";
55

66
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
77
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -34,75 +34,128 @@ async function handler(req: NextRequest) {
3434
const { searchParams } = req.nextUrl;
3535
const imageType = searchParams.get("type");
3636

37-
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
38-
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
39-
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
40-
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
41-
]);
42-
const ogConfig = {
43-
width: 1200,
44-
height: 630,
45-
fonts: [
46-
{ name: "inter", data: interFontData, weight: 400 },
47-
{ name: "inter", data: interFontMediumData, weight: 500 },
48-
{ name: "cal", data: calFontData, weight: 400 },
49-
{ name: "cal", data: calFontData, weight: 600 },
50-
] as SatoriOptions["fonts"],
51-
};
52-
53-
switch (imageType) {
54-
case "meeting": {
55-
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
56-
names: searchParams.getAll("names"),
57-
usernames: searchParams.getAll("usernames"),
58-
title: searchParams.get("title"),
59-
meetingProfileName: searchParams.get("meetingProfileName"),
60-
meetingImage: searchParams.get("meetingImage"),
61-
imageType,
62-
});
63-
64-
const img = new ImageResponse(
65-
(
66-
<Meeting
67-
title={title}
68-
profile={{ name: meetingProfileName, image: meetingImage }}
69-
users={names.map((name, index) => ({ name, username: usernames[index] }))}
70-
/>
71-
),
72-
ogConfig
73-
);
74-
75-
return new Response(img.body, {
76-
status: 200,
77-
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
78-
});
37+
try {
38+
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
39+
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
40+
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
41+
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
42+
]);
43+
const ogConfig = {
44+
width: 1200,
45+
height: 630,
46+
fonts: [
47+
{ name: "inter", data: interFontData, weight: 400 },
48+
{ name: "inter", data: interFontMediumData, weight: 500 },
49+
{ name: "cal", data: calFontData, weight: 400 },
50+
{ name: "cal", data: calFontData, weight: 600 },
51+
] as SatoriOptions["fonts"],
52+
};
53+
54+
switch (imageType) {
55+
case "meeting": {
56+
try {
57+
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
58+
names: searchParams.getAll("names"),
59+
usernames: searchParams.getAll("usernames"),
60+
title: searchParams.get("title"),
61+
meetingProfileName: searchParams.get("meetingProfileName"),
62+
meetingImage: searchParams.get("meetingImage"),
63+
imageType,
64+
});
65+
66+
const img = new ImageResponse(
67+
(
68+
<Meeting
69+
title={title}
70+
profile={{ name: meetingProfileName, image: meetingImage }}
71+
users={names.map((name, index) => ({ name, username: usernames[index] }))}
72+
/>
73+
),
74+
ogConfig
75+
);
76+
77+
return new Response(img.body, {
78+
status: 200,
79+
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
80+
});
81+
} catch (error) {
82+
if (error instanceof ZodError) {
83+
return new Response(
84+
JSON.stringify({
85+
error: "Invalid parameters for meeting image",
86+
message:
87+
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage",
88+
}),
89+
{
90+
status: 400,
91+
headers: { "Content-Type": "application/json" },
92+
}
93+
);
94+
}
95+
throw error;
96+
}
97+
}
98+
case "app": {
99+
try {
100+
const { name, description, slug } = appSchema.parse({
101+
name: searchParams.get("name"),
102+
description: searchParams.get("description"),
103+
slug: searchParams.get("slug"),
104+
imageType,
105+
});
106+
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
107+
108+
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
109+
} catch (error) {
110+
if (error instanceof ZodError) {
111+
return new Response(
112+
JSON.stringify({
113+
error: "Invalid parameters for app image",
114+
message: "Required parameters: name, description, slug",
115+
}),
116+
{
117+
status: 400,
118+
headers: { "Content-Type": "application/json" },
119+
}
120+
);
121+
}
122+
throw error;
123+
}
124+
}
125+
126+
case "generic": {
127+
try {
128+
const { title, description } = genericSchema.parse({
129+
title: searchParams.get("title"),
130+
description: searchParams.get("description"),
131+
imageType,
132+
});
133+
134+
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
135+
136+
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
137+
} catch (error) {
138+
if (error instanceof ZodError) {
139+
return new Response(
140+
JSON.stringify({
141+
error: "Invalid parameters for generic image",
142+
message: "Required parameters: title, description",
143+
}),
144+
{
145+
status: 400,
146+
headers: { "Content-Type": "application/json" },
147+
}
148+
);
149+
}
150+
throw error;
151+
}
152+
}
153+
154+
default:
155+
return new Response("What you're looking for is not here..", { status: 404 });
79156
}
80-
case "app": {
81-
const { name, description, slug } = appSchema.parse({
82-
name: searchParams.get("name"),
83-
description: searchParams.get("description"),
84-
slug: searchParams.get("slug"),
85-
imageType,
86-
});
87-
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
88-
89-
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
90-
}
91-
92-
case "generic": {
93-
const { title, description } = genericSchema.parse({
94-
title: searchParams.get("title"),
95-
description: searchParams.get("description"),
96-
imageType,
97-
});
98-
99-
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
100-
101-
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
102-
}
103-
104-
default:
105-
return new Response("What you're looking for is not here..", { status: 404 });
157+
} catch (error) {
158+
return new Response("Internal server error", { status: 500 });
106159
}
107160
}
108161

0 commit comments

Comments
 (0)