Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions apps/web/lib/reschedule/[uid]/getServerSideProps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import type { GetServerSidePropsContext } from "next";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockGetServerSession = vi.fn();
const mockBuildEventUrlFromBooking = vi.fn();
const mockDetermineReschedulePreventionRedirect = vi.fn();
const mockMaybeGetBookingUidFromSeat = vi.fn();
const mockBookingFindUnique = vi.fn();
const mockEnrichUserWithItsProfile = vi.fn();

vi.mock("@calcom/features/auth/lib/getServerSession", () => ({
getServerSession: mockGetServerSession,
}));

vi.mock("@calcom/features/bookings/lib/buildEventUrlFromBooking", () => ({
buildEventUrlFromBooking: mockBuildEventUrlFromBooking,
}));

vi.mock("@calcom/features/bookings/lib/reschedule/determineReschedulePreventionRedirect", () => ({
determineReschedulePreventionRedirect: mockDetermineReschedulePreventionRedirect,
}));

vi.mock("@calcom/lib/server/maybeGetBookingUidFromSeat", () => ({
maybeGetBookingUidFromSeat: mockMaybeGetBookingUidFromSeat,
}));

vi.mock("@calcom/features/users/repositories/UserRepository", () => ({
UserRepository: class {
enrichUserWithItsProfile = mockEnrichUserWithItsProfile;
},
}));

vi.mock("@calcom/prisma", () => ({
__esModule: true,
bookingMinimalSelect: {},
default: {
booking: {
findUnique: mockBookingFindUnique,
},
},
}));

function createContext(query: Record<string, string | undefined>): GetServerSidePropsContext {
return {
req: {} as GetServerSidePropsContext["req"],
res: {} as GetServerSidePropsContext["res"],
resolvedUrl: "/reschedule/booking-uid",
query,
} as unknown as GetServerSidePropsContext;
}

describe("reschedule/[uid] getServerSideProps", () => {
beforeEach(() => {
vi.clearAllMocks();

mockGetServerSession.mockResolvedValue({ user: { id: 1, email: "owner@example.com" } });
mockBuildEventUrlFromBooking.mockResolvedValue("/john/doe-event");
mockDetermineReschedulePreventionRedirect.mockReturnValue(null);
mockMaybeGetBookingUidFromSeat.mockResolvedValue({
uid: "booking-uid",
seatReferenceUid: null,
bookingSeat: null,
});
mockEnrichUserWithItsProfile.mockResolvedValue(null);
});

it("rescheduling a 60-minute dynamic booking preserves duration=60", async () => {
mockBookingFindUnique.mockResolvedValue({
uid: "booking-uid",
userId: 1,
responses: {},
startTime: new Date("2026-01-10T10:00:00.000Z"),
endTime: new Date("2026-01-10T11:00:00.000Z"),
dynamicEventSlugRef: "dynamic-event",
dynamicGroupSlugRef: null,
user: null,
status: "ACCEPTED",
eventType: {
users: [{ username: "john" }],
slug: "doe-event",
allowReschedulingPastBookings: false,
disableRescheduling: false,
allowReschedulingCancelledBookings: false,
minimumRescheduleNotice: null,
seatsPerTimeSlot: null,
userId: 1,
owner: { id: 1 },
hosts: [{ user: { id: 1 } }],
team: null,
},
});

const { getServerSideProps } = await import("./getServerSideProps");
const result = await getServerSideProps(createContext({ uid: "booking-uid" }));

if (!("redirect" in result) || !result.redirect) throw new Error("Expected redirect result");
const search = new URLSearchParams(result.redirect.destination.split("?")[1]);

expect(search.get("rescheduleUid")).toBe("booking-uid");
expect(search.get("duration")).toBe("60");
});

it("rescheduling a 30-minute booking keeps duration=30", async () => {
mockBookingFindUnique.mockResolvedValue({
uid: "booking-uid",
userId: 1,
responses: {},
startTime: new Date("2026-01-10T10:00:00.000Z"),
endTime: new Date("2026-01-10T10:30:00.000Z"),
dynamicEventSlugRef: null,
dynamicGroupSlugRef: null,
user: null,
status: "ACCEPTED",
eventType: {
users: [{ username: "john" }],
slug: "thirty-min-event",
allowReschedulingPastBookings: false,
disableRescheduling: false,
allowReschedulingCancelledBookings: false,
minimumRescheduleNotice: null,
seatsPerTimeSlot: null,
userId: 1,
owner: { id: 1 },
hosts: [{ user: { id: 1 } }],
team: null,
},
});

const { getServerSideProps } = await import("./getServerSideProps");
const result = await getServerSideProps(createContext({ uid: "booking-uid" }));

if (!("redirect" in result) || !result.redirect) throw new Error("Expected redirect result");
const search = new URLSearchParams(result.redirect.destination.split("?")[1]);

expect(search.get("duration")).toBe("30");
});

it("includes duration query param in the generated reschedule link", async () => {
mockBookingFindUnique.mockResolvedValue({
uid: "booking-uid",
userId: 1,
responses: {},
startTime: new Date("2026-01-10T10:00:00.000Z"),
endTime: new Date("2026-01-10T11:00:00.000Z"),
dynamicEventSlugRef: null,
dynamicGroupSlugRef: null,
user: null,
status: "ACCEPTED",
eventType: {
users: [{ username: "john" }],
slug: "event",
allowReschedulingPastBookings: false,
disableRescheduling: false,
allowReschedulingCancelledBookings: false,
minimumRescheduleNotice: null,
seatsPerTimeSlot: null,
userId: 1,
owner: { id: 1 },
hosts: [{ user: { id: 1 } }],
team: null,
},
});

const { getServerSideProps } = await import("./getServerSideProps");
const result = await getServerSideProps(createContext({ uid: "booking-uid" }));

if (!("redirect" in result) || !result.redirect) throw new Error("Expected redirect result");

expect(result.redirect.destination).toContain("duration=60");
});
});
8 changes: 8 additions & 0 deletions apps/web/lib/reschedule/[uid]/getServerSideProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {

destinationUrlSearchParams.set("rescheduleUid", seatReferenceUid || bookingUid);

const originalBookingDurationInMinutes = Math.round(
(booking.endTime.getTime() - booking.startTime.getTime()) / (1000 * 60)
);

if (originalBookingDurationInMinutes > 0) {
destinationUrlSearchParams.set("duration", String(originalBookingDurationInMinutes));
}

if (allowRescheduleForCancelledBooking) {
destinationUrlSearchParams.set("allowRescheduleForCancelledBooking", "true");
}
Expand Down
42 changes: 42 additions & 0 deletions packages/emails/email-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";

import { shouldSkipAttendeeEmailWithSettings, fetchOrganizationEmailSettings } from "./email-manager";
import renderEmail from "./src/renderEmail";
import AttendeeScheduledEmail from "./templates/attendee-scheduled-email";

const mockGetEmailSettings = vi.fn();
Expand Down Expand Up @@ -406,3 +408,43 @@ describe("AttendeeScheduledEmail - Privacy fix for seated events", () => {
});
});
});

