refactor: Use confirmHandler directly in link and verify-booking-token routes (#26567)
Refactors the link and verify-booking-token API routes to call confirmHandler directly instead of using the tRPC caller pattern. This simplifies the code by removing the need for session getter, legacy request building, and context creation. Changes: - apps/web/app/api/link/route.ts: Use confirmHandler directly with ctx and input - apps/web/app/api/verify-booking-token/route.ts: Use confirmHandler directly with ctx and input - Updated tests to mock confirmHandler instead of tRPC caller Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f5d345b133
commit
2f92563606
4 files changed
+228
-179
No files matched your search
@@ -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<typeof confirmHandler>;
|
||||
|
||||
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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<typeof confirmHandler>;
|
||||
|
||||
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<string, unknown> = {};
|
||||
|
||||
vi.mock("app/api/parseRequestData", () => ({
|
||||
parseRequestData: vi.fn().mockImplementation(() => Promise.resolve(mockRequestBody)),
|
||||
}));
|
||||
|
||||
vi.mock("@lib/buildLegacyCtx", () => ({
|
||||
buildLegacyRequest: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
function setMockRequestBody(body: Record<string, unknown>) {
|
||||
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<string, unknown>
|
||||
): 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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user