feat: correct error codes in responses for /social/og/image (#22015)

* fix error codes in og/image route

* test

* simplify
This commit is contained in:
Benny Joo
2025-06-25 11:24:34 -03:00
committed by GitHub
parent 6ed197d35c
commit c3666dd064
2 changed files with 229 additions and 66 deletions
@@ -0,0 +1,110 @@
import { NextRequest } from "next/server";
import { describe, expect, test, vi, beforeEach } from "vitest";
import { GET } from "../route";
vi.mock("next/og", () => ({
ImageResponse: vi.fn().mockImplementation(() => ({
body: new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3, 4]));
controller.close();
},
}),
})),
}));
vi.mock("@calcom/lib/OgImages", () => ({
Meeting: vi.fn(() => null),
App: vi.fn(() => null),
Generic: vi.fn(() => null),
}));
vi.mock("@calcom/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
}));
global.fetch = vi.fn();
function createNextRequest(url: string): NextRequest {
const request = new Request(url, { method: "GET" });
return new NextRequest(request);
}
describe("GET /api/social/og/image", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(global.fetch).mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
} as any);
});
describe("Validation errors (400 Bad Request)", () => {
test("meeting type: returns 400 when required parameters are missing", async () => {
const request = createNextRequest("http://example.com/api/social/og/image?type=meeting");
const response = await GET(request);
expect(response.status).toBe(400);
expect(response.headers.get("Content-Type")).toBe("application/json");
const errorData = await response.json();
expect(errorData.error).toBe("Invalid parameters for meeting image");
expect(errorData.message).toBe(
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage"
);
});
test("app type: returns 400 when required parameters are missing", async () => {
const request = createNextRequest("http://example.com/api/social/og/image?type=app");
const response = await GET(request);
expect(response.status).toBe(400);
const errorData = await response.json();
expect(errorData.error).toBe("Invalid parameters for app image");
expect(errorData.message).toBe("Required parameters: name, description, slug");
});
test("generic type: returns 400 when required parameters are missing", async () => {
const request = createNextRequest("http://example.com/api/social/og/image?type=generic");
const response = await GET(request);
expect(response.status).toBe(400);
const errorData = await response.json();
expect(errorData.error).toBe("Invalid parameters for generic image");
expect(errorData.message).toBe("Required parameters: title, description");
});
});
describe("Not found errors (404 Not Found)", () => {
test("returns 404 when no type parameter is provided", async () => {
const request = createNextRequest("http://example.com/api/social/og/image");
const response = await GET(request);
expect(response.status).toBe(404);
expect(await response.text()).toBe("What you're looking for is not here..");
});
test("returns 404 when invalid type parameter is provided", async () => {
const request = createNextRequest("http://example.com/api/social/og/image?type=invalid");
const response = await GET(request);
expect(response.status).toBe(404);
expect(await response.text()).toBe("What you're looking for is not here..");
});
});
describe("Server errors (500 Internal Server Error)", () => {
test("returns 500 when font loading fails", async () => {
vi.mocked(global.fetch).mockRejectedValue(new Error("Font loading failed"));
const request = createNextRequest(
"http://example.com/api/social/og/image?type=meeting&title=Test&meetingProfileName=John"
);
const response = await GET(request);
expect(response.status).toBe(500);
expect(await response.text()).toBe("Internal server error");
});
});
});
+119 -66
View File
@@ -1,7 +1,7 @@
import { ImageResponse } from "next/og";
import type { NextRequest } from "next/server";
import type { SatoriOptions } from "satori";
import { z } from "zod";
import { z, ZodError } from "zod";
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -34,75 +34,128 @@ async function handler(req: NextRequest) {
const { searchParams } = req.nextUrl;
const imageType = searchParams.get("type");
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
]);
const ogConfig = {
width: 1200,
height: 630,
fonts: [
{ name: "inter", data: interFontData, weight: 400 },
{ name: "inter", data: interFontMediumData, weight: 500 },
{ name: "cal", data: calFontData, weight: 400 },
{ name: "cal", data: calFontData, weight: 600 },
] as SatoriOptions["fonts"],
};
try {
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
]);
const ogConfig = {
width: 1200,
height: 630,
fonts: [
{ name: "inter", data: interFontData, weight: 400 },
{ name: "inter", data: interFontMediumData, weight: 500 },
{ name: "cal", data: calFontData, weight: 400 },
{ name: "cal", data: calFontData, weight: 600 },
] as SatoriOptions["fonts"],
};
switch (imageType) {
case "meeting": {
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
names: searchParams.getAll("names"),
usernames: searchParams.getAll("usernames"),
title: searchParams.get("title"),
meetingProfileName: searchParams.get("meetingProfileName"),
meetingImage: searchParams.get("meetingImage"),
imageType,
});
switch (imageType) {
case "meeting": {
try {
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
names: searchParams.getAll("names"),
usernames: searchParams.getAll("usernames"),
title: searchParams.get("title"),
meetingProfileName: searchParams.get("meetingProfileName"),
meetingImage: searchParams.get("meetingImage"),
imageType,
});
const img = new ImageResponse(
(
<Meeting
title={title}
profile={{ name: meetingProfileName, image: meetingImage }}
users={names.map((name, index) => ({ name, username: usernames[index] }))}
/>
),
ogConfig
);
const img = new ImageResponse(
(
<Meeting
title={title}
profile={{ name: meetingProfileName, image: meetingImage }}
users={names.map((name, index) => ({ name, username: usernames[index] }))}
/>
),
ogConfig
);
return new Response(img.body, {
status: 200,
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
});
return new Response(img.body, {
status: 200,
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
});
} catch (error) {
if (error instanceof ZodError) {
return new Response(
JSON.stringify({
error: "Invalid parameters for meeting image",
message:
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
throw error;
}
}
case "app": {
try {
const { name, description, slug } = appSchema.parse({
name: searchParams.get("name"),
description: searchParams.get("description"),
slug: searchParams.get("slug"),
imageType,
});
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
} catch (error) {
if (error instanceof ZodError) {
return new Response(
JSON.stringify({
error: "Invalid parameters for app image",
message: "Required parameters: name, description, slug",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
throw error;
}
}
case "generic": {
try {
const { title, description } = genericSchema.parse({
title: searchParams.get("title"),
description: searchParams.get("description"),
imageType,
});
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
} catch (error) {
if (error instanceof ZodError) {
return new Response(
JSON.stringify({
error: "Invalid parameters for generic image",
message: "Required parameters: title, description",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
throw error;
}
}
default:
return new Response("What you're looking for is not here..", { status: 404 });
}
case "app": {
const { name, description, slug } = appSchema.parse({
name: searchParams.get("name"),
description: searchParams.get("description"),
slug: searchParams.get("slug"),
imageType,
});
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
}
case "generic": {
const { title, description } = genericSchema.parse({
title: searchParams.get("title"),
description: searchParams.get("description"),
imageType,
});
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
}
default:
return new Response("What you're looking for is not here..", { status: 404 });
} catch (error) {
return new Response("Internal server error", { status: 500 });
}
}