describe("AttendeeScheduledEmail - Time format fallback", () => {
const createMockPerson = (name: string, email: string): Person => ({
name,
email,
timeZone: "America/New_York",
language: {
translate: vi.fn((key: string) => key),
locale: "en",
},
});

it("uses organizer time format when attendee time format is missing", async () => {
const recipient = createMockPerson("Booker", "booker@example.com");

const calEvent = {
title: "Test Event",
type: "Test Event Type",
startTime: "2024-01-01T10:00:00Z",
endTime: "2024-01-01T11:00:00Z",
organizer: {
...createMockPerson("Organizer", "organizer@example.com"),
timeFormat: TimeFormat.TWENTY_FOUR_HOUR,
},
attendees: [recipient],
} as CalendarEvent;

const email = new AttendeeScheduledEmail(calEvent, recipient);
await email.getHtml(calEvent, recipient);

expect(vi.mocked(renderEmail)).toHaveBeenCalledWith(
"AttendeeScheduledEmail",
expect.objectContaining({
attendee: expect.objectContaining({
timeFormat: TimeFormat.TWENTY_FOUR_HOUR,
}),
})
);
});
});
7 changes: 6 additions & 1 deletion packages/emails/templates/attendee-scheduled-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,14 @@ export default class AttendeeScheduledEmail extends BaseEmail {
}

async getHtml(calEvent: CalendarEvent, attendee: Person) {
const attendeeWithTimeFormat = {
...attendee,
timeFormat: attendee.timeFormat ?? calEvent.organizer.timeFormat,
};

return await renderEmail("AttendeeScheduledEmail", {
calEvent,
attendee,
attendee: attendeeWithTimeFormat,
});
}

Expand Down
11 changes: 11 additions & 0 deletions packages/features/bookings/lib/get-booking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";

import { getMultipleDurationValue } from "./get-booking";

describe("getMultipleDurationValue", () => {
it("falls back to event type default when duration query param is missing", () => {
const result = getMultipleDurationValue([15, 30, 60], undefined, 30);

expect(result).toBe(30);
});
});
29 changes: 29 additions & 0 deletions packages/features/embed/lib/EmbedCodes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";

vi.mock("@calcom/lib/constants", async () => {
const actual = await vi.importActual<typeof import("@calcom/lib/constants")>("@calcom/lib/constants");
return {
...actual,
IS_SELF_HOSTED: false,
};
});

import { doWeNeedCalOriginProp } from "./EmbedCodes";

describe("doWeNeedCalOriginProp", () => {
it("returns false for default cloud origin app.cal.com", () => {
expect(doWeNeedCalOriginProp("https://app.cal.com")).toBe(false);
});

it("returns false for default cloud website origin cal.com", () => {
expect(doWeNeedCalOriginProp("https://cal.com")).toBe(false);
});

it("returns true for non-default cloud origins like app.cal.eu", () => {
expect(doWeNeedCalOriginProp("https://app.cal.eu")).toBe(true);
});

it("handles trailing slash in origin values", () => {
expect(doWeNeedCalOriginProp("https://app.cal.com/")).toBe(false);
});
});
9 changes: 6 additions & 3 deletions packages/features/embed/lib/EmbedCodes.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { WEBSITE_URL, IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";

import type { PreviewState } from "../types";
import { embedLibUrl } from "./constants";
import { getApiNameForReactSnippet, getApiNameForVanillaJsSnippet } from "./getApiName";
import { getDimension } from "./getDimension";

export const doWeNeedCalOriginProp = (embedCalOrigin: string) => {
const normalizedOrigin = embedCalOrigin.replace(/\/$/, "");
const defaultEmbedOrigins = ["https://app.cal.com", "https://cal.com"];

// If we are self hosted, calOrigin won't be app.cal.com so we need to pass it
// If we are not self hosted but it's still different from WEBAPP_URL and WEBSITE_URL, we need to pass it -> It happens for organization booking URL at the moment
return IS_SELF_HOSTED || (embedCalOrigin !== WEBAPP_URL && embedCalOrigin !== WEBSITE_URL);
// If we are cloud hosted but on a non-default Cal origin (e.g. app.cal.eu), we still need to pass it
return IS_SELF_HOSTED || !defaultEmbedOrigins.includes(normalizedOrigin);
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
Loading