diff --git a/apps/web/app/api/link/__tests__/route.test.ts b/apps/web/app/api/link/__tests__/route.test.ts index 9e3f3b309f..b5ecb7246d 100644 --- a/apps/web/app/api/link/__tests__/route.test.ts +++ b/apps/web/app/api/link/__tests__/route.test.ts @@ -1,5 +1,9 @@ import type { NextRequest } from "next/server"; import { describe, it, expect, vi, beforeEach } from "vitest"; +import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler"; +import type { Mock } from "vitest"; + +const mockConfirmHandler = confirmHandler as unknown as Mock; vi.mock("app/api/defaultResponderForAppDir", () => ({ defaultResponderForAppDir: @@ -36,44 +40,47 @@ vi.mock("@calcom/lib/crypto", () => ({ ), })); -vi.mock("@calcom/prisma", () => ({ - default: { +vi.mock("@calcom/prisma", () => { + const mockBookingFindUniqueOrThrow = vi.fn().mockResolvedValue({ + id: 1, + uid: "test-booking-uid", + recurringEventId: null, + }); + const mockUserFindUniqueOrThrow = vi.fn().mockResolvedValue({ + id: 1, + uuid: "user-uuid", + email: "test@example.com", + username: "testuser", + role: "USER", + destinationCalendar: null, + }); + const mockPrismaObj = { booking: { - findUniqueOrThrow: vi.fn().mockResolvedValue({ - id: 1, - uid: "test-booking-uid", - recurringEventId: null, - }), + findUniqueOrThrow: mockBookingFindUniqueOrThrow, }, user: { - findUniqueOrThrow: vi.fn().mockResolvedValue({ - id: 1, - locale: "en", - }), + findUniqueOrThrow: mockUserFindUniqueOrThrow, }, + }; + return { + default: mockPrismaObj, + prisma: mockPrismaObj, + }; +}); + +vi.mock("@calcom/trpc/server/routers/viewer/bookings/confirm.handler", () => ({ + confirmHandler: vi.fn(), +})); + +vi.mock("@calcom/lib/tracing/factory", () => ({ + distributedTracing: { + createTrace: vi.fn().mockReturnValue({}), }, })); -vi.mock("@calcom/trpc/server/createContext", () => ({ - createContext: vi.fn().mockResolvedValue({}), -})); - -vi.mock("@calcom/trpc/server/routers/viewer/bookings/_router", () => ({ - bookingsRouter: {}, -})); - -vi.mock("@calcom/trpc/server/trpc", () => ({ - createCallerFactory: vi.fn().mockReturnValue(() => ({ - confirm: vi.fn().mockResolvedValue({}), - })), -})); - -vi.mock("@lib/buildLegacyCtx", () => ({ - buildLegacyRequest: vi.fn().mockReturnValue({}), -})); - // Import after mocks are set up import { GET } from "../route"; +import prisma from "@calcom/prisma"; const createMockRequest = (url: string): NextRequest => { const urlObj = new URL(url); @@ -160,14 +167,11 @@ describe("link route", () => { }); describe("GET handler - error handling", () => { - it("should redirect with error message when TRPC throws an error", async () => { + it("should redirect with error message when confirmHandler throws a TRPCError", async () => { const { TRPCError } = await import("@trpc/server"); - // Mock createCallerFactory to throw a TRPCError - const { createCallerFactory } = await import("@calcom/trpc/server/trpc"); - vi.mocked(createCallerFactory).mockReturnValue(() => ({ - confirm: vi.fn().mockRejectedValue(new TRPCError({ code: "BAD_REQUEST", message: "Custom error" })), - })); + // Mock confirmHandler to throw a TRPCError + mockConfirmHandler.mockRejectedValueOnce(new TRPCError({ code: "BAD_REQUEST", message: "Custom error" })); const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token"; const req = createMockRequest(baseUrl); @@ -186,11 +190,8 @@ describe("link route", () => { it("should preserve origin in error redirect URL", async () => { const { TRPCError } = await import("@trpc/server"); - // Mock createCallerFactory to throw a TRPCError - const { createCallerFactory } = await import("@calcom/trpc/server/trpc"); - vi.mocked(createCallerFactory).mockReturnValue(() => ({ - confirm: vi.fn().mockRejectedValue(new TRPCError({ code: "INTERNAL_SERVER_ERROR" })), - })); + // Mock confirmHandler to throw a TRPCError + mockConfirmHandler.mockRejectedValueOnce(new TRPCError({ code: "INTERNAL_SERVER_ERROR" })); const baseUrl = "https://self-hosted.company.org/api/link?action=accept&token=encrypted-token"; const req = createMockRequest(baseUrl); diff --git a/apps/web/app/api/link/route.ts b/apps/web/app/api/link/route.ts index 36113b72bc..d3081896f0 100644 --- a/apps/web/app/api/link/route.ts +++ b/apps/web/app/api/link/route.ts @@ -1,19 +1,12 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; -import { cookies, headers } from "next/headers"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { z } from "zod"; import { symmetricDecrypt } from "@calcom/lib/crypto"; +import { distributedTracing } from "@calcom/lib/tracing/factory"; import prisma from "@calcom/prisma"; -import { UserPermissionRole } from "@calcom/prisma/enums"; -import { createContext } from "@calcom/trpc/server/createContext"; -import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router"; -import { createCallerFactory } from "@calcom/trpc/server/trpc"; -import type { UserProfile } from "@calcom/types/UserProfile"; - -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; - +import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler"; import { TRPCError } from "@trpc/server"; enum DirectAction { @@ -36,29 +29,6 @@ const decryptedSchema = z.object({ platformBookingUrl: z.string().optional(), }); -// Move the sessionGetter function outside the GET function -const createSessionGetter = (userId: number, userUuid: string) => async () => { - return { - user: { - id: userId, - uuid: userUuid, - username: "" /* Not used in this context */, - role: UserPermissionRole.USER, - /* Not used in this context */ - profile: { - id: null, - organizationId: null, - organization: null, - username: "", - upId: "", - } satisfies UserProfile, - }, - upId: "", - hasValidLicense: true, - expires: "" /* Not used in this context */, - }; -}; - async function handler(request: NextRequest) { const searchParams = request.nextUrl.searchParams; @@ -83,45 +53,51 @@ async function handler(request: NextRequest) { const user = await prisma.user.findUniqueOrThrow({ where: { id: userId }, + select: { + id: true, + uuid: true, + email: true, + username: true, + role: true, + destinationCalendar: true, + }, }); - // Use the factory function instead of declaring inside the block - const sessionGetter = createSessionGetter(userId, user.uuid); - try { - /** @see https://trpc.io/docs/server-side-calls */ - // Create a legacy request object for compatibility - const legacyReq = buildLegacyRequest(await headers(), await cookies()); - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Response is mocked as it's not used in this context - const res = {} as any; - - const ctx = await createContext({ req: legacyReq, res }, sessionGetter); - const createCaller = createCallerFactory(bookingsRouter); - const caller = createCaller({ - ...ctx, - req: legacyReq, - res, - user: { ...user, locale: user?.locale ?? "en" }, - }); - - await caller.confirm({ - bookingId: booking.id, - recurringEventId: booking.recurringEventId || undefined, - confirmed: action === DirectAction.ACCEPT, - reason, - platformClientParams: platformClientId - ? { - platformClientId, - platformRescheduleUrl, - platformCancelUrl, - platformBookingUrl, - } - : undefined, + await confirmHandler({ + ctx: { + user: { + id: user.id, + uuid: user.uuid, + email: user.email, + username: user.username ?? "", + role: user.role, + destinationCalendar: user.destinationCalendar ?? null, + }, + traceContext: distributedTracing.createTrace("confirm_booking_magic_link"), + }, + input: { + bookingId: booking.id, + recurringEventId: booking.recurringEventId || undefined, + confirmed: action === DirectAction.ACCEPT, + reason, + emailsEnabled: true, + platformClientParams: platformClientId + ? { + platformClientId, + platformRescheduleUrl, + platformCancelUrl, + platformBookingUrl, + } + : undefined, + }, }); } catch (e) { let message = "Error confirming booking"; if (e instanceof TRPCError) message = (e as TRPCError).message; - return NextResponse.redirect(new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, request.url)); + return NextResponse.redirect( + new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, request.url) + ); } return NextResponse.redirect(new URL(`/booking/${bookingUid}`, request.url)); diff --git a/apps/web/app/api/verify-booking-token/__tests__/route.test.ts b/apps/web/app/api/verify-booking-token/__tests__/route.test.ts index d97d34caf5..3ffe786a46 100644 --- a/apps/web/app/api/verify-booking-token/__tests__/route.test.ts +++ b/apps/web/app/api/verify-booking-token/__tests__/route.test.ts @@ -1,5 +1,8 @@ import type { NextRequest } from "next/server"; import { describe, it, expect, vi, beforeEach } from "vitest"; +import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler"; +import type { Mock } from "vitest"; +const mockConfirmHandler = confirmHandler as unknown as Mock; vi.mock("app/api/defaultResponderForAppDir", () => ({ defaultResponderForAppDir: @@ -8,10 +11,6 @@ vi.mock("app/api/defaultResponderForAppDir", () => ({ handler(req), })); -vi.mock("app/api/parseRequestData", () => ({ - parseRequestData: vi.fn().mockResolvedValue({}), -})); - vi.mock("next/headers", () => ({ headers: vi.fn().mockResolvedValue(new Headers()), cookies: vi.fn().mockResolvedValue({ getAll: () => [] }), @@ -34,50 +33,145 @@ vi.mock("next/server", () => ({ vi.mock("@calcom/prisma", () => ({ default: { booking: { - findUnique: vi.fn(), - update: vi.fn(), + findUnique: vi.fn().mockImplementation(({ where }: { where: { oneTimePassword: string } }) => { + const booking = Object.values(DB.bookings).find( + (booking) => booking.oneTimePassword === where.oneTimePassword + ); + return Promise.resolve(booking ?? null); + }), + update: vi + .fn() + .mockImplementation(({ where }: { where: { id: number }; data: { oneTimePassword: null } }) => { + const booking = DB.bookings[where.id]; + return Promise.resolve(booking ?? null); + }), }, user: { - findUniqueOrThrow: vi.fn(), + findUniqueOrThrow: vi.fn().mockImplementation(({ where }: { where: { id: number } }) => { + const user = Object.values(DB.users).find((user) => user.id === where.id); + return Promise.resolve(user ?? null); + }), }, }, })); -vi.mock("@calcom/trpc/server/createContext", () => ({ - createContext: vi.fn().mockResolvedValue({}), +vi.mock("@calcom/trpc/server/routers/viewer/bookings/confirm.handler", () => ({ + confirmHandler: vi.fn(), })); -vi.mock("@calcom/trpc/server/routers/viewer/bookings/_router", () => ({ - bookingsRouter: {}, +vi.mock("@calcom/lib/tracing/factory", () => ({ + distributedTracing: { + createTrace: vi.fn().mockReturnValue({}), + }, })); -vi.mock("@calcom/trpc/server/trpc", () => ({ - createCallerFactory: vi.fn().mockReturnValue(() => ({ - confirm: vi.fn().mockResolvedValue({}), - })), +// Store for mock request body - will be set by tests +let mockRequestBody: Record = {}; + +vi.mock("app/api/parseRequestData", () => ({ + parseRequestData: vi.fn().mockImplementation(() => Promise.resolve(mockRequestBody)), })); -vi.mock("@lib/buildLegacyCtx", () => ({ - buildLegacyRequest: vi.fn().mockReturnValue({}), -})); +function setMockRequestBody(body: Record) { + mockRequestBody = body; +} + +function expectErrorRedirect(res: Response, path: string, error: string) { + const location = res.headers.get("location"); + expect(location).toBeTruthy(); + const redirectUrl = new URL(location!); + expect(redirectUrl.pathname).toBe(path); + expect(redirectUrl.searchParams.get("error")).toBe(error); +} // Import after mocks are set up import { GET, POST } from "../route"; +const DB = { + bookings: {} as Record< + string, + { + id: number; + uid: string; + oneTimePassword: string; + recurringEventId?: string | null; + } + >, + users: {} as Record< + string, + { + id: number; + uuid: string; + email: string; + username: string; + role: string; + destinationCalendar: unknown | null; + } + >, +}; +function createMockBooking(overrides: { + id: number; + uid: string; + oneTimePassword: string; + recurringEventId?: string | null; +}) { + DB.bookings[overrides.uid] = { + id: overrides.id, + uid: overrides.uid, + oneTimePassword: overrides.oneTimePassword, + recurringEventId: overrides.recurringEventId ?? null, + }; +} -const createMockRequest = (url: string, method: "GET" | "POST" = "GET"): NextRequest => { +function createMockUser(overrides: { + id: number; + uuid: string; + email: string; + username: string; + role: string; + destinationCalendar: unknown | null; +}) { + DB.users[overrides.uuid] = { + id: overrides.id, + uuid: overrides.uuid, + email: overrides.email, + username: overrides.username ?? null, + role: overrides.role ?? "USER", + destinationCalendar: overrides.destinationCalendar ?? null, + }; +} + +const createMockRequest = ( + url: string, + method: "GET" | "POST" = "GET", + body?: Record +): NextRequest => { const urlObj = new URL(url); + const headers = new Headers(); + headers.set("content-type", "application/json"); return { method, url, nextUrl: { searchParams: urlObj.searchParams, }, + headers, + json: + method === "POST" + ? async () => { + process.stdout.write("json fn called: " + JSON.stringify(body)); + return Promise.resolve(body ?? {}); + } + : undefined, } as unknown as NextRequest; }; describe("verify-booking-token route", () => { beforeEach(() => { vi.clearAllMocks(); + // Reset mock body and DB + setMockRequestBody({}); + Object.keys(DB.bookings).forEach((key) => delete DB.bookings[key]); + Object.keys(DB.users).forEach((key) => delete DB.users[key]); }); describe("GET handler", () => { diff --git a/apps/web/app/api/verify-booking-token/route.ts b/apps/web/app/api/verify-booking-token/route.ts index c0401168b2..c4529fc8d4 100644 --- a/apps/web/app/api/verify-booking-token/route.ts +++ b/apps/web/app/api/verify-booking-token/route.ts @@ -1,19 +1,12 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; import { parseRequestData } from "app/api/parseRequestData"; -import { headers, cookies } from "next/headers"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { z } from "zod"; +import { distributedTracing } from "@calcom/lib/tracing/factory"; import prisma from "@calcom/prisma"; -import { UserPermissionRole } from "@calcom/prisma/enums"; -import { createContext } from "@calcom/trpc/server/createContext"; -import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router"; -import { createCallerFactory } from "@calcom/trpc/server/trpc"; -import type { UserProfile } from "@calcom/types/UserProfile"; - -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; - +import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler"; import { TRPCError } from "@trpc/server"; enum DirectAction { @@ -37,7 +30,10 @@ async function getHandler(request: NextRequest) { if (action === DirectAction.REJECT) { // Rejections should use POST method return NextResponse.redirect( - new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`, request.url) + new URL( + `/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`, + request.url + ) ); } @@ -89,55 +85,37 @@ async function handleBookingAction( const user = await prisma.user.findUniqueOrThrow({ where: { id: Number(userId) }, + select: { + id: true, + uuid: true, + email: true, + username: true, + role: true, + destinationCalendar: true, + }, }); - /** We shape the session as required by tRPC router */ - async function sessionGetter() { - return { - user: { - id: Number(userId), - uuid: user.uuid, - username: "" /* Not used in this context */, - role: UserPermissionRole.USER, - /* Not used in this context */ - profile: { - id: null, - organizationId: null, - organization: null, - username: "", - upId: "", - } satisfies UserProfile, - }, - upId: "", - hasValidLicense: true, - expires: "" /* Not used in this context */, - }; - } - try { - /** @see https://trpc.io/docs/server-side-calls */ - const createCaller = createCallerFactory(bookingsRouter); - - // Use buildLegacyRequest to create a request object compatible with Pages Router - - const legacyReq = request ? buildLegacyRequest(await headers(), await cookies()) : ({} as any); - - const res = {} as any; - - const ctx = await createContext({ req: legacyReq, res }, sessionGetter); - const caller = createCaller({ - ...ctx, - req: legacyReq, - res, - user: { ...user, locale: user?.locale ?? "en" }, - }); - - await caller.confirm({ - bookingId: booking.id, - recurringEventId: booking.recurringEventId || undefined, - confirmed: action === DirectAction.ACCEPT, - /** Ignored reason input unless we're rejecting */ - reason: action === DirectAction.REJECT ? reason : undefined, + await confirmHandler({ + ctx: { + user: { + id: user.id, + uuid: user.uuid, + email: user.email, + username: user.username ?? "", + role: user.role, + destinationCalendar: user.destinationCalendar ?? null, + }, + traceContext: distributedTracing.createTrace("confirm_booking_verify_token"), + }, + input: { + bookingId: booking.id, + recurringEventId: booking.recurringEventId || undefined, + confirmed: action === DirectAction.ACCEPT, + /** Ignored reason input unless we're rejecting */ + reason: action === DirectAction.REJECT ? reason : undefined, + emailsEnabled: true, + }, }); } catch (e) { let message = "Error confirming booking";