import appStoreMock from "../../../../../tests/libs/__mocks__/app-store"; import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n"; import prismock from "../../../../../tests/libs/__mocks__/prisma"; import type { BookingReference, Attendee, Booking, Membership } from "@prisma/client"; import type { Prisma } from "@prisma/client"; import type { WebhookTriggerEvents } from "@prisma/client"; import type Stripe from "stripe"; import { v4 as uuidv4 } from "uuid"; import "vitest-fetch-mock"; import type { z } from "zod"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook"; import { weekdayToWeekIndex, type WeekDays } from "@calcom/lib/date-fns"; import type { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import type { WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/client"; import type { SchedulingType, SMSLockState, TimeUnit } from "@calcom/prisma/enums"; import type { BookingStatus } from "@calcom/prisma/enums"; import type { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import type { userMetadataType } from "@calcom/prisma/zod-utils"; import type { AppMeta } from "@calcom/types/App"; import type { NewCalendarEventType } from "@calcom/types/Calendar"; import type { EventBusyDate, IntervalLimit } from "@calcom/types/Calendar"; import { getMockPaymentService } from "./MockPaymentService"; import type { getMockRequestDataForBooking } from "./getMockRequestDataForBooking"; logger.settings.minLevel = 0; const log = logger.getSubLogger({ prefix: ["[bookingScenario]"] }); type InputWebhook = { appId: string | null; userId?: number | null; teamId?: number | null; eventTypeId?: number; active: boolean; eventTriggers: WebhookTriggerEvents[]; subscriberUrl: string; }; type InputWorkflow = { userId?: number | null; teamId?: number | null; name?: string; activeOn?: number[]; activeOnTeams?: number[]; trigger: WorkflowTriggerEvents; action: WorkflowActions; template: WorkflowTemplates; time?: number | null; timeUnit?: TimeUnit | null; sendTo?: string; }; type InputHost = { userId: number; isFixed?: boolean; }; /** * Data to be mocked */ export type ScenarioData = { /** * Prisma would return these eventTypes */ eventTypes: InputEventType[]; /** * Prisma would return these users */ users: InputUser[]; /** * Prisma would return these apps */ apps?: Partial[]; bookings?: InputBooking[]; webhooks?: InputWebhook[]; workflows?: InputWorkflow[]; }; type InputCredential = typeof TestData.credentials.google & { id?: number; }; type InputSelectedCalendar = typeof TestData.selectedCalendars.google; type InputUser = Omit & { id: number; defaultScheduleId?: number | null; credentials?: InputCredential[]; organizationId?: number | null; selectedCalendars?: InputSelectedCalendar[]; teams?: { membership: Partial; team: { id: number; name: string; slug: string; parentId?: number; }; }[]; schedules: { // Allows giving id in the input directly so that it can be referenced somewhere else as well id?: number; name: string; availability: { days: number[]; startTime: Date; endTime: Date; date: string | null; }[]; timeZone: string; }[]; destinationCalendar?: Prisma.DestinationCalendarCreateInput; weekStart?: string; profiles?: Prisma.ProfileUncheckedCreateWithoutUserInput[]; }; export type InputEventType = { id: number; title?: string; length?: number; offsetStart?: number; slotInterval?: number; minimumBookingNotice?: number; /** * These user ids are `ScenarioData["users"]["id"]` */ users?: { id: number }[]; hosts?: InputHost[]; schedulingType?: SchedulingType; beforeEventBuffer?: number; afterEventBuffer?: number; teamId?: number | null; team?: { id?: number | null; parentId?: number | null; }; requiresConfirmation?: boolean; destinationCalendar?: Prisma.DestinationCalendarCreateInput; schedule?: InputUser["schedules"][number]; bookingLimits?: IntervalLimit; durationLimits?: IntervalLimit; owner?: number; metadata?: any; } & Partial>; type AttendeeBookingSeatInput = Pick; type WhiteListedBookingProps = { id?: number; uid?: string; userId?: number; eventTypeId: number; startTime: string; endTime: string; title?: string; status: BookingStatus; attendees?: { email: string; bookingSeat?: AttendeeBookingSeatInput | null; }[]; references?: (Omit, "credentialId"> & { // TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId credentialId?: number | null; })[]; bookingSeat?: Prisma.BookingSeatCreateInput[]; }; type InputBooking = Partial> & WhiteListedBookingProps; export const Timezones = { "+5:30": "Asia/Kolkata", "+6:00": "Asia/Dhaka", "-11:00": "Pacific/Pago_Pago", }; async function addHostsToDb(eventTypes: InputEventType[]) { for (const eventType of eventTypes) { if (!eventType.hosts?.length) continue; for (const host of eventType.hosts) { const data: Prisma.HostCreateInput = { eventType: { connect: { id: eventType.id, }, }, isFixed: host.isFixed ?? false, user: { connect: { id: host.userId, }, }, }; await prismock.host.create({ data, }); } } } export async function addEventTypesToDb( eventTypes: (Omit< Prisma.EventTypeCreateInput, "users" | "worflows" | "destinationCalendar" | "schedule" > & { id?: number; // eslint-disable-next-line @typescript-eslint/no-explicit-any users?: any[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any workflows?: any[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any destinationCalendar?: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any schedule?: any; metadata?: any; })[] ) { log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes)); await prismock.eventType.createMany({ data: eventTypes, }); const allEventTypes = await prismock.eventType.findMany({ include: { users: true, workflows: true, destinationCalendar: true, schedule: true, }, }); /** * This is a hack to get the relationship of schedule to be established with eventType. Looks like a prismock bug that creating eventType along with schedule.create doesn't establish the relationship. * HACK STARTS */ log.silly("Fixed possible prismock bug by creating schedule separately"); for (let i = 0; i < eventTypes.length; i++) { const eventType = eventTypes[i]; const createdEventType = allEventTypes[i]; if (eventType.schedule) { log.silly("TestData: Creating Schedule for EventType", JSON.stringify(eventType)); await prismock.schedule.create({ // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore data: { ...eventType.schedule.create, eventType: { connect: { id: createdEventType.id, }, }, }, }); } } /*** * HACK ENDS */ log.silly( "TestData: All EventTypes in DB are", JSON.stringify({ eventTypes: allEventTypes, }) ); return allEventTypes; } export async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) { const baseEventType = { title: "Base EventType Title", slug: "base-event-type-slug", timeZone: null, beforeEventBuffer: 0, afterEventBuffer: 0, bookingLimits: {}, schedulingType: null, length: 15, //TODO: What is the purpose of periodStartDate and periodEndDate? Test these? periodStartDate: new Date("2022-01-21T09:03:48.000Z"), periodEndDate: new Date("2022-01-21T09:03:48.000Z"), periodCountCalendarDays: false, periodDays: 30, seatsPerTimeSlot: null, metadata: {}, minimumBookingNotice: 0, offsetStart: 0, }; const foundEvents: Record = {}; const eventTypesWithUsers = eventTypes.map((eventType) => { if (!eventType.slotInterval && !eventType.length) { throw new Error("eventTypes[number]: slotInterval or length must be defined"); } if (foundEvents[eventType.id]) { throw new Error(`eventTypes[number]: id ${eventType.id} is not unique`); } foundEvents[eventType.id] = true; const users = eventType.users?.map((userWithJustId) => { return usersStore.find((user) => user.id === userWithJustId.id); }) || []; return { ...baseEventType, ...eventType, workflows: [], users, destinationCalendar: eventType.destinationCalendar ? { create: eventType.destinationCalendar, } : eventType.destinationCalendar, schedule: eventType.schedule ? { create: { ...eventType.schedule, availability: { createMany: { data: eventType.schedule.availability, }, }, }, } : eventType.schedule, owner: eventType.owner ? { connect: { id: eventType.owner } } : undefined, schedulingType: eventType.schedulingType, }; }); log.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers)); return await addEventTypesToDb(eventTypesWithUsers); } function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) { prismock.bookingReference.createMany({ data: bookingReferences, }); } async function addBookingsToDb( bookings: (Prisma.BookingCreateInput & { // eslint-disable-next-line @typescript-eslint/no-explicit-any references: any[]; })[] ) { log.silly("TestData: Creating Bookings", JSON.stringify(bookings)); function getDateObj(time: string | Date) { return time instanceof Date ? time : new Date(time); } // Make sure that we store the date in Date object always. This is to ensure consistency which Prisma does but not prismock log.silly("Handling Prismock bug-3"); const fixedBookings = bookings.map((booking) => { const startTime = getDateObj(booking.startTime); const endTime = getDateObj(booking.endTime); return { ...booking, startTime, endTime }; }); await prismock.booking.createMany({ data: fixedBookings, }); log.silly( "TestData: Bookings as in DB", JSON.stringify({ bookings: await prismock.booking.findMany({ include: { references: true, attendees: true, }, }), }) ); } async function addBookings(bookings: InputBooking[]) { log.silly("TestData: Creating Bookings", JSON.stringify(bookings)); const allBookings = [...bookings].map((booking) => { if (booking.references) { addBookingReferencesToDB( booking.references.map((reference) => { return { ...reference, bookingId: booking.id, }; }) ); } return { uid: booking.uid || uuidv4(), workflowReminders: [], references: [], title: "Test Booking Title", ...booking, }; }); await addBookingsToDb( // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore allBookings.map((booking) => { const bookingCreate = booking; if (booking.references) { bookingCreate.references = { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore createMany: { data: booking.references, }, }; } if (booking.attendees) { bookingCreate.attendees = { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore createMany: { data: booking.attendees.map((attendee) => { if (attendee.bookingSeat) { const { bookingSeat, ...attendeeWithoutBookingSeat } = attendee; return { ...attendeeWithoutBookingSeat, bookingSeat: { create: { ...bookingSeat, bookingId: booking.id }, }, }; } else { return attendee; } }), }, }; } return bookingCreate; }) ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async function addWebhooksToDb(webhooks: any[]) { await prismock.webhook.createMany({ data: webhooks, }); } async function addWebhooks(webhooks: InputWebhook[]) { log.silly("TestData: Creating Webhooks", safeStringify(webhooks)); await addWebhooksToDb(webhooks); } async function addWorkflowsToDb(workflows: InputWorkflow[]) { await Promise.all( workflows.map(async (workflow) => { const team = await prismock.team.findFirst({ where: { id: workflow.teamId ?? 0, }, }); if (workflow.teamId && !team) { throw new Error(`Team with ID ${workflow.teamId} not found`); } const isOrg = team?.isOrganization; // Create the workflow first const createdWorkflow = await prismock.workflow.create({ data: { userId: workflow.userId, teamId: workflow.teamId, trigger: workflow.trigger, name: workflow.name ? workflow.name : "Test Workflow", time: workflow.time, timeUnit: workflow.timeUnit, }, include: { steps: true, }, }); await prismock.workflowStep.create({ data: { stepNumber: 1, action: workflow.action, template: workflow.template, numberVerificationPending: false, includeCalendarEvent: false, sendTo: workflow.sendTo, workflow: { connect: { id: createdWorkflow.id, }, }, }, }); //activate event types and teams on workflows if (isOrg && workflow.activeOnTeams) { await Promise.all( workflow.activeOnTeams.map((id) => prismock.workflowsOnTeams.create({ data: { workflowId: createdWorkflow.id, teamId: id, }, }) ) ); } else if (workflow.activeOn) { await Promise.all( workflow.activeOn.map((id) => prismock.workflowsOnEventTypes.create({ data: { workflowId: createdWorkflow.id, eventTypeId: id, }, }) ) ); } }) ); } async function addWorkflows(workflows: InputWorkflow[]) { log.silly("TestData: Creating Workflows", safeStringify(workflows)); await addWorkflowsToDb(workflows); } export async function addUsersToDb( users: (Prisma.UserCreateInput & { schedules: Prisma.ScheduleCreateInput[]; id?: number })[] ) { log.silly("TestData: Creating Users", JSON.stringify(users)); await prismock.user.createMany({ data: users, }); log.silly( "Added users to Db", safeStringify({ allUsers: await prismock.user.findMany({ include: { credentials: true, teams: true, profiles: true, schedules: { include: { availability: true, }, }, destinationCalendar: true, }, }), }) ); } export async function addTeamsToDb(teams: NonNullable[number]["team"][]) { log.silly("TestData: Creating Teams", JSON.stringify(teams)); for (const team of teams) { const teamsWithParentId = { ...team, parentId: team.parentId, }; await prismock.team.upsert({ where: { id: teamsWithParentId.id, }, update: { ...teamsWithParentId, }, create: { ...teamsWithParentId, }, }); } const addedTeams = await prismock.team.findMany({ where: { id: { in: teams.map((team) => team.id), }, }, }); log.silly( "Added teams to Db", safeStringify({ addedTeams, }) ); return addedTeams; } async function addUsers(users: InputUser[]) { const prismaUsersCreate = []; for (let i = 0; i < users.length; i++) { const newUser = users[i]; const user = users[i]; if (user.schedules) { newUser.schedules = { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore createMany: { data: user.schedules.map((schedule) => { return { ...schedule, availability: { createMany: { data: schedule.availability, }, }, }; }), }, }; } if (user.credentials) { newUser.credentials = { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore createMany: { data: user.credentials, }, }; } if (user.teams) { const addedTeams = await addTeamsToDb(user.teams.map((team) => team.team)); newUser.teams = { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore createMany: { data: user.teams.map((team, index) => { return { teamId: addedTeams[index].id, ...team.membership, }; }), }, }; } if (user.selectedCalendars) { newUser.selectedCalendars = { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore createMany: { data: user.selectedCalendars, }, }; } if (user.profiles) { newUser.profiles = { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error Not sure why this is not working createMany: { data: user.profiles, }, }; } prismaUsersCreate.push(newUser); } // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore await addUsersToDb(prismaUsersCreate); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async function addAppsToDb(apps: any[]) { log.silly("TestData: Creating Apps", JSON.stringify({ apps })); await prismock.app.createMany({ data: apps, }); const allApps = await prismock.app.findMany(); log.silly("TestData: Apps as in DB", JSON.stringify({ apps: allApps })); } export async function createBookingScenario(data: ScenarioData) { log.silly("TestData: Creating Scenario", JSON.stringify({ data })); await addUsers(data.users); if (data.apps) { await addAppsToDb( data.apps.map((app) => { // Enable the app by default return { enabled: true, ...app }; }) ); } const eventTypes = await addEventTypes(data.eventTypes, data.users); await addHostsToDb(data.eventTypes); data.bookings = data.bookings || []; // allowSuccessfulBookingCreation(); await addBookings(data.bookings); // mockBusyCalendarTimes([]); await addWebhooks(data.webhooks || []); // addPaymentMock(); await addWorkflows(data.workflows || []); return { eventTypes, }; } export async function createOrganization(orgData: { name: string; slug: string; metadata?: z.infer; withTeam?: boolean; }) { const org = await prismock.team.create({ data: { name: orgData.name, slug: orgData.slug, isOrganization: true, metadata: { ...(orgData.metadata || {}), isOrganization: true, }, }, }); if (orgData.withTeam) { await prismock.team.create({ data: { name: "Org Team", slug: "org-team", isOrganization: false, parent: { connect: { id: org.id, }, }, }, }); } return org; } export async function createCredentials( credentialData: { type: string; key: any; id?: number; userId?: number | null; teamId?: number | null; }[] ) { const credentials = await prismock.credential.createMany({ data: credentialData, }); return credentials; } // async function addPaymentsToDb(payments: Prisma.PaymentCreateInput[]) { // await prismaMock.payment.createMany({ // data: payments, // }); // } /** * This fn indents to /ally compute day, month, year for the purpose of testing. * We are not using DayJS because that's actually being tested by this code. * - `dateIncrement` adds the increment to current day * - `monthIncrement` adds the increment to current month * - `yearIncrement` adds the increment to current year * - `fromDate` starts incrementing from this date (default: today) * @deprecated Stop using this function as it is not timezone aware and can return wrong date depending on the time of the day and timezone. Instead * use vi.setSystemTime to fix the date and time and then use hardcoded days instead of dynamic date calculation. */ export const getDate = ( param: { dateIncrement?: number; monthIncrement?: number; yearIncrement?: number; fromDate?: Date; } = {} ) => { let { dateIncrement, monthIncrement, yearIncrement, fromDate } = param; dateIncrement = dateIncrement || 0; monthIncrement = monthIncrement || 0; yearIncrement = yearIncrement || 0; fromDate = fromDate || new Date(); fromDate.setDate(fromDate.getDate() + dateIncrement); fromDate.setMonth(fromDate.getMonth() + monthIncrement); fromDate.setFullYear(fromDate.getFullYear() + yearIncrement); let _date = fromDate.getDate(); let year = fromDate.getFullYear(); // Make it start with 1 to match with DayJS requiremet let _month = fromDate.getMonth() + 1; // If last day of the month(As _month is plus 1 already it is going to be the 0th day of next month which is the last day of current month) const lastDayOfMonth = new Date(year, _month, 0).getDate(); const numberOfDaysForNextMonth = +_date - +lastDayOfMonth; if (numberOfDaysForNextMonth > 0) { _date = numberOfDaysForNextMonth; _month = _month + 1; } if (_month === 13) { _month = 1; year = year + 1; } const date = _date < 10 ? `0${_date}` : _date; const month = _month < 10 ? `0${_month}` : _month; return { date: String(date), month: String(month), year: String(year), dateString: `${year}-${month}-${date}`, }; }; const isWeekStart = (date: Date, weekStart: WeekDays) => { return date.getDay() === weekdayToWeekIndex(weekStart); }; export const getNextMonthNotStartingOnWeekStart = (weekStart: WeekDays, from?: Date) => { const date = from ?? new Date(); const incrementMonth = (date: Date) => { date.setMonth(date.getMonth() + 1); }; // start searching from the 1st day of next month incrementMonth(date); date.setDate(1); while (isWeekStart(date, weekStart)) { incrementMonth(date); } return getDate({ fromDate: date }); }; export function getMockedCredential({ metadataLookupKey, key, }: { metadataLookupKey: string; key: { expiry_date?: number; token_type?: string; access_token?: string; refresh_token?: string; scope: string; }; }) { const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata]; return { type: app.type, appId: app.slug, app: app, key: { expiry_date: Date.now() + 1000000, token_type: "Bearer", access_token: "ACCESS_TOKEN", refresh_token: "REFRESH_TOKEN", ...key, }, }; } export function getGoogleCalendarCredential() { return getMockedCredential({ metadataLookupKey: "googlecalendar", key: { scope: "https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.readonly", }, }); } export function getGoogleMeetCredential() { return getMockedCredential({ metadataLookupKey: "googlevideo", key: { scope: "", }, }); } export function getAppleCalendarCredential() { return getMockedCredential({ metadataLookupKey: "applecalendar", key: { scope: "https://www.applecalendar.example/auth/calendar.events https://www.applecalendar.example/auth/calendar.readonly", }, }); } export function getZoomAppCredential() { return getMockedCredential({ metadataLookupKey: "zoomvideo", key: { scope: "meeting:write", }, }); } export function getStripeAppCredential() { return getMockedCredential({ metadataLookupKey: "stripepayment", key: { scope: "read_write", }, }); } export const TestData = { selectedCalendars: { google: { integration: "google_calendar", externalId: "john@example.com", }, }, credentials: { google: getGoogleCalendarCredential(), }, schedules: { IstWorkHours: { id: 1, name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT", availability: [ { // userId: null, // eventTypeId: null, days: [0, 1, 2, 3, 4, 5, 6], startTime: new Date("1970-01-01T09:30:00.000Z"), endTime: new Date("1970-01-01T18:00:00.000Z"), date: null, }, ], timeZone: Timezones["+5:30"], }, /** * Has an overlap with IstEveningShift from 5PM to 6PM IST(11:30AM to 12:30PM GMT) */ IstMorningShift: { name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT", availability: [ { // userId: null, // eventTypeId: null, days: [0, 1, 2, 3, 4, 5, 6], startTime: new Date("1970-01-01T09:30:00.000Z"), endTime: new Date("1970-01-01T18:00:00.000Z"), date: null, }, ], timeZone: Timezones["+5:30"], }, /** * Has an overlap with IstMorningShift and IstEveningShift */ IstMidShift: { name: "12:30AM to 8PM in India - 7:00AM to 14:30PM in GMT", availability: [ { // userId: null, // eventTypeId: null, days: [0, 1, 2, 3, 4, 5, 6], startTime: new Date("1970-01-01T12:30:00.000Z"), endTime: new Date("1970-01-01T20:00:00.000Z"), date: null, }, ], timeZone: Timezones["+5:30"], }, /** * Has an overlap with IstMorningShift from 5PM to 6PM IST(11:30AM to 12:30PM GMT) */ IstEveningShift: { name: "5:00PM to 10PM in India - 11:30AM to 16:30PM in GMT", availability: [ { // userId: null, // eventTypeId: null, days: [0, 1, 2, 3, 4, 5, 6], startTime: new Date("1970-01-01T17:00:00.000Z"), endTime: new Date("1970-01-01T22:00:00.000Z"), date: null, }, ], timeZone: Timezones["+5:30"], }, IstWorkHoursWithDateOverride: (dateString: string) => ({ name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT but with a Date Override for 2PM to 6PM IST(in GST time it is 8:30AM to 12:30PM)", availability: [ { days: [0, 1, 2, 3, 4, 5, 6], startTime: new Date("1970-01-01T09:30:00.000Z"), endTime: new Date("1970-01-01T18:00:00.000Z"), date: null, }, { days: [0, 1, 2, 3, 4, 5, 6], startTime: new Date(`1970-01-01T14:00:00.000Z`), endTime: new Date(`1970-01-01T18:00:00.000Z`), date: dateString, }, ], timeZone: Timezones["+5:30"], }), IstWorkHoursNoWeekends: { id: 1, name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT", availability: [ { // userId: null, // eventTypeId: null, days: [/*0*/ 1, 2, 3, 4, 5 /*6*/], startTime: new Date("1970-01-01T09:30:00.000Z"), endTime: new Date("1970-01-01T18:00:00.000Z"), date: null, }, ], timeZone: Timezones["+5:30"], }, }, users: { example: { name: "Example", email: "example@example.com", username: "example.username", defaultScheduleId: 1, timeZone: Timezones["+5:30"], }, }, apps: { "google-calendar": { ...appStoreMetadata.googlecalendar, // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore keys: { expiry_date: Infinity, client_id: "client_id", client_secret: "client_secret", redirect_uris: ["http://localhost:3000/auth/callback"], }, }, "google-meet": { ...appStoreMetadata.googlevideo, // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore keys: { expiry_date: Infinity, client_id: "client_id", client_secret: "client_secret", redirect_uris: ["http://localhost:3000/auth/callback"], }, }, "daily-video": { ...appStoreMetadata.dailyvideo, // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore keys: { expiry_date: Infinity, api_key: "", scale_plan: "false", client_id: "client_id", client_secret: "client_secret", redirect_uris: ["http://localhost:3000/auth/callback"], }, }, zoomvideo: { ...appStoreMetadata.zoomvideo, // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore keys: { expiry_date: Infinity, api_key: "", scale_plan: "false", client_id: "client_id", client_secret: "client_secret", redirect_uris: ["http://localhost:3000/auth/callback"], }, }, "stripe-payment": { ...appStoreMetadata.stripepayment, // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore keys: { expiry_date: Infinity, api_key: "", scale_plan: "false", client_id: "client_id", client_secret: "client_secret", redirect_uris: ["http://localhost:3000/auth/callback"], }, }, }, }; export class MockError extends Error { constructor(message: string) { super(message); this.name = "MockError"; } } export function getOrganizer({ name, email, id, schedules, credentials, selectedCalendars, destinationCalendar, defaultScheduleId, weekStart = "Sunday", teams, organizationId, metadata, smsLockState, }: { name: string; email: string; id: number; organizationId?: number | null; schedules: InputUser["schedules"]; credentials?: InputCredential[]; selectedCalendars?: InputSelectedCalendar[]; defaultScheduleId?: number | null; destinationCalendar?: Prisma.DestinationCalendarCreateInput; weekStart?: WeekDays; teams?: InputUser["teams"]; metadata?: userMetadataType; smsLockState?: SMSLockState; }) { return { ...TestData.users.example, name, email, id, schedules, credentials, selectedCalendars, destinationCalendar, defaultScheduleId, weekStart, teams, organizationId, profiles: [], metadata, smsLockState, }; } export function getScenarioData( { organizer, eventTypes, usersApartFromOrganizer = [], apps = [], webhooks, workflows, bookings, }: { organizer: ReturnType; eventTypes: ScenarioData["eventTypes"]; apps?: ScenarioData["apps"]; usersApartFromOrganizer?: ScenarioData["users"]; webhooks?: ScenarioData["webhooks"]; workflows?: ScenarioData["workflows"]; bookings?: ScenarioData["bookings"]; }, org?: { id: number | null } | undefined | null ) { const users = [organizer, ...usersApartFromOrganizer]; if (org) { const orgId = org.id; if (!orgId) { throw new Error("If org is specified org.id is required"); } users.forEach((user) => { user.profiles = [ { organizationId: orgId, username: user.username || "", uid: ProfileRepository.generateProfileUid(), }, ]; }); } eventTypes.forEach((eventType) => { if ( eventType.users?.filter((eventTypeUser) => { return !users.find((userToCreate) => userToCreate.id === eventTypeUser.id); }).length ) { throw new Error(`EventType ${eventType.id} has users that are not present in ScenarioData["users"]`); } }); return { eventTypes: eventTypes.map((eventType, index) => { return { ...eventType, teamId: eventType.teamId || null, team: { id: eventType.teamId, parentId: org ? org.id : null, }, title: `Test Event Type - ${index + 1}`, description: `It's a test event type - ${index + 1}`, }; }), users: users.map((user) => { const newUser = { ...user, organizationId: user.organizationId ?? null, }; if (user.destinationCalendar) { newUser.destinationCalendar = { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore create: user.destinationCalendar, }; } return newUser; }), apps: [...apps], webhooks, bookings: bookings || [], workflows, } satisfies ScenarioData; } export function enableEmailFeature() { prismock.feature.create({ data: { slug: "emails", enabled: false, type: "KILL_SWITCH", }, }); } export function mockNoTranslations() { log.silly("Mocking i18n.getTranslation to return identity function"); // @ts-expect-error FIXME i18nMock.getTranslation.mockImplementation(() => { return new Promise((resolve) => { const identityFn = (key: string) => key; resolve(identityFn); }); }); } export const enum BookingLocations { CalVideo = "integrations:daily", ZoomVideo = "integrations:zoom", GoogleMeet = "integrations:google:meet", } /** * @param metadataLookupKey * @param calendarData Specify uids and other data to be faked to be returned by createEvent and updateEvent */ export function mockCalendar( metadataLookupKey: keyof typeof appStoreMetadata, calendarData?: { create?: { id?: string; uid?: string; iCalUID?: string; }; update?: { id?: string; uid: string; iCalUID?: string; }; busySlots?: { start: `${string}Z`; end: `${string}Z` }[]; creationCrash?: boolean; updationCrash?: boolean; getAvailabilityCrash?: boolean; } ) { const appStoreLookupKey = metadataLookupKey; const normalizedCalendarData = calendarData || { create: { uid: "MOCK_ID", }, update: { uid: "UPDATED_MOCK_ID", }, }; log.silly(`Mocking ${appStoreLookupKey} on appStoreMock`); // eslint-disable-next-line @typescript-eslint/no-explicit-any const createEventCalls: any[] = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const updateEventCalls: any[] = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const deleteEventCalls: any[] = []; const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata]; const appMock = appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default]; appMock && `mockResolvedValue` in appMock && appMock.mockResolvedValue({ lib: { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore CalendarService: function MockCalendarService() { return { // eslint-disable-next-line @typescript-eslint/no-explicit-any createEvent: async function (...rest: any[]): Promise { if (calendarData?.creationCrash) { throw new Error("MockCalendarService.createEvent fake error"); } const [calEvent, credentialId] = rest; log.silly("mockCalendar.createEvent", JSON.stringify({ calEvent, credentialId })); createEventCalls.push(rest); return Promise.resolve({ type: app.type, additionalInfo: {}, uid: "PROBABLY_UNUSED_UID", // A Calendar is always expected to return an id. id: normalizedCalendarData.create?.id || "FALLBACK_MOCK_CALENDAR_EVENT_ID", iCalUID: normalizedCalendarData.create?.iCalUID, // Password and URL seems useless for CalendarService, plan to remove them if that's the case password: "MOCK_PASSWORD", url: "https://UNUSED_URL", }); }, // eslint-disable-next-line @typescript-eslint/no-explicit-any updateEvent: async function (...rest: any[]): Promise { if (calendarData?.updationCrash) { throw new Error("MockCalendarService.updateEvent fake error"); } const [uid, event, externalCalendarId] = rest; log.silly("mockCalendar.updateEvent", JSON.stringify({ uid, event, externalCalendarId })); // eslint-disable-next-line prefer-rest-params updateEventCalls.push(rest); const isGoogleMeetLocation = event.location === BookingLocations.GoogleMeet; return Promise.resolve({ type: app.type, additionalInfo: {}, uid: "PROBABLY_UNUSED_UID", iCalUID: normalizedCalendarData.update?.iCalUID, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion id: normalizedCalendarData.update?.uid || "FALLBACK_MOCK_ID", // Password and URL seems useless for CalendarService, plan to remove them if that's the case password: "MOCK_PASSWORD", url: "https://UNUSED_URL", location: isGoogleMeetLocation ? "https://UNUSED_URL" : undefined, hangoutLink: isGoogleMeetLocation ? "https://UNUSED_URL" : undefined, conferenceData: isGoogleMeetLocation ? event.conferenceData : undefined, }); }, // eslint-disable-next-line @typescript-eslint/no-explicit-any deleteEvent: async (...rest: any[]) => { log.silly("mockCalendar.deleteEvent", JSON.stringify({ rest })); // eslint-disable-next-line prefer-rest-params deleteEventCalls.push(rest); }, getAvailability: async (): Promise => { if (calendarData?.getAvailabilityCrash) { throw new Error("MockCalendarService.getAvailability fake error"); } return new Promise((resolve) => { resolve(calendarData?.busySlots || []); }); }, }; }, }, }); return { createEventCalls, deleteEventCalls, updateEventCalls, }; } export function mockCalendarToHaveNoBusySlots( metadataLookupKey: keyof typeof appStoreMetadata, calendarData?: Parameters[1] ) { calendarData = calendarData || { create: { uid: "MOCK_ID", }, update: { uid: "UPDATED_MOCK_ID", }, }; return mockCalendar(metadataLookupKey, { ...calendarData, busySlots: [] }); } export function mockCalendarToCrashOnCreateEvent(metadataLookupKey: keyof typeof appStoreMetadata) { return mockCalendar(metadataLookupKey, { creationCrash: true }); } export function mockCalendarToCrashOnUpdateEvent(metadataLookupKey: keyof typeof appStoreMetadata) { return mockCalendar(metadataLookupKey, { updationCrash: true }); } export function mockVideoApp({ metadataLookupKey, appStoreLookupKey, videoMeetingData, creationCrash, updationCrash, }: { metadataLookupKey: string; appStoreLookupKey?: string; videoMeetingData?: { password: string; id: string; url: string; }; creationCrash?: boolean; updationCrash?: boolean; }) { appStoreLookupKey = appStoreLookupKey || metadataLookupKey; videoMeetingData = videoMeetingData || { id: "MOCK_ID", password: "MOCK_PASS", url: `http://mock-${metadataLookupKey}.example.com`, }; log.silly("mockVideoApp", JSON.stringify({ metadataLookupKey, appStoreLookupKey })); // eslint-disable-next-line @typescript-eslint/no-explicit-any const createMeetingCalls: any[] = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const updateMeetingCalls: any[] = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const deleteMeetingCalls: any[] = []; // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => { return new Promise((resolve) => { resolve({ lib: { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore VideoApiAdapter: (credential) => { return { // eslint-disable-next-line @typescript-eslint/no-explicit-any createMeeting: (...rest: any[]) => { if (creationCrash) { throw new Error("MockVideoApiAdapter.createMeeting fake error"); } createMeetingCalls.push({ credential, args: rest, }); return Promise.resolve({ type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, ...videoMeetingData, }); }, // eslint-disable-next-line @typescript-eslint/no-explicit-any updateMeeting: async (...rest: any[]) => { if (updationCrash) { throw new Error("MockVideoApiAdapter.updateMeeting fake error"); } const [bookingRef, calEvent] = rest; updateMeetingCalls.push({ credential, args: rest, }); if (!bookingRef.type) { throw new Error("bookingRef.type is not defined"); } if (!calEvent.organizer) { throw new Error("calEvent.organizer is not defined"); } log.silly("MockVideoApiAdapter.updateMeeting", JSON.stringify({ bookingRef, calEvent })); return Promise.resolve({ type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, ...videoMeetingData, }); }, // eslint-disable-next-line @typescript-eslint/no-explicit-any deleteMeeting: async (...rest: any[]) => { log.silly("MockVideoApiAdapter.deleteMeeting", JSON.stringify(rest)); deleteMeetingCalls.push({ credential, args: rest, }); }, }; }, }, }); }); }); return { createMeetingCalls, updateMeetingCalls, deleteMeetingCalls, }; } export function mockSuccessfulVideoMeetingCreation({ metadataLookupKey, appStoreLookupKey, videoMeetingData, }: { metadataLookupKey: string; appStoreLookupKey?: string; videoMeetingData?: { password: string; id: string; url: string; }; }) { return mockVideoApp({ metadataLookupKey, appStoreLookupKey, videoMeetingData, }); } export function mockVideoAppToCrashOnCreateMeeting({ metadataLookupKey, appStoreLookupKey, }: { metadataLookupKey: string; appStoreLookupKey?: string; }) { return mockVideoApp({ metadataLookupKey, appStoreLookupKey, creationCrash: true, }); } export function mockPaymentApp({ metadataLookupKey, appStoreLookupKey, }: { metadataLookupKey: string; appStoreLookupKey?: string; }) { appStoreLookupKey = appStoreLookupKey || metadataLookupKey; const { paymentUid, externalId, MockPaymentService } = getMockPaymentService(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => { return new Promise((resolve) => { resolve({ lib: { PaymentService: MockPaymentService, }, }); }); }); return { paymentUid, externalId, }; } export function mockErrorOnVideoMeetingCreation({ metadataLookupKey, appStoreLookupKey, }: { metadataLookupKey: string; appStoreLookupKey?: string; }) { appStoreLookupKey = appStoreLookupKey || metadataLookupKey; // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore appStoreMock.default[appStoreLookupKey].mockImplementation(() => { return new Promise((resolve) => { resolve({ lib: { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore VideoApiAdapter: () => ({ createMeeting: () => { throw new MockError("Error creating Video meeting"); }, }), }, }); }); }); } export function mockCrmApp( metadataLookupKey: string, crmData?: { createContacts?: { id: string; email: string; }[]; getContacts?: { id: string; email: string; ownerEmail: string; }[]; } ) { let contactsCreated: { id: string; email: string; }[] = []; let contactsQueried: { id: string; email: string; ownerEmail: string; }[] = []; const eventsCreated: boolean[] = []; const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata]; const appMock = appStoreMock.default[metadataLookupKey as keyof typeof appStoreMock.default]; appMock && `mockResolvedValue` in appMock && appMock.mockResolvedValue({ lib: { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore CrmService: class { constructor() { log.debug("Create CrmSerive"); } createContact() { if (crmData?.createContacts) { contactsCreated = crmData.createContacts; return Promise.resolve(crmData?.createContacts); } } getContacts(email: string) { if (crmData?.getContacts) { contactsQueried = crmData?.getContacts; const contactsOfEmail = contactsQueried.filter((contact) => contact.email === email); return Promise.resolve(contactsOfEmail); } } createEvent() { eventsCreated.push(true); return Promise.resolve({}); } }, }, }); return { contactsCreated, contactsQueried, eventsCreated, }; } export function getBooker({ name, email }: { name: string; email: string }) { return { name, email, }; } export function getMockedStripePaymentEvent({ paymentIntentId }: { paymentIntentId: string }) { return { id: null, data: { object: { id: paymentIntentId, }, }, } as unknown as Stripe.Event; } export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { externalId: string }) { let webhookResponse = null; try { await handleStripePaymentSuccess(getMockedStripePaymentEvent({ paymentIntentId: externalId })); } catch (e) { log.silly("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e)); webhookResponse = e as HttpError; } return { webhookResponse }; } export function getExpectedCalEventForBookingRequest({ bookingRequest, eventType, }: { bookingRequest: ReturnType; // eslint-disable-next-line @typescript-eslint/no-explicit-any eventType: any; }) { return { // keep adding more fields as needed, so that they can be verified in all scenarios type: eventType.slug, // Not sure why, but milliseconds are missing in cal Event. startTime: bookingRequest.start.replace(".000Z", "Z"), endTime: bookingRequest.end.replace(".000Z", "Z"), }; } export function getMockBookingReference( bookingReference: Partial & Pick ) { let credentialId = bookingReference.credentialId; if (bookingReference.type === appStoreMetadata.dailyvideo.type) { // Right now we seems to be storing credentialId for `dailyvideo` in BookingReference as null. Another possible value is 0 in there. credentialId = null; log.debug("Ensuring null credentialId for dailyvideo"); } return { ...bookingReference, credentialId, }; } export function getMockBookingAttendee( attendee: Omit & { bookingSeat?: AttendeeBookingSeatInput; } ) { return { id: attendee.id, timeZone: attendee.timeZone, name: attendee.name, email: attendee.email, locale: attendee.locale, bookingSeat: attendee.bookingSeat || null, }; } const getMockAppStatus = ({ slug, failures, success, overrideName, }: { slug: string; failures: number; success: number; overrideName?: string; }) => { const foundEntry = Object.entries(appStoreMetadata).find(([, app]) => { return app.slug === slug; }); if (!foundEntry) { throw new Error("App not found for the slug"); } const foundApp = foundEntry[1]; return { appName: overrideName ?? foundApp.slug, type: foundApp.type, failures, success, errors: [], }; }; export const getMockFailingAppStatus = ({ slug }: { slug: string }) => { return getMockAppStatus({ slug, failures: 1, success: 0 }); }; export const getMockPassingAppStatus = ({ slug, overrideName }: { slug: string; overrideName?: string }) => { return getMockAppStatus({ slug, overrideName, failures: 0, success: 1 }); }; export const replaceDates = (dates: string[], replacement: Record) => { return dates.map((date) => { return date.replace(/(.*)T/, (_, group1) => `${replacement[group1]}T`); }); };