diff --git a/apps/web/app/api/social/og/image/__tests__/route.test.ts b/apps/web/app/api/social/og/image/__tests__/route.test.ts new file mode 100644 index 0000000000..6476e6dfa3 --- /dev/null +++ b/apps/web/app/api/social/og/image/__tests__/route.test.ts @@ -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"); + }); + }); +}); diff --git a/apps/web/app/api/social/og/image/route.tsx b/apps/web/app/api/social/og/image/route.tsx index 6e100d795b..710585eec4 100644 --- a/apps/web/app/api/social/og/image/route.tsx +++ b/apps/web/app/api/social/og/image/route.tsx @@ -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( - ( - ({ name, username: usernames[index] }))} - /> - ), - ogConfig - ); + const img = new ImageResponse( + ( + ({ 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(, 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(, 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(, 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(, 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 }); } }