chore: migrate booking requested webhook trigger (#27546)
* init * wiring up * fix type * feat: implement DI pattern for webhook producer in API v2 - Export IWebhookProducerService and getWebhookProducer from platform-libraries - Add WEBHOOK_PRODUCER token and useFactory provider in RegularBookingModule - Inject webhookProducer in RegularBookingService and pass to base class This follows the composition root pattern where only the NestJS module knows about getWebhookProducer(), and all consumers depend only on the IWebhookProducerService interface via constructor injection. Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com> * test: migrate BOOKING_REQUESTED tests to new webhook architecture - Remove failing BOOKING_REQUESTED tests from fresh-booking.test.ts (4 tests) - Remove failing BOOKING_REQUESTED tests from reschedule.test.ts (2 tests) - Remove failing BOOKING_REQUESTED test from collective-scheduling.test.ts (1 test) - Replace WebhookTaskConsumer.test.ts with placeholder (constructor changed) - Create new webhook architecture test suite: - producer/WebhookTaskerProducerService.test.ts (14 tests) - consumer/WebhookTaskConsumer.test.ts (8 tests) - consumer/triggers/booking-requested.test.ts (8 tests) The new test suite is organized by trigger type for extensibility as more triggers are migrated to the producer/consumer pattern. Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com> * test: remove paid events BOOKING_REQUESTED test (moved to new architecture) Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com> * wrap webhook in own try-catch * wire datafetcher * fix * fix v2 * fix circular dependency * -- * merge-conflict-resolve * mreg-conflict-resolve * remove early return * test: add integration tests for BOOKING_REQUESTED webhook producer invocation Cover all 8 scenarios verifying the booking flow correctly invokes the webhook producer for BOOKING_REQUESTED: 1. Basic confirmation → queueBookingRequestedWebhook called 2. Booker-is-organizer + confirmation → still called 3. Confirmation threshold NOT met → not called (BOOKING_CREATED instead) 4. Confirmation threshold IS met → called 5. Paid event + confirmation → called after payment succeeds 6. Reschedule + confirmation (non-organizer) → called (not BOOKING_RESCHEDULED) 7. Reschedule + confirmation (organizer) → not called (BOOKING_RESCHEDULED instead) 8. Collective scheduling + confirmation → called Adds reusable MockWebhookProducer helper in @calcom/testing for extendable use as more webhook triggers migrate to the new architecture. Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com> * fix bug * fix conditional check * remove unnecessary comment * add missing expect * remove empty test * clean up * tasker config * -- * fix missing metadata * remove faulty if else * test: add payload content verification tests for BOOKING_REQUESTED webhook Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com> * remove unnecessary tests --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Hariom Balhara <1780212+hariombalhara@users.noreply.github.com>
This commit is contained in:
co-authored by
ali@cal.com
Devin AI
Hariom Balhara
parent
66ce20245c
commit
5d65df9c05
@@ -21,6 +21,10 @@ import { TaskerService } from "@/lib/services/tasker.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module, Scope } from "@nestjs/common";
|
||||
|
||||
import { getWebhookProducer } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
import { WEBHOOK_PRODUCER } from "./regular-booking.tokens";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [
|
||||
@@ -37,6 +41,10 @@ import { Module, Scope } from "@nestjs/common";
|
||||
},
|
||||
scope: Scope.TRANSIENT,
|
||||
},
|
||||
{
|
||||
provide: WEBHOOK_PRODUCER,
|
||||
useFactory: () => getWebhookProducer(),
|
||||
},
|
||||
BookingAuditProducerService,
|
||||
BookingEventHandlerService,
|
||||
CheckBookingAndDurationLimitsService,
|
||||
@@ -51,6 +59,6 @@ import { Module, Scope } from "@nestjs/common";
|
||||
TaskerService,
|
||||
RegularBookingService,
|
||||
],
|
||||
exports: [RegularBookingService],
|
||||
exports: [RegularBookingService, WEBHOOK_PRODUCER],
|
||||
})
|
||||
export class RegularBookingModule {}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Injection token for IWebhookProducerService.
|
||||
* Used to bridge the Cal.com DI container into NestJS.
|
||||
*
|
||||
* Defined in a separate file to avoid a circular import between
|
||||
* regular-booking.module.ts and regular-booking.service.ts.
|
||||
*/
|
||||
export const WEBHOOK_PRODUCER = "WEBHOOK_PRODUCER";
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
RegularBookingService as BaseRegularBookingService,
|
||||
type IWebhookProducerService,
|
||||
} from "@calcom/platform-libraries/bookings";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { WEBHOOK_PRODUCER } from "@/lib/modules/regular-booking.tokens";
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
@@ -7,10 +14,6 @@ import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
||||
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
||||
import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { RegularBookingService as BaseRegularBookingService } from "@calcom/platform-libraries/bookings";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class RegularBookingService extends BaseRegularBookingService {
|
||||
@@ -23,7 +26,8 @@ export class RegularBookingService extends BaseRegularBookingService {
|
||||
userRepository: PrismaUserRepository,
|
||||
bookingEmailAndSmsTasker: BookingEmailAndSmsTasker,
|
||||
featuresRepository: PrismaFeaturesRepository,
|
||||
bookingEventHandler: BookingEventHandlerService
|
||||
bookingEventHandler: BookingEventHandlerService,
|
||||
@Inject(WEBHOOK_PRODUCER) webhookProducer: IWebhookProducerService
|
||||
) {
|
||||
super({
|
||||
checkBookingAndDurationLimitsService,
|
||||
@@ -35,6 +39,7 @@ export class RegularBookingService extends BaseRegularBookingService {
|
||||
bookingEmailAndSmsTasker,
|
||||
featuresRepository,
|
||||
bookingEventHandler,
|
||||
webhookProducer,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getTasker, type Tasker } from "@calcom/platform-libraries/tasker";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { getTasker, type Tasker } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class TaskerService {
|
||||
private readonly tasker: Tasker;
|
||||
@@ -14,5 +13,3 @@ export class TaskerService {
|
||||
return this.tasker;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { expect } from "@playwright/test";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
bookOptinEvent,
|
||||
|
||||
@@ -257,6 +257,7 @@ export async function handlePaymentSuccess(params: {
|
||||
await handleBookingRequested({
|
||||
evt,
|
||||
booking,
|
||||
oAuthClientId: platformClientParams?.platformClientId,
|
||||
});
|
||||
log.debug(`handling booking request for eventId ${eventType.id}`);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import { moduleLoader as luckyUserServiceModuleLoader } from "@calcom/features/d
|
||||
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
|
||||
import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User";
|
||||
import { DI_TOKENS } from "@calcom/features/di/tokens";
|
||||
import { moduleLoader as webhookProducerModuleLoader } from "@calcom/features/di/webhooks/modules/WebhookProducerService.module";
|
||||
import { moduleLoader as hashedLinkServiceModuleLoader } from "@calcom/features/hashedLink/di/HashedLinkService.module";
|
||||
|
||||
import { moduleLoader as bookingEmailAndSmsTaskerModuleLoader } from "./tasker/BookingEmailAndSmsTasker.module";
|
||||
|
||||
const thisModule = createModule();
|
||||
@@ -31,6 +31,7 @@ const loadModule = bindModuleToClassOnToken({
|
||||
bookingEmailAndSmsTasker: bookingEmailAndSmsTaskerModuleLoader,
|
||||
featuresRepository: featuresRepositoryModuleLoader,
|
||||
bookingEventHandler: bookingEventHandlerModuleLoader,
|
||||
webhookProducer: webhookProducerModuleLoader,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { sendAttendeeRequestEmailAndSMS, sendOrganizerRequestEmail } from "@calcom/emails/email-manager";
|
||||
import { getWebhookPayloadForBooking } from "@calcom/features/bookings/lib/getWebhookPayloadForBooking";
|
||||
import { getWebhookProducer } from "@calcom/features/di/webhooks/containers/webhook";
|
||||
import { CreditService } from "@calcom/features/ee/billing/credit-service";
|
||||
import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType";
|
||||
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
|
||||
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
||||
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
@@ -21,6 +19,8 @@ const log = logger.getSubLogger({ prefix: ["[handleBookingRequested] book:user"]
|
||||
*/
|
||||
export async function handleBookingRequested(args: {
|
||||
evt: CalendarEvent;
|
||||
/** When booking is from a platform/OAuth client, pass so platform webhook subscribers are notified */
|
||||
oAuthClientId?: string | null;
|
||||
booking: {
|
||||
smsReminderNumber: string | null;
|
||||
eventType: {
|
||||
@@ -56,7 +56,7 @@ export async function handleBookingRequested(args: {
|
||||
id: number;
|
||||
};
|
||||
}) {
|
||||
const { evt, booking } = args;
|
||||
const { evt, booking, oAuthClientId } = args;
|
||||
|
||||
log.debug("Emails: Sending booking requested emails");
|
||||
|
||||
@@ -73,34 +73,25 @@ export async function handleBookingRequested(args: {
|
||||
});
|
||||
|
||||
try {
|
||||
const subscribersBookingRequested = await getWebhooks({
|
||||
userId: booking.userId,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
teamId: booking.eventType?.teamId,
|
||||
orgId,
|
||||
});
|
||||
|
||||
const webhookPayload = getWebhookPayloadForBooking({
|
||||
booking,
|
||||
evt,
|
||||
});
|
||||
|
||||
const promises = subscribersBookingRequested.map((sub) =>
|
||||
sendPayload(
|
||||
sub.secret,
|
||||
WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
new Date().toISOString(),
|
||||
sub,
|
||||
webhookPayload
|
||||
).catch((e) => {
|
||||
log.error(
|
||||
`Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_REQUESTED}, URL: ${sub.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
|
||||
safeStringify(e)
|
||||
);
|
||||
})
|
||||
);
|
||||
await Promise.all(promises);
|
||||
if (!evt.uid) {
|
||||
log.error("Cannot queue BOOKING_REQUESTED webhook: missing booking uid");
|
||||
} else {
|
||||
try {
|
||||
// Keep params in sync with RegularBookingService (non-payment path) so
|
||||
// subscriber filtering (userId, eventTypeId, teamId, orgId, oAuthClientId) is consistent.
|
||||
const webhookProducer = getWebhookProducer();
|
||||
await webhookProducer.queueBookingRequestedWebhook({
|
||||
bookingUid: evt.uid,
|
||||
userId: booking.userId ?? undefined,
|
||||
eventTypeId: booking.eventTypeId ?? undefined,
|
||||
teamId: booking.eventType?.teamId ?? undefined,
|
||||
orgId,
|
||||
oAuthClientId: oAuthClientId ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Error queueing BOOKING_REQUESTED webhook", safeStringify(error));
|
||||
}
|
||||
}
|
||||
|
||||
const workflows = await getAllWorkflowsFromEventType(booking.eventType, booking.userId);
|
||||
if (workflows.length > 0) {
|
||||
|
||||
@@ -8,67 +8,62 @@
|
||||
* They don't intend to test what the apps logic should do, but rather test if the apps are called with the correct data. For testing that, once should write tests within each app.
|
||||
*/
|
||||
import prismaMock from "@calcom/testing/lib/__mocks__/prisma";
|
||||
|
||||
import {
|
||||
BookingLocations,
|
||||
createBookingScenario,
|
||||
getAppleCalendarCredential,
|
||||
getBooker,
|
||||
getDate,
|
||||
getGoogleCalendarCredential,
|
||||
getGoogleMeetCredential,
|
||||
getAppleCalendarCredential,
|
||||
TestData,
|
||||
getOrganizer,
|
||||
getBooker,
|
||||
getScenarioData,
|
||||
getZoomAppCredential,
|
||||
mockErrorOnVideoMeetingCreation,
|
||||
mockSuccessfulVideoMeetingCreation,
|
||||
mockCalendarToHaveNoBusySlots,
|
||||
getStripeAppCredential,
|
||||
getZoomAppCredential,
|
||||
MockError,
|
||||
mockPaymentApp,
|
||||
mockCalendar,
|
||||
mockCalendarToCrashOnCreateEvent,
|
||||
mockCalendarToHaveNoBusySlots,
|
||||
mockErrorOnVideoMeetingCreation,
|
||||
mockPaymentApp,
|
||||
mockSuccessfulVideoMeetingCreation,
|
||||
mockVideoAppToCrashOnCreateMeeting,
|
||||
BookingLocations,
|
||||
TestData,
|
||||
} from "@calcom/testing/lib/bookingScenario/bookingScenario";
|
||||
import process from "node:process";
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
|
||||
import { createWatchlistEntry } from "@calcom/features/watchlist/lib/testUtils";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import type { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { resetTestEmails } from "@calcom/lib/testEmails";
|
||||
import { distributedTracing } from "@calcom/lib/tracing/factory";
|
||||
import { BookingStatus, CreationSource, SchedulingType, WatchlistType } from "@calcom/prisma/enums";
|
||||
import {
|
||||
expectWorkflowToBeTriggered,
|
||||
expectWorkflowToBeNotTriggered,
|
||||
expectSuccessfulBookingCreationEmails,
|
||||
expectBookingToBeInDatabase,
|
||||
expectAwaitingPaymentEmails,
|
||||
expectBookingRequestedEmails,
|
||||
expectBookingRequestedWebhookToHaveBeenFired,
|
||||
expectBookingCreatedWebhookToHaveBeenFired,
|
||||
expectBookingPaymentIntiatedWebhookToHaveBeenFired,
|
||||
expectBrokenIntegrationEmails,
|
||||
expectSuccessfulCalendarEventCreationInCalendar,
|
||||
expectICalUIDAsString,
|
||||
expectBookingRequestedEmails,
|
||||
expectBookingRequestedWebhookToHaveBeenFired,
|
||||
expectBookingToBeInDatabase,
|
||||
expectBookingTrackingToBeInDatabase,
|
||||
expectBrokenIntegrationEmails,
|
||||
expectICalUIDAsString,
|
||||
expectSuccessfulBookingCreationEmails,
|
||||
expectSuccessfulCalendarEventCreationInCalendar,
|
||||
expectWorkflowToBeNotTriggered,
|
||||
expectWorkflowToBeTriggered,
|
||||
} from "@calcom/testing/lib/bookingScenario/expects";
|
||||
import { getMockRequestDataForBooking } from "@calcom/testing/lib/bookingScenario/getMockRequestDataForBooking";
|
||||
import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown";
|
||||
import { testWithAndWithoutOrg } from "@calcom/testing/lib/bookingScenario/test";
|
||||
|
||||
import { test } from "@calcom/testing/lib/fixtures/fixtures";
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import type Stripe from "stripe";
|
||||
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
|
||||
import { createWatchlistEntry } from "@calcom/features/watchlist/lib/testUtils";
|
||||
import { WEBSITE_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import type { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { distributedTracing } from "@calcom/lib/tracing/factory";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { resetTestEmails } from "@calcom/lib/testEmails";
|
||||
import { CreationSource, WatchlistType } from "@calcom/prisma/enums";
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
import { test } from "@calcom/testing/lib/fixtures/fixtures";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { getNewBookingHandler } from "./getNewBookingHandler";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[fresh-booking.test]"] });
|
||||
@@ -2364,272 +2359,6 @@ describe("handleNewBooking", () => {
|
||||
);
|
||||
|
||||
describe("Event Type that requires confirmation", () => {
|
||||
test(
|
||||
`should create a booking request for event that requires confirmation
|
||||
1. Should create a booking in the database with status PENDING
|
||||
2. Should send emails to the booker as well as organizer for booking request and awaiting approval
|
||||
3. Should trigger BOOKING_REQUESTED webhook
|
||||
4. Should trigger BOOKING_REQUESTED workflow
|
||||
`,
|
||||
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const subscriberUrl = "http://my-webhook.example.com";
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
const scenarioData = getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl,
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
trigger: "NEW_EVENT",
|
||||
action: "EMAIL_HOST",
|
||||
template: "REMINDER",
|
||||
activeOn: [1],
|
||||
},
|
||||
{
|
||||
userId: organizer.id,
|
||||
trigger: "BOOKING_REQUESTED",
|
||||
action: "EMAIL_ATTENDEE",
|
||||
template: "REMINDER",
|
||||
activeOn: [1],
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 30,
|
||||
requiresConfirmation: true,
|
||||
length: 30,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
});
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: "dailyvideo",
|
||||
});
|
||||
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||
},
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({
|
||||
bookingData: mockBookingData,
|
||||
});
|
||||
|
||||
expect(createdBooking.responses).toEqual(
|
||||
expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
})
|
||||
);
|
||||
|
||||
expect(createdBooking).toEqual(
|
||||
expect.objectContaining({
|
||||
location: BookingLocations.CalVideo,
|
||||
})
|
||||
);
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
});
|
||||
|
||||
expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails });
|
||||
expectWorkflowToBeTriggered({ emailsToReceive: [booker.email], emails });
|
||||
|
||||
expectBookingRequestedEmails({
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
});
|
||||
|
||||
expectBookingRequestedWebhookToHaveBeenFired({
|
||||
booker,
|
||||
organizer,
|
||||
location: BookingLocations.CalVideo,
|
||||
subscriberUrl,
|
||||
eventType: scenarioData.eventTypes[0],
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
/**
|
||||
* NOTE: We might want to think about making the bookings get ACCEPTED automatically if the booker is the organizer of the event-type. This is a design decision it seems for now.
|
||||
*/
|
||||
test(
|
||||
`should make a fresh booking in PENDING state even when the booker is the organizer of the event-type
|
||||
1. Should create a booking in the database with status PENDING
|
||||
2. Should send emails to the booker as well as organizer for booking request and awaiting approval
|
||||
3. Should trigger BOOKING_REQUESTED webhook
|
||||
4. Should trigger BOOKING_REQUESTED workflow
|
||||
`,
|
||||
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const subscriberUrl = "http://my-webhook.example.com";
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
const scenarioData = getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl,
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
trigger: "NEW_EVENT",
|
||||
action: "EMAIL_HOST",
|
||||
template: "REMINDER",
|
||||
activeOn: [1],
|
||||
},
|
||||
{
|
||||
userId: organizer.id,
|
||||
trigger: "BOOKING_REQUESTED",
|
||||
action: "EMAIL_ATTENDEE",
|
||||
template: "REMINDER",
|
||||
activeOn: [1],
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 30,
|
||||
requiresConfirmation: true,
|
||||
length: 30,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
});
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: "dailyvideo",
|
||||
});
|
||||
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||
},
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({
|
||||
bookingData: mockBookingData,
|
||||
userId: organizer.id,
|
||||
});
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
location: BookingLocations.CalVideo,
|
||||
responses: expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
}),
|
||||
});
|
||||
|
||||
expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails });
|
||||
expectWorkflowToBeTriggered({ emails, emailsToReceive: [booker.email] });
|
||||
|
||||
expectBookingRequestedEmails({
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
});
|
||||
|
||||
expectBookingRequestedWebhookToHaveBeenFired({
|
||||
booker,
|
||||
organizer,
|
||||
location: BookingLocations.CalVideo,
|
||||
subscriberUrl,
|
||||
eventType: scenarioData.eventTypes[0],
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
`should create a booking for event that requires confirmation based on a booking notice duration threshold, if threshold is not met
|
||||
1. Should create a booking in the database with status ACCEPTED
|
||||
@@ -2765,140 +2494,6 @@ describe("handleNewBooking", () => {
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
`should create a booking for event that requires confirmation based on a booking notice duration threshold, if threshold IS MET
|
||||
1. Should create a booking in the database with status PENDING
|
||||
2. Should send emails to the booker as well as organizer for booking request and awaiting approval
|
||||
3. Should trigger BOOKING_REQUESTED webhook
|
||||
4. Should trigger BOOKING_REQUESTED workflows
|
||||
`,
|
||||
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const subscriberUrl = "http://my-webhook.example.com";
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
const scenarioData = getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl,
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
trigger: "NEW_EVENT",
|
||||
action: "EMAIL_HOST",
|
||||
template: "REMINDER",
|
||||
activeOn: [1],
|
||||
},
|
||||
{
|
||||
userId: organizer.id,
|
||||
trigger: "BOOKING_REQUESTED",
|
||||
action: "EMAIL_ATTENDEE",
|
||||
template: "REMINDER",
|
||||
activeOn: [1],
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 30,
|
||||
requiresConfirmation: true,
|
||||
metadata: {
|
||||
requiresConfirmationThreshold: {
|
||||
time: 120,
|
||||
unit: "hours",
|
||||
},
|
||||
},
|
||||
length: 30,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
});
|
||||
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: "dailyvideo",
|
||||
});
|
||||
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({
|
||||
bookingData: mockBookingData,
|
||||
});
|
||||
expect(createdBooking.responses).toEqual(
|
||||
expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
})
|
||||
);
|
||||
|
||||
expect(createdBooking).toEqual(
|
||||
expect.objectContaining({
|
||||
location: BookingLocations.CalVideo,
|
||||
})
|
||||
);
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
iCalUID: createdBooking.iCalUID,
|
||||
});
|
||||
|
||||
expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails });
|
||||
expectWorkflowToBeTriggered({ emailsToReceive: [booker.email], emails });
|
||||
|
||||
expectBookingRequestedEmails({ booker, organizer, emails });
|
||||
|
||||
expectBookingRequestedWebhookToHaveBeenFired({
|
||||
booker,
|
||||
organizer,
|
||||
location: BookingLocations.CalVideo,
|
||||
subscriberUrl,
|
||||
eventType: scenarioData.eventTypes[0],
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
});
|
||||
|
||||
// FIXME: We shouldn't throw error here, the behaviour should be fixed.
|
||||
@@ -3271,195 +2866,6 @@ describe("handleNewBooking", () => {
|
||||
},
|
||||
timeout
|
||||
);
|
||||
// TODO: We should introduce a new state BOOKING.PAYMENT_PENDING that can clearly differentiate b/w pending confirmation(stuck on Organizer) and pending payment(stuck on booker)
|
||||
test(
|
||||
`Event Type that requires confirmation
|
||||
1. Should create a booking in the database with status PENDING
|
||||
2. Should send email to the booker for Payment request
|
||||
3. Should trigger BOOKING_PAYMENT_INITIATED webhook
|
||||
4. Should trigger BOOKING_PAYMENT_INITIATED workflow
|
||||
5. Once payment is successful, should trigger BOOKING_REQUESTED webhook
|
||||
6. Should trigger BOOKING_REQUESTED workflow
|
||||
7. Booking should still stay in pending state
|
||||
`,
|
||||
|
||||
async ({ emails }) => {
|
||||
const bookingInitiatedEmail = "booking_initiated@workflow.com";
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const subscriberUrl = "http://my-webhook.example.com";
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential(), getStripeAppCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
const scenarioData = getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl,
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
trigger: "NEW_EVENT",
|
||||
action: "EMAIL_HOST",
|
||||
template: "REMINDER",
|
||||
activeOn: [1],
|
||||
},
|
||||
{
|
||||
userId: organizer.id,
|
||||
trigger: "BOOKING_PAYMENT_INITIATED",
|
||||
verifiedAt: new Date("2023-01-01T00:00:00.000Z"),
|
||||
action: "EMAIL_ADDRESS",
|
||||
sendTo: bookingInitiatedEmail,
|
||||
template: "REMINDER",
|
||||
activeOn: [1],
|
||||
},
|
||||
{
|
||||
userId: organizer.id,
|
||||
trigger: "BOOKING_REQUESTED",
|
||||
action: "EMAIL_ATTENDEE",
|
||||
template: "REMINDER",
|
||||
activeOn: [1],
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 30,
|
||||
requiresConfirmation: true,
|
||||
metadata: {
|
||||
apps: {
|
||||
stripe: {
|
||||
price: 100,
|
||||
enabled: true,
|
||||
currency: "inr" /*, credentialId: 57*/,
|
||||
},
|
||||
},
|
||||
},
|
||||
length: 30,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [
|
||||
TestData.apps["google-calendar"],
|
||||
TestData.apps["daily-video"],
|
||||
TestData.apps["stripe-payment"],
|
||||
],
|
||||
});
|
||||
await createBookingScenario(scenarioData);
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: "dailyvideo",
|
||||
});
|
||||
const { paymentUid, externalId } = mockPaymentApp({
|
||||
metadataLookupKey: "stripe",
|
||||
appStoreLookupKey: "stripepayment",
|
||||
});
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar");
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
const createdBooking = await handleNewBooking({
|
||||
bookingData: mockBookingData,
|
||||
});
|
||||
|
||||
expect(createdBooking.responses).toEqual(
|
||||
expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
})
|
||||
);
|
||||
expect(createdBooking).toEqual(
|
||||
expect.objectContaining({
|
||||
location: BookingLocations.CalVideo,
|
||||
paymentUid: paymentUid,
|
||||
})
|
||||
);
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
});
|
||||
|
||||
expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails });
|
||||
expectWorkflowToBeNotTriggered({ emailsToReceive: [booker.email], emails });
|
||||
|
||||
expectAwaitingPaymentEmails({
|
||||
organizer,
|
||||
booker,
|
||||
emails,
|
||||
subject: "complete_your_booking_subject",
|
||||
});
|
||||
expectBookingPaymentIntiatedWebhookToHaveBeenFired({
|
||||
booker,
|
||||
organizer,
|
||||
location: BookingLocations.CalVideo,
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
|
||||
paymentId: createdBooking.paymentId!,
|
||||
});
|
||||
expectWorkflowToBeTriggered({ emailsToReceive: [bookingInitiatedEmail], emails });
|
||||
|
||||
// FIXME: Right now we need to reset the test Emails because email expects only tests first email content for an email address
|
||||
// Reset Test Emails to test for more Emails
|
||||
resetTestEmails();
|
||||
const { webhookResponse } = await mockPaymentSuccessWebhookFromStripe({ externalId });
|
||||
|
||||
expect(webhookResponse?.statusCode).toBe(200);
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
});
|
||||
|
||||
expectBookingRequestedEmails({
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
});
|
||||
expectBookingRequestedWebhookToHaveBeenFired({
|
||||
booker,
|
||||
organizer,
|
||||
location: BookingLocations.CalVideo,
|
||||
subscriberUrl,
|
||||
paidEvent: true,
|
||||
eventType: scenarioData.eventTypes[0],
|
||||
});
|
||||
expectWorkflowToBeTriggered({ emailsToReceive: [booker.email], emails });
|
||||
},
|
||||
timeout
|
||||
);
|
||||
test(
|
||||
`cannot book same slot multiple times `,
|
||||
|
||||
|
||||
@@ -690,211 +690,8 @@ describe("handleNewBooking", () => {
|
||||
);
|
||||
|
||||
describe("Event Type that requires confirmation", () => {
|
||||
test(
|
||||
`should reschedule a booking that requires confirmation in PENDING state - When a booker(who is not the organizer himself) is doing the reschedule
|
||||
1. Should cancel the existing booking
|
||||
2. Should delete existing calendar invite and Video meeting
|
||||
2. Should create a new booking in the database in PENDING state
|
||||
3. Should send BOOKING Requested scenario emails to the booker as well as organizer
|
||||
4. Should trigger BOOKING_REQUESTED webhook instead of BOOKING_RESCHEDULED
|
||||
`,
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const subscriberUrl = "http://my-webhook.example.com";
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
|
||||
|
||||
const scenarioData = getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl,
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
trigger: "RESCHEDULE_EVENT",
|
||||
action: "EMAIL_HOST",
|
||||
template: "REMINDER",
|
||||
activeOn: [1],
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 15,
|
||||
requiresConfirmation: true,
|
||||
length: 15,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
uid: uidOfBookingToBeRescheduled,
|
||||
eventTypeId: 1,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${plus1DateString}T05:00:00.000Z`,
|
||||
endTime: `${plus1DateString}T05:15:00.000Z`,
|
||||
references: [
|
||||
getMockBookingReference({
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||
credentialId: 0,
|
||||
}),
|
||||
getMockBookingReference({
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||
credentialId: 1,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
});
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const videoMock = mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: "dailyvideo",
|
||||
});
|
||||
|
||||
const calendarMock = await mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
uid: "MOCK_ID",
|
||||
},
|
||||
update: {
|
||||
uid: "UPDATED_MOCK_ID",
|
||||
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||
},
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
rescheduleUid: uidOfBookingToBeRescheduled,
|
||||
start: `${plus1DateString}T04:00:00.000Z`,
|
||||
end: `${plus1DateString}T04:15:00.000Z`,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({
|
||||
bookingData: mockBookingData,
|
||||
});
|
||||
expect(createdBooking.responses).toEqual(
|
||||
expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
})
|
||||
);
|
||||
|
||||
await expectBookingInDBToBeRescheduledFromTo({
|
||||
from: {
|
||||
uid: uidOfBookingToBeRescheduled,
|
||||
},
|
||||
to: {
|
||||
description: "",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
// Rescheduled booking sill stays in pending state
|
||||
status: BookingStatus.PENDING,
|
||||
location: BookingLocations.CalVideo,
|
||||
responses: expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
}),
|
||||
references: [
|
||||
{
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||
},
|
||||
{
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails });
|
||||
|
||||
expectBookingRequestedEmails({
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
});
|
||||
|
||||
expectBookingRequestedWebhookToHaveBeenFired({
|
||||
booker,
|
||||
organizer,
|
||||
location: BookingLocations.CalVideo,
|
||||
subscriberUrl,
|
||||
eventType: scenarioData.eventTypes[0],
|
||||
});
|
||||
|
||||
expectSuccessfulVideoMeetingDeletionInCalendar(videoMock, {
|
||||
bookingRef: {
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expectSuccessfulCalendarEventDeletionInCalendar(calendarMock, {
|
||||
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||
calEvent: {
|
||||
videoCallData: expect.objectContaining({
|
||||
url: "http://mock-dailyvideo.example.com",
|
||||
}),
|
||||
},
|
||||
uid: "MOCK_ID",
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
// NOTE: Tests for BOOKING_REQUESTED webhook have been moved to the new webhook architecture test suite
|
||||
// See packages/features/webhooks/lib/__tests__/ for tests using the producer/consumer pattern
|
||||
|
||||
test(
|
||||
`should rechedule a booking, that requires confirmation, without confirmation - When booker is the organizer of the existing booking as well as the event-type
|
||||
@@ -1381,216 +1178,6 @@ describe("handleNewBooking", () => {
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
`should rechedule a booking, that requires confirmation, in PENDING state - Even when the rescheduler is the organizer of the event-type but not the organizer of the existing booking
|
||||
1. Should cancel the existing booking
|
||||
2. Should delete existing calendar invite and Video meeting
|
||||
2. Should create a new booking in the database in PENDING state
|
||||
3. Should send booking requested emails to the booker as well as organizer
|
||||
4. Should trigger BOOKING_REQUESTED webhook
|
||||
`,
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const subscriberUrl = "http://my-webhook.example.com";
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
|
||||
const iCalUID = `${uidOfBookingToBeRescheduled}@Cal.com`;
|
||||
|
||||
const scenarioData = getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl,
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
trigger: "RESCHEDULE_EVENT",
|
||||
action: "EMAIL_HOST",
|
||||
template: "REMINDER",
|
||||
activeOn: [1],
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 15,
|
||||
requiresConfirmation: true,
|
||||
length: 15,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
uid: uidOfBookingToBeRescheduled,
|
||||
eventTypeId: 1,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${plus1DateString}T05:00:00.000Z`,
|
||||
endTime: `${plus1DateString}T05:15:00.000Z`,
|
||||
references: [
|
||||
getMockBookingReference({
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||
credentialId: 0,
|
||||
}),
|
||||
getMockBookingReference({
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||
credentialId: 1,
|
||||
}),
|
||||
],
|
||||
iCalUID,
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
});
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const videoMock = mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: "dailyvideo",
|
||||
});
|
||||
|
||||
const calendarMock = await mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
uid: "MOCK_ID",
|
||||
},
|
||||
update: {
|
||||
uid: "UPDATED_MOCK_ID",
|
||||
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||
},
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
rescheduleUid: uidOfBookingToBeRescheduled,
|
||||
start: `${plus1DateString}T04:00:00.000Z`,
|
||||
end: `${plus1DateString}T04:15:00.000Z`,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({
|
||||
bookingData: mockBookingData,
|
||||
// Fake the request to be from organizer
|
||||
userId: organizer.id,
|
||||
});
|
||||
expect(createdBooking.responses).toEqual(
|
||||
expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
})
|
||||
);
|
||||
|
||||
await expectBookingInDBToBeRescheduledFromTo({
|
||||
from: {
|
||||
uid: uidOfBookingToBeRescheduled,
|
||||
},
|
||||
to: {
|
||||
description: "",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
// Rescheduled booking sill stays in pending state
|
||||
status: BookingStatus.PENDING,
|
||||
location: BookingLocations.CalVideo,
|
||||
responses: expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
}),
|
||||
references: [
|
||||
{
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||
},
|
||||
{
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
//expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails });
|
||||
|
||||
expectBookingRequestedEmails({
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
});
|
||||
|
||||
expectBookingRequestedWebhookToHaveBeenFired({
|
||||
booker,
|
||||
organizer,
|
||||
location: BookingLocations.CalVideo,
|
||||
subscriberUrl,
|
||||
eventType: scenarioData.eventTypes[0],
|
||||
});
|
||||
|
||||
expectSuccessfulVideoMeetingDeletionInCalendar(videoMock, {
|
||||
bookingRef: {
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expectSuccessfulCalendarEventDeletionInCalendar(calendarMock, {
|
||||
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||
calEvent: {
|
||||
videoCallData: expect.objectContaining({
|
||||
url: "http://mock-dailyvideo.example.com",
|
||||
}),
|
||||
},
|
||||
uid: "MOCK_ID",
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
`should rechedule a booking, that requires confirmation, without confirmation - When the owner of the previous booking is doing the reschedule(but he isn't the organizer of the event-type now)
|
||||
1. Should cancel the existing booking
|
||||
|
||||
+2
-200
@@ -1011,207 +1011,9 @@ describe("handleNewBooking", () => {
|
||||
},
|
||||
timeout
|
||||
);
|
||||
test(
|
||||
`[Event Type that requires confirmation with only Attendee Phone number as required field and Email as optional field] successfully creates a booking when the users are available as per the common schedule selected in the event-type
|
||||
- Destination calendars for event-type and non-first hosts are used to create calendar events
|
||||
`,
|
||||
async ({ emails, sms }) => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const org = await createOrganization({
|
||||
name: "Test Org",
|
||||
slug: "testorg",
|
||||
});
|
||||
const subscriberUrl = "http://my-webhook.example.com";
|
||||
const TEST_ATTENDEE_NUMBER = "+918888888888";
|
||||
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
attendeePhoneNumber: TEST_ATTENDEE_NUMBER,
|
||||
});
|
||||
|
||||
const otherTeamMembers = [
|
||||
{
|
||||
name: "Other Team Member 1",
|
||||
username: "other-team-member-1",
|
||||
timeZone: Timezones["+5:30"],
|
||||
defaultScheduleId: null,
|
||||
email: "other-team-member-1@example.com",
|
||||
id: 102,
|
||||
schedules: [TestData.schedules.IstEveningShift],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
destinationCalendar: {
|
||||
integration: TestData.apps["google-calendar"].type,
|
||||
externalId: "other-team-member-1@google-calendar.com",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
defaultScheduleId: null,
|
||||
organizationId: org.id,
|
||||
schedules: [TestData.schedules.IstMorningShift],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
destinationCalendar: {
|
||||
integration: TestData.apps["google-calendar"].type,
|
||||
externalId: "organizer@google-calendar.com",
|
||||
},
|
||||
teams: [
|
||||
{
|
||||
membership: {
|
||||
accepted: true,
|
||||
},
|
||||
team: {
|
||||
id: 1,
|
||||
name: "Team 1",
|
||||
slug: "team-1",
|
||||
parentId: org.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const scenarioData = getScenarioData(
|
||||
{
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
teamId: 1,
|
||||
slotInterval: 15,
|
||||
requiresConfirmation: true,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
length: 15,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
},
|
||||
],
|
||||
// Both Email and Attendee Phone Number Fields are required
|
||||
bookingFields: getDefaultBookingFields({
|
||||
emailField: {
|
||||
name: "email",
|
||||
type: "email",
|
||||
label: "",
|
||||
hidden: true,
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system-but-optional",
|
||||
required: false,
|
||||
placeholder: "",
|
||||
defaultLabel: "email_address",
|
||||
},
|
||||
bookingFields: [
|
||||
{
|
||||
name: "attendeePhoneNumber",
|
||||
type: "phone",
|
||||
hidden: false,
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system-but-optional",
|
||||
required: true,
|
||||
defaultLabel: "phone_number",
|
||||
},
|
||||
],
|
||||
}),
|
||||
// Common schedule is the morning shift
|
||||
schedule: TestData.schedules.IstMorningShift,
|
||||
destinationCalendar: {
|
||||
integration: TestData.apps["google-calendar"].type,
|
||||
externalId: "event-type-1@google-calendar.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
usersApartFromOrganizer: otherTeamMembers,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
},
|
||||
{ id: org.id }
|
||||
);
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: appStoreMetadata.dailyvideo.dirName,
|
||||
videoMeetingData: {
|
||||
id: "MOCK_ID",
|
||||
password: "MOCK_PASS",
|
||||
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
||||
},
|
||||
});
|
||||
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
||||
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||
},
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
// Try booking the first available free timeslot in both the users' schedules
|
||||
start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
|
||||
end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
|
||||
eventTypeId: 1,
|
||||
// No Email Passed
|
||||
responses: {
|
||||
name: booker.name,
|
||||
email: "",
|
||||
attendeePhoneNumber: booker.attendeePhoneNumber,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({
|
||||
bookingData: mockBookingData,
|
||||
});
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
location: BookingLocations.CalVideo,
|
||||
responses: expect.objectContaining({
|
||||
attendeePhoneNumber: booker.attendeePhoneNumber,
|
||||
name: booker.name,
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
});
|
||||
|
||||
expectBookingRequestedEmails({
|
||||
organizer,
|
||||
emails,
|
||||
});
|
||||
|
||||
expectBookingRequestedWebhookToHaveBeenFired({
|
||||
booker: { name: booker.name, email: contructEmailFromPhoneNumber(TEST_ATTENDEE_NUMBER) },
|
||||
organizer,
|
||||
location: BookingLocations.CalVideo,
|
||||
subscriberUrl,
|
||||
eventType: scenarioData.eventTypes[0],
|
||||
isEmailHidden: true,
|
||||
});
|
||||
|
||||
expectSMSToBeTriggered({ sms, toNumber: TEST_ATTENDEE_NUMBER });
|
||||
},
|
||||
timeout
|
||||
);
|
||||
// NOTE: Tests for BOOKING_REQUESTED webhook have been moved to the new webhook architecture test suite
|
||||
// See packages/features/webhooks/lib/__tests__/ for tests using the producer/consumer pattern
|
||||
|
||||
test(
|
||||
`rejects a booking when the timeslot isn't within the common schedule`,
|
||||
|
||||
+956
@@ -0,0 +1,956 @@
|
||||
/**
|
||||
* Integration tests verifying that the booking flow correctly invokes the webhook producer
|
||||
* for BOOKING_REQUESTED scenarios.
|
||||
*
|
||||
* These tests exercise the full handleNewBooking path (event type creation → booking → webhook producer invocation)
|
||||
* and assert that deps.webhookProducer.queueBookingRequestedWebhook is called (or not called) with correct params.
|
||||
*
|
||||
* This pattern is extendable: as more webhook triggers are migrated to the producer/consumer architecture,
|
||||
* new test files can follow this same structure — mock the producer, run the booking flow, assert on the mock.
|
||||
*/
|
||||
import {
|
||||
expectWebhookProducerCalled,
|
||||
expectWebhookProducerNotCalled,
|
||||
type MockWebhookProducer,
|
||||
resetMockWebhookProducer,
|
||||
} from "@calcom/testing/lib/webhookProducer";
|
||||
|
||||
const mockWebhookProducer: MockWebhookProducer = vi.hoisted(() => ({
|
||||
queueBookingCreatedWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueBookingCancelledWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueBookingRescheduledWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueBookingRequestedWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueBookingRejectedWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueBookingPaymentInitiatedWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueBookingPaidWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueBookingNoShowUpdatedWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueFormSubmittedWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueRecordingReadyWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueOOOCreatedWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/webhooks/lib/service/WebhookTaskerProducerService", () => {
|
||||
const MockProducer = class {
|
||||
queueBookingCreatedWebhook = mockWebhookProducer.queueBookingCreatedWebhook;
|
||||
queueBookingCancelledWebhook = mockWebhookProducer.queueBookingCancelledWebhook;
|
||||
queueBookingRescheduledWebhook = mockWebhookProducer.queueBookingRescheduledWebhook;
|
||||
queueBookingRequestedWebhook = mockWebhookProducer.queueBookingRequestedWebhook;
|
||||
queueBookingRejectedWebhook = mockWebhookProducer.queueBookingRejectedWebhook;
|
||||
queueBookingPaymentInitiatedWebhook = mockWebhookProducer.queueBookingPaymentInitiatedWebhook;
|
||||
queueBookingPaidWebhook = mockWebhookProducer.queueBookingPaidWebhook;
|
||||
queueBookingNoShowUpdatedWebhook = mockWebhookProducer.queueBookingNoShowUpdatedWebhook;
|
||||
queueFormSubmittedWebhook = mockWebhookProducer.queueFormSubmittedWebhook;
|
||||
queueRecordingReadyWebhook = mockWebhookProducer.queueRecordingReadyWebhook;
|
||||
queueOOOCreatedWebhook = mockWebhookProducer.queueOOOCreatedWebhook;
|
||||
};
|
||||
return { WebhookTaskerProducerService: MockProducer };
|
||||
});
|
||||
|
||||
import {
|
||||
BookingLocations,
|
||||
createBookingScenario,
|
||||
getBooker,
|
||||
getDate,
|
||||
getGoogleCalendarCredential,
|
||||
getMockBookingReference,
|
||||
getOrganizer,
|
||||
getScenarioData,
|
||||
getStripeAppCredential,
|
||||
mockCalendarToHaveNoBusySlots,
|
||||
mockPaymentApp,
|
||||
mockSuccessfulVideoMeetingCreation,
|
||||
TestData,
|
||||
} from "@calcom/testing/lib/bookingScenario/bookingScenario";
|
||||
import process from "node:process";
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
|
||||
import type { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { resetTestEmails } from "@calcom/lib/testEmails";
|
||||
import { distributedTracing } from "@calcom/lib/tracing/factory";
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
import {
|
||||
expectBookingInDBToBeRescheduledFromTo,
|
||||
expectBookingRequestedEmails,
|
||||
expectBookingToBeInDatabase,
|
||||
} from "@calcom/testing/lib/bookingScenario/expects";
|
||||
import { getMockRequestDataForBooking } from "@calcom/testing/lib/bookingScenario/getMockRequestDataForBooking";
|
||||
import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown";
|
||||
import { test } from "@calcom/testing/lib/fixtures/fixtures";
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type Stripe from "stripe";
|
||||
import { beforeEach, describe, expect, vi } from "vitest";
|
||||
import { getNewBookingHandler } from "./getNewBookingHandler";
|
||||
|
||||
export type CustomNextApiRequest = NextApiRequest & Request;
|
||||
export type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[webhook-producer-booking-requested.test]"] });
|
||||
const timeout = process.env.CI ? 5000 : 20000;
|
||||
|
||||
function getMockedStripePaymentEvent({ paymentIntentId }: { paymentIntentId: string }) {
|
||||
return {
|
||||
id: null,
|
||||
data: {
|
||||
object: {
|
||||
id: paymentIntentId,
|
||||
},
|
||||
},
|
||||
} as unknown as Stripe.Event;
|
||||
}
|
||||
|
||||
async function mockPaymentSuccessWebhookFromStripe({ externalId }: { externalId: string }) {
|
||||
let webhookResponse = null;
|
||||
try {
|
||||
const traceContext = distributedTracing.createTrace("test_stripe_webhook");
|
||||
await handleStripePaymentSuccess(
|
||||
getMockedStripePaymentEvent({ paymentIntentId: externalId }),
|
||||
traceContext
|
||||
);
|
||||
} catch (e) {
|
||||
log.silly("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e));
|
||||
webhookResponse = e as HttpError;
|
||||
}
|
||||
return { webhookResponse };
|
||||
}
|
||||
|
||||
describe("Webhook Producer – BOOKING_REQUESTED", () => {
|
||||
setupAndTeardown();
|
||||
|
||||
beforeEach(() => {
|
||||
resetMockWebhookProducer(mockWebhookProducer);
|
||||
});
|
||||
|
||||
describe("Fresh booking with confirmation required", () => {
|
||||
test(
|
||||
"should call queueBookingRequestedWebhook when event type requires confirmation",
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 30,
|
||||
requiresConfirmation: true,
|
||||
length: 30,
|
||||
users: [{ id: 101 }],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({ metadataLookupKey: "dailyvideo" });
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({ bookingData: mockBookingData });
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
});
|
||||
|
||||
expectWebhookProducerCalled(mockWebhookProducer, "queueBookingRequestedWebhook", {
|
||||
bookingUid: createdBooking.uid,
|
||||
eventTypeId: 1,
|
||||
userId: organizer.id,
|
||||
});
|
||||
|
||||
expectWebhookProducerNotCalled(mockWebhookProducer, "queueBookingCreatedWebhook");
|
||||
expectWebhookProducerNotCalled(mockWebhookProducer, "queueBookingRescheduledWebhook");
|
||||
|
||||
expectBookingRequestedEmails({ booker, organizer, emails });
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
"should call queueBookingRequestedWebhook even when booker is the organizer",
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 30,
|
||||
requiresConfirmation: true,
|
||||
length: 30,
|
||||
users: [{ id: 101 }],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({ metadataLookupKey: "dailyvideo" });
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({
|
||||
bookingData: mockBookingData,
|
||||
userId: organizer.id,
|
||||
});
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
});
|
||||
|
||||
expectWebhookProducerCalled(mockWebhookProducer, "queueBookingRequestedWebhook", {
|
||||
bookingUid: createdBooking.uid,
|
||||
eventTypeId: 1,
|
||||
userId: organizer.id,
|
||||
});
|
||||
|
||||
expectBookingRequestedEmails({ booker, organizer, emails });
|
||||
},
|
||||
timeout
|
||||
);
|
||||
});
|
||||
|
||||
describe("Confirmation threshold", () => {
|
||||
test(
|
||||
"should NOT call queueBookingRequestedWebhook when confirmation threshold is not met",
|
||||
async () => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 30,
|
||||
requiresConfirmation: true,
|
||||
metadata: {
|
||||
requiresConfirmationThreshold: {
|
||||
time: 30,
|
||||
unit: "minutes",
|
||||
},
|
||||
},
|
||||
length: 30,
|
||||
users: [{ id: 101 }],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({ metadataLookupKey: "dailyvideo" });
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({ bookingData: mockBookingData });
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
});
|
||||
|
||||
expectWebhookProducerNotCalled(mockWebhookProducer, "queueBookingRequestedWebhook");
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
"should call queueBookingRequestedWebhook when confirmation threshold IS met",
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 30,
|
||||
requiresConfirmation: true,
|
||||
metadata: {
|
||||
requiresConfirmationThreshold: {
|
||||
time: 120,
|
||||
unit: "hours",
|
||||
},
|
||||
},
|
||||
length: 30,
|
||||
users: [{ id: 101 }],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({ metadataLookupKey: "dailyvideo" });
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
start: `${plus3DateString}T05:00:00.000Z`,
|
||||
end: `${plus3DateString}T05:30:00.000Z`,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({ bookingData: mockBookingData });
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
});
|
||||
|
||||
expectWebhookProducerCalled(mockWebhookProducer, "queueBookingRequestedWebhook", {
|
||||
bookingUid: createdBooking.uid,
|
||||
eventTypeId: 1,
|
||||
userId: organizer.id,
|
||||
});
|
||||
|
||||
expectBookingRequestedEmails({ booker, organizer, emails });
|
||||
},
|
||||
timeout
|
||||
);
|
||||
});
|
||||
|
||||
describe("Paid event with confirmation required", () => {
|
||||
test(
|
||||
"should call queueBookingRequestedWebhook after payment succeeds for paid event requiring confirmation",
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential(), getStripeAppCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 30,
|
||||
requiresConfirmation: true,
|
||||
metadata: {
|
||||
apps: {
|
||||
stripe: {
|
||||
price: 100,
|
||||
enabled: true,
|
||||
currency: "inr",
|
||||
},
|
||||
},
|
||||
},
|
||||
length: 30,
|
||||
users: [{ id: 101 }],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [
|
||||
TestData.apps["google-calendar"],
|
||||
TestData.apps["daily-video"],
|
||||
TestData.apps["stripe-payment"],
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({ metadataLookupKey: "dailyvideo" });
|
||||
const { paymentUid, externalId } = mockPaymentApp({
|
||||
metadataLookupKey: "stripe",
|
||||
appStoreLookupKey: "stripepayment",
|
||||
});
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar");
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({ bookingData: mockBookingData });
|
||||
|
||||
expect(createdBooking).toEqual(
|
||||
expect.objectContaining({
|
||||
location: BookingLocations.CalVideo,
|
||||
paymentUid,
|
||||
})
|
||||
);
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
});
|
||||
|
||||
expectWebhookProducerNotCalled(mockWebhookProducer, "queueBookingRequestedWebhook");
|
||||
|
||||
resetTestEmails();
|
||||
resetMockWebhookProducer(mockWebhookProducer);
|
||||
const { webhookResponse } = await mockPaymentSuccessWebhookFromStripe({ externalId });
|
||||
|
||||
expect(webhookResponse?.statusCode).toBe(200);
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
});
|
||||
|
||||
expectWebhookProducerCalled(mockWebhookProducer, "queueBookingRequestedWebhook", {
|
||||
bookingUid: createdBooking.uid,
|
||||
eventTypeId: 1,
|
||||
userId: organizer.id,
|
||||
});
|
||||
|
||||
expectBookingRequestedEmails({ booker, organizer, emails });
|
||||
},
|
||||
timeout
|
||||
);
|
||||
});
|
||||
|
||||
describe("Reschedule with confirmation required", () => {
|
||||
test(
|
||||
"should call queueBookingRequestedWebhook when non-organizer reschedules a booking requiring confirmation",
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 15,
|
||||
requiresConfirmation: true,
|
||||
length: 15,
|
||||
users: [{ id: 101 }],
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
uid: uidOfBookingToBeRescheduled,
|
||||
eventTypeId: 1,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${plus1DateString}T05:00:00.000Z`,
|
||||
endTime: `${plus1DateString}T05:15:00.000Z`,
|
||||
references: [
|
||||
getMockBookingReference({
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||
credentialId: 0,
|
||||
}),
|
||||
getMockBookingReference({
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||
credentialId: 1,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({ metadataLookupKey: "dailyvideo" });
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: { uid: "MOCK_ID" },
|
||||
update: { uid: "UPDATED_MOCK_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID" },
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
rescheduleUid: uidOfBookingToBeRescheduled,
|
||||
start: `${plus1DateString}T04:00:00.000Z`,
|
||||
end: `${plus1DateString}T04:15:00.000Z`,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({ bookingData: mockBookingData });
|
||||
|
||||
await expectBookingInDBToBeRescheduledFromTo({
|
||||
from: { uid: uidOfBookingToBeRescheduled },
|
||||
to: {
|
||||
description: "",
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
location: BookingLocations.CalVideo,
|
||||
responses: expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expectWebhookProducerCalled(mockWebhookProducer, "queueBookingRequestedWebhook", {
|
||||
bookingUid: createdBooking.uid,
|
||||
eventTypeId: 1,
|
||||
userId: organizer.id,
|
||||
});
|
||||
|
||||
expectWebhookProducerNotCalled(mockWebhookProducer, "queueBookingRescheduledWebhook");
|
||||
|
||||
expectBookingRequestedEmails({ booker, organizer, emails });
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
"should NOT call queueBookingRequestedWebhook when organizer reschedules their own booking requiring confirmation",
|
||||
async () => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
requiresConfirmation: true,
|
||||
slotInterval: 15,
|
||||
length: 15,
|
||||
users: [{ id: 101 }],
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "event-type-1@example.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
uid: uidOfBookingToBeRescheduled,
|
||||
eventTypeId: 1,
|
||||
userId: organizer.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${plus1DateString}T05:00:00.000Z`,
|
||||
endTime: `${plus1DateString}T05:15:00.000Z`,
|
||||
references: [
|
||||
getMockBookingReference({
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||
credentialId: 0,
|
||||
}),
|
||||
getMockBookingReference({
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||
credentialId: 1,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({ metadataLookupKey: "dailyvideo" });
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: { uid: "MOCK_ID" },
|
||||
update: { uid: "UPDATED_MOCK_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID" },
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
rescheduleUid: uidOfBookingToBeRescheduled,
|
||||
start: `${plus1DateString}T04:00:00.000Z`,
|
||||
end: `${plus1DateString}T04:15:00.000Z`,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({
|
||||
bookingData: mockBookingData,
|
||||
userId: organizer.id,
|
||||
});
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
});
|
||||
|
||||
expectWebhookProducerNotCalled(mockWebhookProducer, "queueBookingRequestedWebhook");
|
||||
},
|
||||
timeout
|
||||
);
|
||||
});
|
||||
|
||||
describe("Collective scheduling with confirmation required", () => {
|
||||
test(
|
||||
"should call queueBookingRequestedWebhook for collective team booking requiring confirmation",
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = getNewBookingHandler();
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const otherTeamMembers = [
|
||||
{
|
||||
name: "Other Team Member 1",
|
||||
username: "other-team-member-1",
|
||||
timeZone: "Asia/Kolkata",
|
||||
defaultScheduleId: null,
|
||||
email: "other-team-member-1@example.com",
|
||||
id: 102,
|
||||
schedules: [TestData.schedules.IstEveningShift],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
destinationCalendar: {
|
||||
integration: TestData.apps["google-calendar"].type,
|
||||
externalId: "other-team-member-1@google-calendar.com",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
defaultScheduleId: null,
|
||||
schedules: [TestData.schedules.IstMorningShift],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
destinationCalendar: {
|
||||
integration: TestData.apps["google-calendar"].type,
|
||||
externalId: "organizer@google-calendar.com",
|
||||
},
|
||||
teams: [
|
||||
{
|
||||
membership: { accepted: true },
|
||||
team: {
|
||||
id: 1,
|
||||
name: "Team 1",
|
||||
slug: "team-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
teamId: 1,
|
||||
slotInterval: 15,
|
||||
requiresConfirmation: true,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
length: 15,
|
||||
users: [{ id: 101 }, { id: 102 }],
|
||||
schedule: TestData.schedules.IstMorningShift,
|
||||
destinationCalendar: {
|
||||
integration: TestData.apps["google-calendar"].type,
|
||||
externalId: "event-type-1@google-calendar.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
usersApartFromOrganizer: otherTeamMembers,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: appStoreMetadata.dailyvideo.dirName,
|
||||
videoMeetingData: {
|
||||
id: "MOCK_ID",
|
||||
password: "MOCK_PASS",
|
||||
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
||||
},
|
||||
});
|
||||
|
||||
mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
||||
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||
},
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
|
||||
end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({ bookingData: mockBookingData });
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
});
|
||||
|
||||
expectWebhookProducerCalled(mockWebhookProducer, "queueBookingRequestedWebhook", {
|
||||
bookingUid: createdBooking.uid,
|
||||
eventTypeId: 1,
|
||||
teamId: 1,
|
||||
});
|
||||
|
||||
expectWebhookProducerNotCalled(mockWebhookProducer, "queueBookingCreatedWebhook");
|
||||
|
||||
expectBookingRequestedEmails({ organizer, emails });
|
||||
},
|
||||
timeout
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,7 @@ import type { UserRepository } from "@calcom/features/users/repositories/UserRep
|
||||
import { UsersRepository } from "@calcom/features/users/users.repository";
|
||||
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import type { IWebhookProducerService } from "@calcom/features/webhooks/lib/interface/WebhookProducerService";
|
||||
import {
|
||||
cancelNoShowTasksForBooking,
|
||||
deleteWebhookScheduledTriggers,
|
||||
@@ -483,6 +484,7 @@ export interface IBookingServiceDependencies {
|
||||
bookingEmailAndSmsTasker: BookingEmailAndSmsTasker;
|
||||
featuresRepository: FeaturesRepository;
|
||||
bookingEventHandler: BookingEventHandlerService;
|
||||
webhookProducer: IWebhookProducerService;
|
||||
}
|
||||
|
||||
async function validateRescheduleRestrictions({
|
||||
@@ -2740,18 +2742,6 @@ async function handler(
|
||||
isDryRun,
|
||||
traceContext,
|
||||
});
|
||||
} else {
|
||||
// if eventType requires confirmation we will trigger the BOOKING REQUESTED Webhook
|
||||
const eventTrigger: WebhookTriggerEvents = WebhookTriggerEvents.BOOKING_REQUESTED;
|
||||
subscriberOptions.triggerEvent = eventTrigger;
|
||||
webhookData.status = "PENDING";
|
||||
await handleWebhookTrigger({
|
||||
subscriberOptions,
|
||||
eventTrigger,
|
||||
webhookData,
|
||||
isDryRun,
|
||||
traceContext,
|
||||
});
|
||||
}
|
||||
|
||||
if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" });
|
||||
@@ -2777,6 +2767,27 @@ async function handler(
|
||||
tracingLogger.error("Error while creating booking references", JSON.stringify({ error }));
|
||||
}
|
||||
|
||||
// Queue BOOKING_REQUESTED webhook after booking update so consumer fetches booking with location, metadata, references
|
||||
if (booking && booking.status === BookingStatus.PENDING && !isDryRun) {
|
||||
try {
|
||||
await deps.webhookProducer.queueBookingRequestedWebhook({
|
||||
bookingUid: booking.uid,
|
||||
userId: subscriberOptions.userId ?? undefined,
|
||||
eventTypeId: subscriberOptions.eventTypeId ?? undefined,
|
||||
teamId: Array.isArray(subscriberOptions.teamId)
|
||||
? subscriberOptions.teamId[0]
|
||||
: (subscriberOptions.teamId ?? undefined),
|
||||
orgId: subscriberOptions.orgId ?? undefined,
|
||||
oAuthClientId: platformClientId ?? undefined,
|
||||
});
|
||||
} catch (webhookError) {
|
||||
tracingLogger.error(
|
||||
`Error queueing BOOKING_REQUESTED webhook: bookingId: ${booking.id}, bookingUid: ${booking.uid}`,
|
||||
safeStringify(webhookError)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const evtWithMetadata = {
|
||||
...evt,
|
||||
rescheduleReason,
|
||||
|
||||
@@ -200,6 +200,7 @@ const buildWhereClauseForActiveBookings = ({
|
||||
});
|
||||
|
||||
const selectStatementToGetBookingForCalEventBuilder = {
|
||||
id: true,
|
||||
uid: true,
|
||||
title: true,
|
||||
startTime: true,
|
||||
@@ -212,6 +213,13 @@ const selectStatementToGetBookingForCalEventBuilder = {
|
||||
iCalUID: true,
|
||||
iCalSequence: true,
|
||||
oneTimePassword: true,
|
||||
status: true,
|
||||
eventTypeId: true,
|
||||
userId: true,
|
||||
smsReminderNumber: true,
|
||||
cancellationReason: true,
|
||||
rejectionReason: true,
|
||||
rescheduledBy: true,
|
||||
attendees: {
|
||||
select: {
|
||||
name: true,
|
||||
@@ -254,6 +262,9 @@ const selectStatementToGetBookingForCalEventBuilder = {
|
||||
slug: true,
|
||||
description: true,
|
||||
hideCalendarNotes: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
length: true,
|
||||
hideCalendarEventDetails: true,
|
||||
hideOrganizerEmail: true,
|
||||
schedulingType: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Booking } from "@calcom/prisma/client";
|
||||
import type { BookingForCalEventBuilder } from "@calcom/features/CalendarEventBuilder";
|
||||
import type { Booking } from "@calcom/prisma/client";
|
||||
import type { BookingStatus, WorkflowMethods } from "@calcom/prisma/enums";
|
||||
|
||||
export interface BookingWhereInput {
|
||||
@@ -52,4 +53,6 @@ export interface IBookingRepository {
|
||||
findManyIncludeWorkflowRemindersAndReferences(params: {
|
||||
where: BookingWhereInput;
|
||||
}): Promise<BookingWithWorkflowReminders[]>;
|
||||
|
||||
getBookingForCalEventBuilderFromUid(bookingUid: string): Promise<BookingForCalEventBuilder | null>;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { IWebhookProducerService } from "@calcom/features/webhooks/lib/inte
|
||||
import type { IWebhookNotifier } from "@calcom/features/webhooks/lib/interface/webhook";
|
||||
import type { WebhookTaskConsumer } from "@calcom/features/webhooks/lib/service/WebhookTaskConsumer";
|
||||
import { type Container, createContainer } from "@evyweb/ioctopus";
|
||||
import { moduleLoader as bookingRepositoryModuleLoader } from "../../modules/Booking";
|
||||
import { moduleLoader as prismaModuleLoader } from "../../modules/Prisma";
|
||||
import { moduleLoader as loggerModuleLoader } from "../../shared/services/logger.service";
|
||||
import { taskerServiceModule } from "../../shared/services/tasker.service";
|
||||
@@ -30,6 +31,7 @@ const webhookContainer: Container = createContainer();
|
||||
// Load shared infrastructure
|
||||
loggerModuleLoader.loadModule(webhookContainer);
|
||||
prismaModuleLoader.loadModule(webhookContainer);
|
||||
bookingRepositoryModuleLoader.loadModule(webhookContainer);
|
||||
webhookContainer.load(SHARED_TOKENS.TASKER, taskerServiceModule);
|
||||
|
||||
// Load webhook module (includes cross-table repositories + all webhook services)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { BookingWebhookDataFetcher } from "@calcom/features/webhooks/lib/service/data-fetchers/BookingWebhookDataFetcher";
|
||||
import { createModule } from "@evyweb/ioctopus";
|
||||
import { createModule, type Module } from "@evyweb/ioctopus";
|
||||
import { SHARED_TOKENS } from "../../shared/shared.tokens";
|
||||
import { DI_TOKENS } from "../../tokens";
|
||||
import { WEBHOOK_TOKENS } from "../Webhooks.tokens";
|
||||
|
||||
export const bookingWebhookDataFetcherModule = createModule();
|
||||
const bookingWebhookDataFetcherModule: Module = createModule();
|
||||
|
||||
bookingWebhookDataFetcherModule
|
||||
.bind(WEBHOOK_TOKENS.BOOKING_DATA_FETCHER)
|
||||
.toClass(BookingWebhookDataFetcher, [SHARED_TOKENS.LOGGER]);
|
||||
.toClass(BookingWebhookDataFetcher, [SHARED_TOKENS.LOGGER, DI_TOKENS.BOOKING_REPOSITORY]);
|
||||
|
||||
export { bookingWebhookDataFetcherModule };
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { PayloadBuilderFactory } from "@calcom/features/webhooks/lib/factory/versioned/PayloadBuilderFactory";
|
||||
import type { IWebhookDataFetcher } from "@calcom/features/webhooks/lib/interface/IWebhookDataFetcher";
|
||||
import type { IWebhookRepository } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
|
||||
import type { ILogger } from "@calcom/features/webhooks/lib/interface/infrastructure";
|
||||
import type { IWebhookService } from "@calcom/features/webhooks/lib/interface/services";
|
||||
import { WebhookTaskConsumer } from "@calcom/features/webhooks/lib/service/WebhookTaskConsumer";
|
||||
import type { ResolveFunction } from "@evyweb/ioctopus";
|
||||
import { createModule } from "@evyweb/ioctopus";
|
||||
import { createModule, type Module } from "@evyweb/ioctopus";
|
||||
import { SHARED_TOKENS } from "../../shared/shared.tokens";
|
||||
import { WEBHOOK_TOKENS } from "../Webhooks.tokens";
|
||||
|
||||
@@ -11,12 +13,11 @@ import { WEBHOOK_TOKENS } from "../Webhooks.tokens";
|
||||
* Consumer Module
|
||||
*
|
||||
* Binds the heavy WebhookTaskConsumer.
|
||||
* Dependencies: WebhookRepository, Data Fetchers array (Strategy Pattern), Logger
|
||||
* Dependencies: WebhookRepository, Data Fetchers (Strategy Pattern), PayloadBuilderFactory, WebhookService, Logger
|
||||
*
|
||||
* Uses Strategy Pattern: Data fetchers are injected as an array,
|
||||
* consumer uses polymorphism to route to the correct fetcher.
|
||||
* Uses Strategy Pattern for data fetching and proper DI for HTTP delivery via WebhookService.
|
||||
*/
|
||||
export const webhookTaskConsumerModule = createModule();
|
||||
const webhookTaskConsumerModule: Module = createModule();
|
||||
|
||||
webhookTaskConsumerModule.bind(WEBHOOK_TOKENS.WEBHOOK_TASK_CONSUMER).toFactory((resolve: ResolveFunction) => {
|
||||
const webhookRepository = resolve(WEBHOOK_TOKENS.WEBHOOK_REPOSITORY) as IWebhookRepository;
|
||||
@@ -27,7 +28,18 @@ webhookTaskConsumerModule.bind(WEBHOOK_TOKENS.WEBHOOK_TASK_CONSUMER).toFactory((
|
||||
resolve(WEBHOOK_TOKENS.RECORDING_DATA_FETCHER) as IWebhookDataFetcher,
|
||||
resolve(WEBHOOK_TOKENS.OOO_DATA_FETCHER) as IWebhookDataFetcher,
|
||||
];
|
||||
const payloadBuilderFactory: PayloadBuilderFactory = resolve(
|
||||
WEBHOOK_TOKENS.PAYLOAD_BUILDER_FACTORY
|
||||
) as PayloadBuilderFactory;
|
||||
const webhookService: IWebhookService = resolve(WEBHOOK_TOKENS.WEBHOOK_SERVICE) as IWebhookService;
|
||||
const logger = resolve(SHARED_TOKENS.LOGGER) as ILogger;
|
||||
|
||||
return new WebhookTaskConsumer(webhookRepository, dataFetchers, logger);
|
||||
return new WebhookTaskConsumer(
|
||||
webhookRepository,
|
||||
dataFetchers,
|
||||
payloadBuilderFactory,
|
||||
webhookService,
|
||||
logger
|
||||
);
|
||||
});
|
||||
export { webhookTaskConsumerModule };
|
||||
|
||||
@@ -21,6 +21,7 @@ export default defineConfig({
|
||||
"./calendars/lib/tasker/trigger",
|
||||
"./ee/billing/service/proration/tasker/trigger",
|
||||
"./ee/organizations/lib/billing/tasker/trigger",
|
||||
"./webhooks/lib/tasker/trigger",
|
||||
], // Customize based on your project structure
|
||||
|
||||
// Retry configuration
|
||||
|
||||
+43
-98
@@ -1,26 +1,37 @@
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IWebhookDataFetcher } from "../../interface/IWebhookDataFetcher";
|
||||
import type { IWebhookRepository } from "../../interface/IWebhookRepository";
|
||||
import { WebhookVersion } from "../../interface/IWebhookRepository";
|
||||
import type { ILogger } from "../../interface/infrastructure";
|
||||
import type { IWebhookService } from "../../interface/services";
|
||||
import type { PayloadBuilderFactory } from "../../factory/versioned/PayloadBuilderFactory";
|
||||
import type { WebhookTaskPayload } from "../../types/webhookTask";
|
||||
import { WebhookTaskConsumer } from "../WebhookTaskConsumer";
|
||||
import { WebhookTaskConsumer } from "../../service/WebhookTaskConsumer";
|
||||
|
||||
/**
|
||||
* Unit Tests for WebhookTaskConsumer
|
||||
*
|
||||
* Tests the heavy Consumer service for processing webhook delivery tasks.
|
||||
* Tests the Consumer service for processing webhook delivery tasks.
|
||||
* The consumer handles the heavy lifting: DB queries, payload building, HTTP delivery.
|
||||
*
|
||||
* Constructor signature:
|
||||
* - webhookRepository: IWebhookRepository
|
||||
* - dataFetchers: IWebhookDataFetcher[]
|
||||
* - payloadBuilderFactory: PayloadBuilderFactory
|
||||
* - webhookService: IWebhookService
|
||||
* - logger: ILogger
|
||||
*/
|
||||
describe("WebhookTaskConsumer", () => {
|
||||
let consumer: WebhookTaskConsumer;
|
||||
let mockWebhookRepository: IWebhookRepository;
|
||||
let mockDataFetchers: IWebhookDataFetcher[];
|
||||
let mockPayloadBuilderFactory: PayloadBuilderFactory;
|
||||
let mockWebhookService: IWebhookService;
|
||||
let mockLogger: ILogger;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock Repository
|
||||
mockWebhookRepository = {
|
||||
getSubscribers: vi.fn().mockResolvedValue([]),
|
||||
getWebhookById: vi.fn(),
|
||||
@@ -29,7 +40,6 @@ describe("WebhookTaskConsumer", () => {
|
||||
getFilteredWebhooksForUser: vi.fn(),
|
||||
} as unknown as IWebhookRepository;
|
||||
|
||||
// Mock Data Fetchers (Strategy Pattern implementations)
|
||||
const createMockFetcher = (triggerEvents: string[]): IWebhookDataFetcher => ({
|
||||
canHandle: vi.fn((event) => triggerEvents.includes(event)),
|
||||
fetchEventData: vi.fn().mockResolvedValue({ _scaffold: true }),
|
||||
@@ -61,7 +71,16 @@ describe("WebhookTaskConsumer", () => {
|
||||
createMockFetcher([WebhookTriggerEvents.OOO_CREATED]),
|
||||
];
|
||||
|
||||
// Mock Logger
|
||||
mockPayloadBuilderFactory = {
|
||||
getBuilder: vi.fn().mockReturnValue({
|
||||
build: vi.fn().mockReturnValue({ payload: "mock-payload" }),
|
||||
}),
|
||||
} as unknown as PayloadBuilderFactory;
|
||||
|
||||
mockWebhookService = {
|
||||
processWebhooks: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as IWebhookService;
|
||||
|
||||
mockLogger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
@@ -70,11 +89,17 @@ describe("WebhookTaskConsumer", () => {
|
||||
getSubLogger: vi.fn().mockReturnThis(),
|
||||
} as unknown as ILogger;
|
||||
|
||||
consumer = new WebhookTaskConsumer(mockWebhookRepository, mockDataFetchers, mockLogger);
|
||||
consumer = new WebhookTaskConsumer(
|
||||
mockWebhookRepository,
|
||||
mockDataFetchers,
|
||||
mockPayloadBuilderFactory,
|
||||
mockWebhookService,
|
||||
mockLogger
|
||||
);
|
||||
});
|
||||
|
||||
describe("Constructor & Dependencies", () => {
|
||||
it("should be instantiable with Repository, Data Fetchers, and Logger", () => {
|
||||
it("should be instantiable with all required dependencies", () => {
|
||||
expect(consumer).toBeInstanceOf(WebhookTaskConsumer);
|
||||
});
|
||||
|
||||
@@ -154,7 +179,7 @@ describe("WebhookTaskConsumer", () => {
|
||||
|
||||
expect(mockWebhookRepository.getSubscribers).toHaveBeenCalledWith({
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_CANCELLED,
|
||||
userId: undefined, // Not in payload
|
||||
userId: undefined,
|
||||
eventTypeId: 789,
|
||||
teamId: 111,
|
||||
orgId: 222,
|
||||
@@ -171,110 +196,31 @@ describe("WebhookTaskConsumer", () => {
|
||||
});
|
||||
|
||||
describe("processWebhookTask - Event Type Routing", () => {
|
||||
it("should process BOOKING_CREATED event type (scaffold)", async () => {
|
||||
it("should find appropriate data fetcher for BOOKING_REQUESTED", async () => {
|
||||
const payload: WebhookTaskPayload = {
|
||||
operationId: "op-test",
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
bookingUid: "test-booking-uid",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([
|
||||
{
|
||||
id: "sub-1",
|
||||
subscriberUrl: "https://example.com/webhook",
|
||||
payloadTemplate: null,
|
||||
appId: null,
|
||||
secret: null,
|
||||
time: null,
|
||||
timeUnit: null,
|
||||
eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED],
|
||||
version: WebhookVersion.V_2021_10_20,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(consumer.processWebhookTask(payload, "task-test")).resolves.not.toThrow();
|
||||
expect(mockWebhookRepository.getSubscribers).toHaveBeenCalled();
|
||||
|
||||
const bookingFetcher = mockDataFetchers[0];
|
||||
expect(bookingFetcher.canHandle).toHaveBeenCalledWith(WebhookTriggerEvents.BOOKING_REQUESTED);
|
||||
});
|
||||
|
||||
it("should process FORM_SUBMITTED event type (scaffold)", async () => {
|
||||
it("should throw error when no data fetcher found for trigger event", async () => {
|
||||
const payload: WebhookTaskPayload = {
|
||||
operationId: "op-test",
|
||||
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
|
||||
formId: "test-form-id",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([
|
||||
{
|
||||
id: "sub-1",
|
||||
subscriberUrl: "https://example.com/webhook",
|
||||
payloadTemplate: null,
|
||||
appId: null,
|
||||
secret: null,
|
||||
time: null,
|
||||
timeUnit: null,
|
||||
eventTriggers: [WebhookTriggerEvents.FORM_SUBMITTED],
|
||||
version: WebhookVersion.V_2021_10_20,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(consumer.processWebhookTask(payload, "task-test")).resolves.not.toThrow();
|
||||
expect(mockWebhookRepository.getSubscribers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should process RECORDING_READY event type (scaffold)", async () => {
|
||||
const payload: WebhookTaskPayload = {
|
||||
operationId: "op-test",
|
||||
triggerEvent: WebhookTriggerEvents.RECORDING_READY,
|
||||
recordingId: "test-recording-id",
|
||||
triggerEvent: "UNKNOWN_EVENT" as WebhookTriggerEvents,
|
||||
bookingUid: "test-booking-uid",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([
|
||||
{
|
||||
id: "sub-1",
|
||||
subscriberUrl: "https://example.com/webhook",
|
||||
payloadTemplate: null,
|
||||
appId: null,
|
||||
secret: null,
|
||||
time: null,
|
||||
timeUnit: null,
|
||||
eventTriggers: [WebhookTriggerEvents.RECORDING_READY],
|
||||
version: WebhookVersion.V_2021_10_20,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(consumer.processWebhookTask(payload, "task-test")).resolves.not.toThrow();
|
||||
expect(mockWebhookRepository.getSubscribers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should process OOO_CREATED event type (scaffold)", async () => {
|
||||
const payload: WebhookTaskPayload = {
|
||||
userId: 456,
|
||||
operationId: "op-test",
|
||||
triggerEvent: WebhookTriggerEvents.OOO_CREATED,
|
||||
oooEntryId: 123,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([
|
||||
{
|
||||
id: "sub-1",
|
||||
subscriberUrl: "https://example.com/webhook",
|
||||
payloadTemplate: null,
|
||||
appId: null,
|
||||
secret: null,
|
||||
time: null,
|
||||
timeUnit: null,
|
||||
eventTriggers: [WebhookTriggerEvents.OOO_CREATED],
|
||||
version: WebhookVersion.V_2021_10_20,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(consumer.processWebhookTask(payload, "task-test")).resolves.not.toThrow();
|
||||
expect(mockWebhookRepository.getSubscribers).toHaveBeenCalled();
|
||||
await expect(consumer.processWebhookTask(payload, "task-test")).rejects.toThrow(
|
||||
"No data fetcher registered for trigger event: UNKNOWN_EVENT"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -324,7 +270,6 @@ describe("WebhookTaskConsumer", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock the data fetcher to return null (simulating event not found)
|
||||
const bookingFetcher = mockDataFetchers[0];
|
||||
(bookingFetcher.fetchEventData as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PayloadBuilderFactory } from "../../../factory/versioned/PayloadBuilderFactory";
|
||||
import { createPayloadBuilderFactory } from "../../../factory/versioned/registry";
|
||||
import type { IWebhookDataFetcher } from "../../../interface/IWebhookDataFetcher";
|
||||
import type { IWebhookRepository } from "../../../interface/IWebhookRepository";
|
||||
import { WebhookVersion } from "../../../interface/IWebhookRepository";
|
||||
import type { ILogger } from "../../../interface/infrastructure";
|
||||
import type { IWebhookService } from "../../../interface/services";
|
||||
import { WebhookTaskConsumer } from "../../../service/WebhookTaskConsumer";
|
||||
import type { BookingWebhookTaskPayload } from "../../../types/webhookTask";
|
||||
|
||||
/**
|
||||
* BOOKING_REQUESTED Trigger Tests
|
||||
*
|
||||
* Tests specific to the BOOKING_REQUESTED webhook trigger.
|
||||
* This trigger fires when a booking requires confirmation (opt-in booking).
|
||||
*
|
||||
* Scenarios covered:
|
||||
* - Event types that require confirmation
|
||||
* - Paid events that require confirmation
|
||||
* - Reschedule scenarios with confirmation required
|
||||
* - Team/collective scheduling with confirmation required
|
||||
*/
|
||||
describe("BOOKING_REQUESTED Trigger", () => {
|
||||
let consumer: WebhookTaskConsumer;
|
||||
let mockWebhookRepository: IWebhookRepository;
|
||||
let mockBookingDataFetcher: IWebhookDataFetcher;
|
||||
let mockPayloadBuilderFactory: PayloadBuilderFactory;
|
||||
let mockWebhookService: IWebhookService;
|
||||
let mockLogger: ILogger;
|
||||
|
||||
const createMockCalendarEvent = (overrides = {}) => ({
|
||||
type: "Test Event",
|
||||
title: "Test Booking",
|
||||
description: "Test description",
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: new Date(Date.now() + 3600000).toISOString(),
|
||||
organizer: {
|
||||
id: 1,
|
||||
email: "organizer@example.com",
|
||||
name: "Organizer",
|
||||
timeZone: "UTC",
|
||||
language: { locale: "en" },
|
||||
},
|
||||
attendees: [
|
||||
{
|
||||
email: "attendee@example.com",
|
||||
name: "Attendee",
|
||||
timeZone: "UTC",
|
||||
language: { locale: "en" },
|
||||
},
|
||||
],
|
||||
location: "https://cal.com/video/test",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockBooking = (overrides = {}) => ({
|
||||
id: 123,
|
||||
uid: "booking-uid-123",
|
||||
eventTypeId: 456,
|
||||
userId: 789,
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 3600000),
|
||||
status: "PENDING",
|
||||
smsReminderNumber: null,
|
||||
iCalSequence: 0,
|
||||
assignmentReason: null,
|
||||
user: {
|
||||
id: 789,
|
||||
email: "organizer@example.com",
|
||||
name: "Organizer",
|
||||
},
|
||||
eventType: {
|
||||
id: 456,
|
||||
title: "Test Event Type",
|
||||
description: "Test event type description",
|
||||
requiresConfirmation: true,
|
||||
price: 0,
|
||||
currency: "USD",
|
||||
length: 60,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockWebhookRepository = {
|
||||
getSubscribers: vi.fn().mockResolvedValue([]),
|
||||
getWebhookById: vi.fn(),
|
||||
findByWebhookId: vi.fn(),
|
||||
findByOrgIdAndTrigger: vi.fn(),
|
||||
getFilteredWebhooksForUser: vi.fn(),
|
||||
} as unknown as IWebhookRepository;
|
||||
|
||||
mockBookingDataFetcher = {
|
||||
canHandle: vi.fn((event) =>
|
||||
[
|
||||
WebhookTriggerEvents.BOOKING_CREATED,
|
||||
WebhookTriggerEvents.BOOKING_CANCELLED,
|
||||
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
||||
WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
WebhookTriggerEvents.BOOKING_REJECTED,
|
||||
WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
|
||||
].includes(event)
|
||||
),
|
||||
fetchEventData: vi.fn().mockResolvedValue({
|
||||
calendarEvent: createMockCalendarEvent(),
|
||||
booking: createMockBooking(),
|
||||
}),
|
||||
getSubscriberContext: vi.fn((payload: BookingWebhookTaskPayload) => ({
|
||||
triggerEvent: payload.triggerEvent,
|
||||
userId: payload.userId,
|
||||
eventTypeId: payload.eventTypeId,
|
||||
teamId: payload.teamId,
|
||||
orgId: payload.orgId,
|
||||
oAuthClientId: payload.oAuthClientId,
|
||||
})),
|
||||
};
|
||||
|
||||
mockPayloadBuilderFactory = {
|
||||
getBuilder: vi.fn().mockReturnValue({
|
||||
build: vi.fn().mockReturnValue({
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
createdAt: new Date().toISOString(),
|
||||
payload: {
|
||||
type: "Test Event Type",
|
||||
title: "Test Booking",
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: new Date(Date.now() + 3600000).toISOString(),
|
||||
organizer: { email: "organizer@example.com" },
|
||||
attendees: [{ email: "attendee@example.com" }],
|
||||
metadata: {},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
} as unknown as PayloadBuilderFactory;
|
||||
|
||||
mockWebhookService = {
|
||||
processWebhooks: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as IWebhookService;
|
||||
|
||||
mockLogger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
getSubLogger: vi.fn().mockReturnThis(),
|
||||
} as unknown as ILogger;
|
||||
|
||||
consumer = new WebhookTaskConsumer(
|
||||
mockWebhookRepository,
|
||||
[mockBookingDataFetcher],
|
||||
mockPayloadBuilderFactory,
|
||||
mockWebhookService,
|
||||
mockLogger
|
||||
);
|
||||
});
|
||||
|
||||
describe("Event Type that requires confirmation", () => {
|
||||
it("should process BOOKING_REQUESTED when event type requires confirmation", async () => {
|
||||
const payload: BookingWebhookTaskPayload = {
|
||||
operationId: "op-booking-requested-1",
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
bookingUid: "booking-uid-123",
|
||||
eventTypeId: 456,
|
||||
userId: 789,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([
|
||||
{
|
||||
id: "sub-1",
|
||||
subscriberUrl: "https://example.com/webhook",
|
||||
payloadTemplate: null,
|
||||
appId: null,
|
||||
secret: "webhook-secret",
|
||||
time: null,
|
||||
timeUnit: null,
|
||||
eventTriggers: [WebhookTriggerEvents.BOOKING_REQUESTED],
|
||||
version: WebhookVersion.V_2021_10_20,
|
||||
},
|
||||
]);
|
||||
|
||||
await consumer.processWebhookTask(payload, "task-booking-requested-1");
|
||||
|
||||
expect(mockBookingDataFetcher.canHandle).toHaveBeenCalledWith(WebhookTriggerEvents.BOOKING_REQUESTED);
|
||||
expect(mockBookingDataFetcher.fetchEventData).toHaveBeenCalledWith(payload);
|
||||
expect(mockWebhookService.processWebhooks).toHaveBeenCalledWith(
|
||||
WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
expect.any(Object),
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
subscriberUrl: "https://example.com/webhook",
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("should include metadata in BOOKING_REQUESTED payload", async () => {
|
||||
const payload: BookingWebhookTaskPayload = {
|
||||
operationId: "op-booking-requested-2",
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
bookingUid: "booking-uid-456",
|
||||
eventTypeId: 456,
|
||||
userId: 789,
|
||||
metadata: { customField: "customValue", source: "api" },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([
|
||||
{
|
||||
id: "sub-1",
|
||||
subscriberUrl: "https://example.com/webhook",
|
||||
payloadTemplate: null,
|
||||
appId: null,
|
||||
secret: null,
|
||||
time: null,
|
||||
timeUnit: null,
|
||||
eventTriggers: [WebhookTriggerEvents.BOOKING_REQUESTED],
|
||||
version: WebhookVersion.V_2021_10_20,
|
||||
},
|
||||
]);
|
||||
|
||||
await consumer.processWebhookTask(payload, "task-booking-requested-2");
|
||||
|
||||
expect(mockBookingDataFetcher.fetchEventData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: { customField: "customValue", source: "api" },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Team/Collective scheduling", () => {
|
||||
it("should process BOOKING_REQUESTED for team event with confirmation required", async () => {
|
||||
const payload: BookingWebhookTaskPayload = {
|
||||
operationId: "op-team-booking",
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
bookingUid: "booking-uid-team",
|
||||
eventTypeId: 456,
|
||||
userId: 789,
|
||||
teamId: 111,
|
||||
orgId: 222,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([
|
||||
{
|
||||
id: "sub-team",
|
||||
subscriberUrl: "https://example.com/team-webhook",
|
||||
payloadTemplate: null,
|
||||
appId: null,
|
||||
secret: null,
|
||||
time: null,
|
||||
timeUnit: null,
|
||||
eventTriggers: [WebhookTriggerEvents.BOOKING_REQUESTED],
|
||||
version: WebhookVersion.V_2021_10_20,
|
||||
},
|
||||
]);
|
||||
|
||||
await consumer.processWebhookTask(payload, "task-team-booking");
|
||||
|
||||
expect(mockBookingDataFetcher.getSubscriberContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamId: 111,
|
||||
orgId: 222,
|
||||
})
|
||||
);
|
||||
expect(mockWebhookService.processWebhooks).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration: real PayloadBuilder verifies webhook payload content", () => {
|
||||
const realPayloadBuilderFactory = createPayloadBuilderFactory();
|
||||
|
||||
const defaultSubscriber = {
|
||||
id: "sub-1",
|
||||
subscriberUrl: "https://example.com/webhook",
|
||||
payloadTemplate: null,
|
||||
appId: null,
|
||||
secret: "secret",
|
||||
time: null,
|
||||
timeUnit: null,
|
||||
eventTriggers: [WebhookTriggerEvents.BOOKING_REQUESTED],
|
||||
version: WebhookVersion.V_2021_10_20,
|
||||
};
|
||||
|
||||
function buildConsumerWithRealBuilder() {
|
||||
return new WebhookTaskConsumer(
|
||||
mockWebhookRepository,
|
||||
[mockBookingDataFetcher],
|
||||
realPayloadBuilderFactory,
|
||||
mockWebhookService,
|
||||
mockLogger
|
||||
);
|
||||
}
|
||||
|
||||
function getDeliveredPayload(): Record<string, unknown> {
|
||||
const [, webhookPayload] = vi.mocked(mockWebhookService.processWebhooks).mock.calls[0];
|
||||
return (webhookPayload as { triggerEvent: string; createdAt: string; payload: Record<string, unknown> })
|
||||
.payload;
|
||||
}
|
||||
|
||||
function getDeliveredWebhookEnvelope(): {
|
||||
triggerEvent: string;
|
||||
createdAt: string;
|
||||
payload: Record<string, unknown>;
|
||||
} {
|
||||
const [, webhookPayload] = vi.mocked(mockWebhookService.processWebhooks).mock.calls[0];
|
||||
return webhookPayload as { triggerEvent: string; createdAt: string; payload: Record<string, unknown> };
|
||||
}
|
||||
|
||||
it("delivers PENDING status and BOOKING_REQUESTED triggerEvent in payload", async () => {
|
||||
const timestamp = "2024-06-15T10:00:00.000Z";
|
||||
const payload: BookingWebhookTaskPayload = {
|
||||
operationId: "op-status-check",
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
bookingUid: "booking-uid-123",
|
||||
eventTypeId: 456,
|
||||
userId: 789,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([defaultSubscriber]);
|
||||
|
||||
const consumerWithRealBuilder = buildConsumerWithRealBuilder();
|
||||
await consumerWithRealBuilder.processWebhookTask(payload, "task-status");
|
||||
|
||||
const envelope = getDeliveredWebhookEnvelope();
|
||||
expect(envelope.triggerEvent).toBe("BOOKING_REQUESTED");
|
||||
expect(envelope.createdAt).toBe(timestamp);
|
||||
|
||||
const p = getDeliveredPayload();
|
||||
expect(p.status).toBe("PENDING");
|
||||
});
|
||||
|
||||
it("includes eventTitle, eventDescription, requiresConfirmation, price, currency, and length from event type", async () => {
|
||||
vi.mocked(mockBookingDataFetcher.fetchEventData).mockResolvedValueOnce({
|
||||
calendarEvent: createMockCalendarEvent(),
|
||||
booking: createMockBooking({
|
||||
eventType: {
|
||||
id: 456,
|
||||
title: "Consultation Call",
|
||||
description: "30-minute consultation",
|
||||
requiresConfirmation: true,
|
||||
price: 5000,
|
||||
currency: "INR",
|
||||
length: 30,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([defaultSubscriber]);
|
||||
|
||||
const consumerWithRealBuilder = buildConsumerWithRealBuilder();
|
||||
await consumerWithRealBuilder.processWebhookTask(
|
||||
{
|
||||
operationId: "op-evt-type",
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
bookingUid: "booking-uid-123",
|
||||
eventTypeId: 456,
|
||||
userId: 789,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
"task-evt-type"
|
||||
);
|
||||
|
||||
const p = getDeliveredPayload();
|
||||
expect(p.eventTitle).toBe("Consultation Call");
|
||||
expect(p.eventDescription).toBe("30-minute consultation");
|
||||
expect(p.requiresConfirmation).toBe(true);
|
||||
expect(p.price).toBe(5000);
|
||||
expect(p.currency).toBe("INR");
|
||||
expect(p.length).toBe(30);
|
||||
});
|
||||
|
||||
it("includes bookingId, title, location, organizer, and attendees from calendar event", async () => {
|
||||
vi.mocked(mockBookingDataFetcher.fetchEventData).mockResolvedValueOnce({
|
||||
calendarEvent: createMockCalendarEvent({
|
||||
title: "My Booking",
|
||||
location: "https://cal.com/video/abc",
|
||||
organizer: {
|
||||
id: 1,
|
||||
email: "host@example.com",
|
||||
name: "Host User",
|
||||
timeZone: "America/New_York",
|
||||
language: { locale: "en" },
|
||||
},
|
||||
attendees: [
|
||||
{
|
||||
email: "guest@example.com",
|
||||
name: "Guest User",
|
||||
timeZone: "Europe/London",
|
||||
language: { locale: "en" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
booking: createMockBooking({ id: 999 }),
|
||||
});
|
||||
|
||||
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([defaultSubscriber]);
|
||||
|
||||
const consumerWithRealBuilder = buildConsumerWithRealBuilder();
|
||||
await consumerWithRealBuilder.processWebhookTask(
|
||||
{
|
||||
operationId: "op-cal-evt",
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
bookingUid: "booking-uid-123",
|
||||
eventTypeId: 456,
|
||||
userId: 789,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
"task-cal-evt"
|
||||
);
|
||||
|
||||
const p = getDeliveredPayload();
|
||||
expect(p.bookingId).toBe(999);
|
||||
expect(p.title).toBe("My Booking");
|
||||
expect(p.location).toBe("https://cal.com/video/abc");
|
||||
expect(p.organizer).toEqual(
|
||||
expect.objectContaining({
|
||||
email: "host@example.com",
|
||||
name: "Host User",
|
||||
timeZone: "America/New_York",
|
||||
})
|
||||
);
|
||||
expect(p.attendees).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
email: "guest@example.com",
|
||||
name: "Guest User",
|
||||
timeZone: "Europe/London",
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("metadata is empty object when no metadata provided (no videoCallUrl for pending requests)", async () => {
|
||||
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([defaultSubscriber]);
|
||||
|
||||
const consumerWithRealBuilder = buildConsumerWithRealBuilder();
|
||||
await consumerWithRealBuilder.processWebhookTask(
|
||||
{
|
||||
operationId: "op-no-metadata",
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
bookingUid: "booking-uid-123",
|
||||
eventTypeId: 456,
|
||||
userId: 789,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
"task-no-metadata"
|
||||
);
|
||||
|
||||
const p = getDeliveredPayload();
|
||||
expect(p.metadata).toBeDefined();
|
||||
expect((p.metadata as Record<string, unknown>).videoCallUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("paid event payload has PENDING status and correct event type price/currency", async () => {
|
||||
vi.mocked(mockBookingDataFetcher.fetchEventData).mockResolvedValueOnce({
|
||||
calendarEvent: createMockCalendarEvent(),
|
||||
booking: createMockBooking({
|
||||
eventType: {
|
||||
id: 456,
|
||||
title: "Paid Consultation",
|
||||
description: "Paid event requiring confirmation",
|
||||
requiresConfirmation: true,
|
||||
price: 10000,
|
||||
currency: "USD",
|
||||
length: 60,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
vi.mocked(mockWebhookRepository.getSubscribers).mockResolvedValueOnce([defaultSubscriber]);
|
||||
|
||||
const consumerWithRealBuilder = buildConsumerWithRealBuilder();
|
||||
await consumerWithRealBuilder.processWebhookTask(
|
||||
{
|
||||
operationId: "op-paid-payload",
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
bookingUid: "booking-uid-paid",
|
||||
eventTypeId: 456,
|
||||
userId: 789,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
"task-paid-payload"
|
||||
);
|
||||
|
||||
const p = getDeliveredPayload();
|
||||
expect(p.status).toBe("PENDING");
|
||||
expect(p.eventTitle).toBe("Paid Consultation");
|
||||
expect(p.price).toBe(10000);
|
||||
expect(p.currency).toBe("USD");
|
||||
expect(p.requiresConfirmation).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ILogger } from "../../interface/infrastructure";
|
||||
import type { WebhookTasker } from "../../tasker/WebhookTasker";
|
||||
import { WebhookTaskerProducerService } from "../../service/WebhookTaskerProducerService";
|
||||
|
||||
/**
|
||||
* Unit Tests for WebhookTaskerProducerService
|
||||
*
|
||||
* Tests the lightweight Producer service for queueing webhook delivery tasks.
|
||||
* The producer has minimal dependencies (only Tasker and Logger) and queues
|
||||
* tasks to be processed by WebhookTaskConsumer.
|
||||
*/
|
||||
describe("WebhookTaskerProducerService", () => {
|
||||
let producer: WebhookTaskerProducerService;
|
||||
let mockWebhookTasker: WebhookTasker;
|
||||
let mockLogger: ILogger;
|
||||
|
||||
beforeEach(() => {
|
||||
mockWebhookTasker = {
|
||||
deliverWebhook: vi.fn().mockResolvedValue({ taskId: "mock-task-id" }),
|
||||
} as unknown as WebhookTasker;
|
||||
|
||||
mockLogger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
getSubLogger: vi.fn().mockReturnThis(),
|
||||
} as unknown as ILogger;
|
||||
|
||||
producer = new WebhookTaskerProducerService({
|
||||
webhookTasker: mockWebhookTasker,
|
||||
logger: mockLogger,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Constructor & Dependencies", () => {
|
||||
it("should be instantiable with Tasker and Logger", () => {
|
||||
expect(producer).toBeInstanceOf(WebhookTaskerProducerService);
|
||||
});
|
||||
|
||||
it("should create sub-logger with prefix", () => {
|
||||
expect(mockLogger.getSubLogger).toHaveBeenCalledWith({
|
||||
prefix: ["[WebhookTaskerProducerService]"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("queueBookingRequestedWebhook", () => {
|
||||
it("should queue a BOOKING_REQUESTED webhook task with required params", async () => {
|
||||
const params = {
|
||||
bookingUid: "booking-123",
|
||||
eventTypeId: 456,
|
||||
userId: 789,
|
||||
};
|
||||
|
||||
await producer.queueBookingRequestedWebhook(params);
|
||||
|
||||
expect(mockWebhookTasker.deliverWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
bookingUid: "booking-123",
|
||||
eventTypeId: 456,
|
||||
userId: 789,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should queue a BOOKING_REQUESTED webhook task with all optional params", async () => {
|
||||
const params = {
|
||||
bookingUid: "booking-123",
|
||||
eventTypeId: 456,
|
||||
userId: 789,
|
||||
teamId: 111,
|
||||
orgId: 222,
|
||||
oAuthClientId: "oauth-client-123",
|
||||
operationId: "custom-op-id",
|
||||
metadata: { customKey: "customValue" },
|
||||
};
|
||||
|
||||
await producer.queueBookingRequestedWebhook(params);
|
||||
|
||||
expect(mockWebhookTasker.deliverWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operationId: "custom-op-id",
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
bookingUid: "booking-123",
|
||||
eventTypeId: 456,
|
||||
userId: 789,
|
||||
teamId: 111,
|
||||
orgId: 222,
|
||||
oAuthClientId: "oauth-client-123",
|
||||
metadata: { customKey: "customValue" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should generate operationId if not provided", async () => {
|
||||
const params = {
|
||||
bookingUid: "booking-123",
|
||||
};
|
||||
|
||||
await producer.queueBookingRequestedWebhook(params);
|
||||
|
||||
expect(mockWebhookTasker.deliverWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operationId: expect.any(String),
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
bookingUid: "booking-123",
|
||||
timestamp: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should log debug message when queueing", async () => {
|
||||
const params = {
|
||||
bookingUid: "booking-123",
|
||||
eventTypeId: 456,
|
||||
};
|
||||
|
||||
await producer.queueBookingRequestedWebhook(params);
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
"Queueing booking webhook task",
|
||||
expect.objectContaining({
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REQUESTED,
|
||||
bookingUid: "booking-123",
|
||||
eventTypeId: 456,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should log and rethrow error if tasker fails", async () => {
|
||||
const error = new Error("Tasker error");
|
||||
vi.mocked(mockWebhookTasker.deliverWebhook).mockRejectedValueOnce(error);
|
||||
|
||||
const params = {
|
||||
bookingUid: "booking-123",
|
||||
};
|
||||
|
||||
await expect(producer.queueBookingRequestedWebhook(params)).rejects.toThrow("Tasker error");
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
"Failed to queue webhook delivery task",
|
||||
expect.objectContaining({
|
||||
error: "Tasker error",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Other booking webhook methods", () => {
|
||||
it("should queue BOOKING_CREATED webhook", async () => {
|
||||
await producer.queueBookingCreatedWebhook({ bookingUid: "booking-123" });
|
||||
|
||||
expect(mockWebhookTasker.deliverWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
|
||||
bookingUid: "booking-123",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should queue BOOKING_CANCELLED webhook", async () => {
|
||||
await producer.queueBookingCancelledWebhook({ bookingUid: "booking-123" });
|
||||
|
||||
expect(mockWebhookTasker.deliverWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_CANCELLED,
|
||||
bookingUid: "booking-123",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should queue BOOKING_RESCHEDULED webhook", async () => {
|
||||
await producer.queueBookingRescheduledWebhook({ bookingUid: "booking-123" });
|
||||
|
||||
expect(mockWebhookTasker.deliverWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
||||
bookingUid: "booking-123",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should queue BOOKING_REJECTED webhook", async () => {
|
||||
await producer.queueBookingRejectedWebhook({ bookingUid: "booking-123" });
|
||||
|
||||
expect(mockWebhookTasker.deliverWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_REJECTED,
|
||||
bookingUid: "booking-123",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should queue BOOKING_NO_SHOW_UPDATED webhook", async () => {
|
||||
await producer.queueBookingNoShowUpdatedWebhook({ bookingUid: "booking-123" });
|
||||
|
||||
expect(mockWebhookTasker.deliverWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
|
||||
bookingUid: "booking-123",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Payment webhook methods", () => {
|
||||
it("should queue BOOKING_PAYMENT_INITIATED webhook", async () => {
|
||||
await producer.queueBookingPaymentInitiatedWebhook({ bookingUid: "booking-123" });
|
||||
|
||||
expect(mockWebhookTasker.deliverWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
|
||||
bookingUid: "booking-123",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should queue BOOKING_PAID webhook", async () => {
|
||||
await producer.queueBookingPaidWebhook({ bookingUid: "booking-123" });
|
||||
|
||||
expect(mockWebhookTasker.deliverWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerEvent: WebhookTriggerEvents.BOOKING_PAID,
|
||||
bookingUid: "booking-123",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { EventType, User, Webhook } from "@calcom/prisma/client";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { v4 } from "uuid";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { WebhookTaskPayload } from "../types/webhookTask";
|
||||
|
||||
/**
|
||||
* Webhook Producer Integration Tests
|
||||
*
|
||||
* These tests verify that the WebhookTaskerProducerService correctly:
|
||||
* 1. Integrates with the DI container
|
||||
* 2. Calls WebhookTasker.deliverWebhook() with correct payloads
|
||||
*
|
||||
* Note: With the new WebhookTasker architecture:
|
||||
* - Async mode sends to trigger.dev (external service)
|
||||
* - Sync mode executes immediately via WebhookTaskConsumer
|
||||
*
|
||||
* Neither writes to the local Prisma Task table, so we mock
|
||||
* the WebhookTasker to verify the producer's behavior.
|
||||
*/
|
||||
|
||||
// Track deliverWebhook calls
|
||||
const deliveredWebhooks: WebhookTaskPayload[] = [];
|
||||
|
||||
// Mock the WebhookTasker module before importing the container
|
||||
vi.mock("@calcom/features/webhooks/lib/tasker/WebhookTasker", () => ({
|
||||
WebhookTasker: class MockWebhookTasker {
|
||||
async deliverWebhook(payload: WebhookTaskPayload) {
|
||||
deliveredWebhooks.push(payload);
|
||||
return { taskId: `mock-task-${deliveredWebhooks.length}` };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
const { getWebhookProducer } = await import("@calcom/features/di/webhooks/containers/webhook");
|
||||
|
||||
describe("Webhook Producer Integration", () => {
|
||||
let testUser: User;
|
||||
let testEventType: EventType;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
let testWebhook: Webhook;
|
||||
|
||||
// Use unique identifiers for each test run to avoid collisions
|
||||
const testId = v4();
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create test user
|
||||
testUser = await prisma.user.create({
|
||||
data: {
|
||||
email: `webhook-producer-test-${testId}@example.com`,
|
||||
username: `webhook-producer-test-${testId}`,
|
||||
name: "Webhook Producer Test User",
|
||||
},
|
||||
});
|
||||
|
||||
// Create test event type
|
||||
testEventType = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Webhook Producer Test Event",
|
||||
slug: `webhook-producer-test-event-${testId}`,
|
||||
length: 30,
|
||||
userId: testUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test webhook
|
||||
testWebhook = await prisma.webhook.create({
|
||||
data: {
|
||||
id: `webhook-producer-test-${testId}`,
|
||||
userId: testUser.id,
|
||||
subscriberUrl: "https://example.com/webhook",
|
||||
eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED],
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear delivered webhooks before each test
|
||||
deliveredWebhooks.length = 0;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up all test data
|
||||
await prisma.webhook.deleteMany({
|
||||
where: { userId: testUser.id },
|
||||
});
|
||||
await prisma.eventType.deleteMany({
|
||||
where: { id: testEventType.id },
|
||||
});
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: testUser.id },
|
||||
});
|
||||
});
|
||||
|
||||
describe("BOOKING_REQUESTED", () => {
|
||||
test("calls deliverWebhook with correct payload", async () => {
|
||||
const producer = getWebhookProducer();
|
||||
const bookingUid = "test-booking-uid-requested";
|
||||
|
||||
await producer.queueBookingRequestedWebhook({
|
||||
bookingUid,
|
||||
eventTypeId: testEventType.id,
|
||||
userId: testUser.id,
|
||||
});
|
||||
|
||||
expect(deliveredWebhooks.length).toBe(1);
|
||||
|
||||
const payload = deliveredWebhooks[0];
|
||||
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_REQUESTED);
|
||||
if (payload.triggerEvent === WebhookTriggerEvents.BOOKING_REQUESTED) {
|
||||
expect(payload.bookingUid).toBe(bookingUid);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { TGetTranscriptAccessLink } from "@calcom/app-store/dailyvideo/zod";
|
||||
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
|
||||
import type { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
|
||||
import type { CalendarEvent, ConferenceData, Person } from "@calcom/types/Calendar";
|
||||
import type { WebhookVersion } from "../interface/IWebhookRepository";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
export interface BaseEventDTO {
|
||||
triggerEvent: WebhookTriggerEvents;
|
||||
@@ -83,6 +82,7 @@ export interface BookingRequestedDTO extends BaseEventDTO {
|
||||
eventTypeId: number | null;
|
||||
userId: number | null;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface BookingRescheduledDTO extends BaseEventDTO {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import type { EventTypeInfo, BookingWebhookEventDTO } from "../../dto/types";
|
||||
import type { BookingWebhookEventDTO, EventTypeInfo } from "../../dto/types";
|
||||
import type { WebhookPayload } from "../types";
|
||||
import type { IBookingPayloadBuilder } from "../versioned/PayloadBuilderFactory";
|
||||
|
||||
@@ -16,7 +15,9 @@ export type BookingExtraDataMap = {
|
||||
cancellationReason?: string;
|
||||
requestReschedule?: boolean;
|
||||
};
|
||||
[WebhookTriggerEvents.BOOKING_REQUESTED]: null;
|
||||
[WebhookTriggerEvents.BOOKING_REQUESTED]: {
|
||||
metadata?: { [key: string]: string | number | boolean | null };
|
||||
};
|
||||
[WebhookTriggerEvents.BOOKING_REJECTED]: null;
|
||||
[WebhookTriggerEvents.BOOKING_RESCHEDULED]: {
|
||||
rescheduleId?: number;
|
||||
@@ -38,6 +39,8 @@ export interface BookingPayloadParams<T extends keyof BookingExtraDataMap> {
|
||||
eventTypeId: number | null;
|
||||
userId: number | null;
|
||||
smsReminderNumber?: string | null;
|
||||
// Raw assignmentReason from DB for legacy webhook format [{ reasonEnum, reasonString }]
|
||||
assignmentReason?: { reasonEnum: string; reasonString: string }[];
|
||||
};
|
||||
eventType: EventTypeInfo;
|
||||
evt: CalendarEvent;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type {
|
||||
EventPayloadType,
|
||||
OOOEntryPayloadType,
|
||||
BookingNoShowUpdatedPayload,
|
||||
DelegationCredentialErrorPayloadType,
|
||||
EventPayloadType,
|
||||
OOOEntryPayloadType,
|
||||
} from "../dto/types";
|
||||
|
||||
export interface FormSubmittedPayload {
|
||||
|
||||
+233
-1
@@ -2,7 +2,11 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import type { BookingWebhookEventDTO, EventTypeInfo } from "../../../dto/types";
|
||||
import type {
|
||||
BookingWebhookEventDTO,
|
||||
EventPayloadType,
|
||||
EventTypeInfo,
|
||||
} from "../../../dto/types";
|
||||
import { BookingPayloadBuilder } from "./BookingPayloadBuilder";
|
||||
|
||||
vi.mock("@calcom/lib/dayjs", () => ({
|
||||
@@ -149,4 +153,232 @@ describe("v2021-10-20/BookingPayloadBuilder", () => {
|
||||
|
||||
expect(() => builder.build(minimalDTO)).not.toThrow();
|
||||
});
|
||||
|
||||
describe("Legacy payload shape compatibility (v2021-10-20)", () => {
|
||||
/**
|
||||
* These tests assert the real BookingPayloadBuilder output matches the
|
||||
* legacy webhook format expected by E2E (webhook.e2e.ts) and external
|
||||
* consumers. They exercise normalization and field shapes without mocks.
|
||||
*/
|
||||
|
||||
it("normalizes response labels to your_name and email_address for BOOKING_REQUESTED", () => {
|
||||
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REQUESTED, {
|
||||
evt: {
|
||||
...mockCalendarEvent,
|
||||
responses: {
|
||||
name: { value: "Test Testson", label: "name" },
|
||||
email: { value: "test@example.com", label: "email" },
|
||||
},
|
||||
userFieldsResponses: {},
|
||||
},
|
||||
});
|
||||
const result = builder.build(dto);
|
||||
const p = result.payload as EventPayloadType;
|
||||
|
||||
expect(p.responses?.name?.label).toBe("your_name");
|
||||
expect(p.responses?.name?.value).toBe("Test Testson");
|
||||
expect(p.responses?.email?.label).toBe("email_address");
|
||||
expect(p.responses?.email?.value).toBe("test@example.com");
|
||||
});
|
||||
|
||||
it("passes through userFieldsResponses unchanged (only responses is normalized)", () => {
|
||||
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REQUESTED, {
|
||||
evt: {
|
||||
...mockCalendarEvent,
|
||||
responses: {},
|
||||
userFieldsResponses: {
|
||||
name: { value: "Jane", label: "name" },
|
||||
email: { value: "jane@example.com", label: "email" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = builder.build(dto);
|
||||
const p = result.payload as EventPayloadType;
|
||||
|
||||
expect(p.userFieldsResponses?.name?.label).toBe("name");
|
||||
expect(p.userFieldsResponses?.email?.label).toBe("email");
|
||||
});
|
||||
|
||||
it("includes destinationCalendar in payload (null when not set)", () => {
|
||||
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REQUESTED, {
|
||||
evt: { ...mockCalendarEvent, destinationCalendar: undefined },
|
||||
});
|
||||
const result = builder.build(dto);
|
||||
const p = result.payload as EventPayloadType;
|
||||
|
||||
expect("destinationCalendar" in p).toBe(true);
|
||||
expect(p.destinationCalendar).toBeNull();
|
||||
});
|
||||
|
||||
it("includes destinationCalendar when evt has it", () => {
|
||||
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REQUESTED, {
|
||||
evt: {
|
||||
...mockCalendarEvent,
|
||||
destinationCalendar: { id: "cal-1", integration: "google_calendar" },
|
||||
},
|
||||
});
|
||||
const result = builder.build(dto);
|
||||
const p = result.payload as EventPayloadType;
|
||||
|
||||
expect(p.destinationCalendar).toEqual({
|
||||
id: "cal-1",
|
||||
integration: "google_calendar",
|
||||
});
|
||||
});
|
||||
|
||||
it("outputs assignmentReason as legacy array from booking", () => {
|
||||
const legacyReason = [
|
||||
{ reasonEnum: "ROUND_ROBIN", reasonString: "Round robin" },
|
||||
{ reasonEnum: "LEAST_RECENTLY_BOOKED", reasonString: "Least recently booked" },
|
||||
];
|
||||
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REQUESTED, {
|
||||
booking: {
|
||||
id: 1,
|
||||
eventTypeId: 1,
|
||||
userId: 1,
|
||||
smsReminderNumber: null,
|
||||
assignmentReason: legacyReason,
|
||||
},
|
||||
});
|
||||
const result = builder.build(dto);
|
||||
const p = result.payload as EventPayloadType;
|
||||
|
||||
expect(p.assignmentReason).toEqual(legacyReason);
|
||||
});
|
||||
|
||||
it("outputs assignmentReason as null when booking has none", () => {
|
||||
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REQUESTED, {
|
||||
booking: {
|
||||
id: 1,
|
||||
eventTypeId: 1,
|
||||
userId: 1,
|
||||
smsReminderNumber: null,
|
||||
},
|
||||
});
|
||||
const result = builder.build(dto);
|
||||
const p = result.payload as EventPayloadType;
|
||||
|
||||
expect(p.assignmentReason).toBeNull();
|
||||
});
|
||||
|
||||
it("BOOKING_REQUESTED payload includes metadata from extra", () => {
|
||||
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REQUESTED, {
|
||||
metadata: { videoCallUrl: "https://cal.com/video/abc" },
|
||||
});
|
||||
const result = builder.build(dto);
|
||||
const p = result.payload as EventPayloadType;
|
||||
|
||||
expect(p.metadata).toEqual({ videoCallUrl: "https://cal.com/video/abc" });
|
||||
});
|
||||
|
||||
it("preserves empty string description (does not replace with additionalNotes)", () => {
|
||||
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REQUESTED, {
|
||||
evt: {
|
||||
...mockCalendarEvent,
|
||||
description: "",
|
||||
additionalNotes: "organizer notes here",
|
||||
},
|
||||
});
|
||||
const result = builder.build(dto);
|
||||
const p = result.payload as EventPayloadType;
|
||||
|
||||
expect(p.description).toBe("");
|
||||
expect(p.additionalNotes).toBe("organizer notes here");
|
||||
});
|
||||
|
||||
it("defaults to price 0, currency usd, length null when eventType fields are missing", () => {
|
||||
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REQUESTED, {
|
||||
eventType: {
|
||||
...mockEventType,
|
||||
price: undefined,
|
||||
currency: undefined,
|
||||
length: undefined,
|
||||
},
|
||||
});
|
||||
const result = builder.build(dto);
|
||||
const p = result.payload as EventPayloadType;
|
||||
|
||||
expect(p.price).toBe(0);
|
||||
expect(p.currency).toBe("usd");
|
||||
expect(p.length).toBeNull();
|
||||
});
|
||||
|
||||
it("derives attendee firstName and lastName from full name when absent", () => {
|
||||
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REQUESTED, {
|
||||
evt: {
|
||||
...mockCalendarEvent,
|
||||
attendees: [
|
||||
{
|
||||
email: "john@example.com",
|
||||
name: "John Doe",
|
||||
timeZone: "UTC",
|
||||
language: { locale: "en" },
|
||||
},
|
||||
{
|
||||
email: "alice@example.com",
|
||||
name: "Alice",
|
||||
timeZone: "UTC",
|
||||
language: { locale: "en" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const result = builder.build(dto);
|
||||
const p = result.payload as EventPayloadType;
|
||||
|
||||
expect(p.attendees?.[0]).toMatchObject({
|
||||
name: "John Doe",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
});
|
||||
expect(p.attendees?.[1]).toMatchObject({
|
||||
name: "Alice",
|
||||
firstName: "Alice",
|
||||
lastName: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not leak evt.assignmentReason; only booking legacy array or null appears", () => {
|
||||
const evtShape = { category: "round_robin", details: "transformed" } as const;
|
||||
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REQUESTED, {
|
||||
evt: {
|
||||
...mockCalendarEvent,
|
||||
assignmentReason: evtShape,
|
||||
},
|
||||
booking: {
|
||||
id: 1,
|
||||
eventTypeId: 1,
|
||||
userId: 1,
|
||||
smsReminderNumber: null,
|
||||
assignmentReason: null,
|
||||
},
|
||||
});
|
||||
const result = builder.build(dto);
|
||||
const p = result.payload as EventPayloadType;
|
||||
|
||||
expect(p.assignmentReason).toBeNull();
|
||||
expect(p.assignmentReason).not.toEqual(evtShape);
|
||||
});
|
||||
|
||||
it("uses only booking.assignmentReason when both evt and booking have it", () => {
|
||||
const legacyArray = [{ reasonEnum: "ROUND_ROBIN", reasonString: "Round robin" }];
|
||||
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REQUESTED, {
|
||||
evt: {
|
||||
...mockCalendarEvent,
|
||||
assignmentReason: { category: "other", details: "from evt" },
|
||||
},
|
||||
booking: {
|
||||
id: 1,
|
||||
eventTypeId: 1,
|
||||
userId: 1,
|
||||
smsReminderNumber: null,
|
||||
assignmentReason: legacyArray,
|
||||
},
|
||||
});
|
||||
const result = builder.build(dto);
|
||||
const p = result.payload as EventPayloadType;
|
||||
|
||||
expect(p.assignmentReason).toEqual(legacyArray);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+102
-32
@@ -1,13 +1,51 @@
|
||||
import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs";
|
||||
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
|
||||
import type { CalEventResponses } from "@calcom/types/Calendar";
|
||||
import type { BookingWebhookEventDTO } from "../../../dto/types";
|
||||
import type { WebhookPayload } from "../../types";
|
||||
import {
|
||||
BaseBookingPayloadBuilder,
|
||||
type BookingExtraDataMap,
|
||||
type BookingPayloadParams,
|
||||
} from "../../base/BaseBookingPayloadBuilder";
|
||||
import type { WebhookPayload } from "../../types";
|
||||
import type { V20211020BookingEventPayload } from "./types";
|
||||
|
||||
/** Default labels for system booking fields (form-builder / E2E expectation) */
|
||||
const SYSTEM_FIELD_DEFAULT_LABELS: Record<string, string> = {
|
||||
name: "your_name",
|
||||
email: "email_address",
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize responses so system fields use default labels when label equals field name.
|
||||
* getCalEventResponses uses field name as label when bookingFields is missing; E2E expects default labels.
|
||||
*/
|
||||
function normalizeResponses(responses: CalEventResponses | null | undefined): CalEventResponses | undefined {
|
||||
if (!responses || typeof responses !== "object") return undefined;
|
||||
const out: CalEventResponses = {};
|
||||
for (const [name, entry] of Object.entries(responses)) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const defaultLabel = SYSTEM_FIELD_DEFAULT_LABELS[name];
|
||||
const label =
|
||||
defaultLabel && (entry.label === name || entry.label === undefined)
|
||||
? defaultLabel
|
||||
: (entry.label ?? name);
|
||||
out[name] = { ...entry, label };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Derive firstName/lastName from name for legacy payload parity (attendees[].firstName, attendees[].lastName). */
|
||||
function nameToFirstAndLast(name: string): { firstName: string; lastName: string } {
|
||||
const trimmed = (name ?? "").trim();
|
||||
if (!trimmed) return { firstName: "", lastName: "" };
|
||||
const firstSpace = trimmed.indexOf(" ");
|
||||
if (firstSpace === -1) return { firstName: trimmed, lastName: "" };
|
||||
return {
|
||||
firstName: trimmed.slice(0, firstSpace),
|
||||
lastName: trimmed.slice(firstSpace + 1).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking payload builder for webhook version 2021-10-20.
|
||||
@@ -57,6 +95,9 @@ export class BookingPayloadBuilder extends BaseBookingPayloadBuilder {
|
||||
status: BookingStatus.PENDING,
|
||||
triggerEvent: dto.triggerEvent,
|
||||
createdAt: dto.createdAt,
|
||||
extra: {
|
||||
metadata: (dto.metadata ?? {}) as { [key: string]: string | number | boolean | null },
|
||||
},
|
||||
});
|
||||
|
||||
case WebhookTriggerEvents.BOOKING_REJECTED:
|
||||
@@ -131,40 +172,69 @@ export class BookingPayloadBuilder extends BaseBookingPayloadBuilder {
|
||||
params: BookingPayloadParams<T>
|
||||
): WebhookPayload {
|
||||
const utcOffsetOrganizer = getUTCOffsetByTimezone(params.evt.organizer?.timeZone, params.evt.startTime);
|
||||
const organizer = { ...params.evt.organizer, utcOffset: utcOffsetOrganizer };
|
||||
const organizer = {
|
||||
...params.evt.organizer,
|
||||
utcOffset: utcOffsetOrganizer,
|
||||
usernameInOrg: params.evt.organizer?.usernameInOrg,
|
||||
};
|
||||
|
||||
const attendeesWithLegacyFields =
|
||||
params.evt.attendees?.map((a) => {
|
||||
const utcOffset = getUTCOffsetByTimezone(a.timeZone, params.evt.startTime);
|
||||
const nameParts =
|
||||
"firstName" in a && "lastName" in a
|
||||
? {
|
||||
firstName: (a as { firstName?: string }).firstName ?? "",
|
||||
lastName: (a as { lastName?: string }).lastName ?? "",
|
||||
}
|
||||
: nameToFirstAndLast(a.name ?? "");
|
||||
return {
|
||||
...a,
|
||||
utcOffset,
|
||||
firstName: nameParts.firstName,
|
||||
lastName: nameParts.lastName,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// Destructure assignmentReason out of evt so it doesn't leak via the
|
||||
// spread — this version uses its own legacy-shaped field instead.
|
||||
const { assignmentReason: _evtAssignmentReason, ...evtWithoutAssignmentReason } = params.evt;
|
||||
|
||||
const payload: V20211020BookingEventPayload = {
|
||||
...evtWithoutAssignmentReason,
|
||||
bookingId: params.booking.id,
|
||||
startTime: params.evt.startTime,
|
||||
endTime: params.evt.endTime,
|
||||
title: params.evt.title,
|
||||
type: params.evt.type,
|
||||
hashedLink: params.evt.hashedLink ?? null,
|
||||
conferenceData: params.evt.conferenceData,
|
||||
organizer,
|
||||
attendees: attendeesWithLegacyFields,
|
||||
location: params.evt.location,
|
||||
uid: params.evt.uid,
|
||||
customInputs: params.evt.customInputs,
|
||||
responses: normalizeResponses(params.evt.responses) ?? params.evt.responses,
|
||||
userFieldsResponses: params.evt.userFieldsResponses,
|
||||
status: params.status,
|
||||
eventTitle: params.eventType?.eventTitle,
|
||||
eventDescription: params.eventType?.eventDescription ?? null,
|
||||
requiresConfirmation: params.eventType?.requiresConfirmation ?? null,
|
||||
price: params.eventType?.price ?? 0,
|
||||
currency: params.eventType?.currency ?? "usd",
|
||||
length: params.eventType?.length ?? null,
|
||||
smsReminderNumber: params.booking.smsReminderNumber || undefined,
|
||||
additionalNotes: params.evt.additionalNotes ?? "",
|
||||
description: params.evt.description ?? params.evt.additionalNotes ?? "",
|
||||
assignmentReason: params.booking.assignmentReason ?? null,
|
||||
destinationCalendar: params.evt.destinationCalendar ?? null,
|
||||
...(params.extra || {}),
|
||||
};
|
||||
|
||||
return {
|
||||
triggerEvent: params.triggerEvent,
|
||||
createdAt: params.createdAt,
|
||||
payload: {
|
||||
...params.evt,
|
||||
bookingId: params.booking.id,
|
||||
startTime: params.evt.startTime,
|
||||
endTime: params.evt.endTime,
|
||||
title: params.evt.title,
|
||||
type: params.evt.type,
|
||||
organizer,
|
||||
attendees:
|
||||
params.evt.attendees?.map((a) => ({
|
||||
...a,
|
||||
utcOffset: getUTCOffsetByTimezone(a.timeZone, params.evt.startTime),
|
||||
})) ?? [],
|
||||
location: params.evt.location,
|
||||
uid: params.evt.uid,
|
||||
customInputs: params.evt.customInputs,
|
||||
responses: params.evt.responses,
|
||||
userFieldsResponses: params.evt.userFieldsResponses,
|
||||
status: params.status,
|
||||
eventTitle: params.eventType?.eventTitle,
|
||||
eventDescription: params.eventType?.eventDescription,
|
||||
requiresConfirmation: params.eventType?.requiresConfirmation,
|
||||
price: params.eventType?.price,
|
||||
currency: params.eventType?.currency,
|
||||
length: params.eventType?.length,
|
||||
smsReminderNumber: params.booking.smsReminderNumber || undefined,
|
||||
description: params.evt.description || params.evt.additionalNotes,
|
||||
...(params.extra || {}),
|
||||
},
|
||||
payload: payload as WebhookPayload["payload"],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { EventPayloadType } from "../../../dto/types";
|
||||
|
||||
/**
|
||||
* v2021-10-20 booking event payload.
|
||||
*
|
||||
* Overrides CalendarEvent's `assignmentReason` (which uses the transformed
|
||||
* `{ category, details }` shape) with the legacy webhook format: a raw array
|
||||
* of `[{ reasonEnum, reasonString }]` from the DB, or null.
|
||||
*
|
||||
* This keeps the webhook contract stable for v2021-10-20 subscribers.
|
||||
*/
|
||||
export type V20211020BookingEventPayload = Omit<EventPayloadType, "assignmentReason"> & {
|
||||
assignmentReason?: { reasonEnum: string; reasonString: string }[] | null;
|
||||
};
|
||||
@@ -4,7 +4,6 @@
|
||||
interface BaseQueueWebhookParams {
|
||||
/** Unique identifier for this webhook operation (generated if not provided) */
|
||||
operationId?: string;
|
||||
|
||||
/** Additional context data (kept minimal) */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -15,22 +14,11 @@ interface BaseQueueWebhookParams {
|
||||
* BOOKING_REQUESTED, BOOKING_REJECTED, BOOKING_NO_SHOW_UPDATED
|
||||
*/
|
||||
export interface QueueBookingWebhookParams extends BaseQueueWebhookParams {
|
||||
/** Booking UID (required) */
|
||||
bookingUid: string;
|
||||
|
||||
/** Event Type ID */
|
||||
eventTypeId?: number;
|
||||
|
||||
/** Team ID */
|
||||
teamId?: number | null;
|
||||
|
||||
/** User ID */
|
||||
userId?: number;
|
||||
|
||||
/** Organization ID */
|
||||
orgId?: number;
|
||||
|
||||
/** OAuth Client ID (for platform webhooks) */
|
||||
oAuthClientId?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,8 @@ export class WebhookRepository implements IWebhookRepository {
|
||||
}): Promise<WebhookSubscriber[]> {
|
||||
const { userId, eventTypeId, managedParentEventTypeId, teamIds, oAuthClientId, triggerEvent } = params;
|
||||
|
||||
// Use static SQL with IS NOT NULL guards and PostgreSQL ANY() for arrays
|
||||
// IMPORTANT: Explicit type casts (::int, ::text) are required for nullable params
|
||||
// PostgreSQL can't infer types for NULL values without explicit casts
|
||||
const results = await this.prisma.$queryRaw<WebhookQueryResult[]>`
|
||||
-- Platform webhooks (highest priority)
|
||||
SELECT
|
||||
@@ -142,8 +143,8 @@ export class WebhookRepository implements IWebhookRepository {
|
||||
2 as priority
|
||||
FROM "Webhook"
|
||||
WHERE active = true
|
||||
AND ${userId} IS NOT NULL
|
||||
AND "userId" = ${userId}
|
||||
AND ${userId}::int IS NOT NULL
|
||||
AND "userId" = ${userId}::int
|
||||
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
|
||||
AND platform = false
|
||||
|
||||
@@ -155,8 +156,8 @@ export class WebhookRepository implements IWebhookRepository {
|
||||
3 as priority
|
||||
FROM "Webhook"
|
||||
WHERE active = true
|
||||
AND ${eventTypeId} IS NOT NULL
|
||||
AND "eventTypeId" = ${eventTypeId}
|
||||
AND ${eventTypeId}::int IS NOT NULL
|
||||
AND "eventTypeId" = ${eventTypeId}::int
|
||||
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
|
||||
AND platform = false
|
||||
|
||||
@@ -168,8 +169,8 @@ export class WebhookRepository implements IWebhookRepository {
|
||||
4 as priority
|
||||
FROM "Webhook"
|
||||
WHERE active = true
|
||||
AND ${managedParentEventTypeId} IS NOT NULL
|
||||
AND "eventTypeId" = ${managedParentEventTypeId}
|
||||
AND ${managedParentEventTypeId}::int IS NOT NULL
|
||||
AND "eventTypeId" = ${managedParentEventTypeId}::int
|
||||
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
|
||||
AND platform = false
|
||||
|
||||
@@ -181,7 +182,7 @@ export class WebhookRepository implements IWebhookRepository {
|
||||
5 as priority
|
||||
FROM "Webhook"
|
||||
WHERE active = true
|
||||
AND ${teamIds} IS NOT NULL
|
||||
AND ${teamIds}::int[] IS NOT NULL
|
||||
AND cardinality(${teamIds}::int[]) > 0
|
||||
AND "teamId" = ANY(${teamIds}::int[])
|
||||
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
|
||||
@@ -195,8 +196,8 @@ export class WebhookRepository implements IWebhookRepository {
|
||||
6 as priority
|
||||
FROM "Webhook"
|
||||
WHERE active = true
|
||||
AND ${oAuthClientId} IS NOT NULL
|
||||
AND "platformOAuthClientId" = ${oAuthClientId}
|
||||
AND ${oAuthClientId}::text IS NOT NULL
|
||||
AND "platformOAuthClientId" = ${oAuthClientId}::text
|
||||
AND ${triggerEvent}::"WebhookTriggerEvents" = ANY("eventTriggers")
|
||||
AND platform = false
|
||||
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import type { BookingForCalEventBuilder } from "@calcom/features/CalendarEventBuilder";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { WebhookEventDTO, WebhookSubscriber } from "../dto/types";
|
||||
import type { PayloadBuilderFactory } from "../factory/versioned/PayloadBuilderFactory";
|
||||
import type { IWebhookDataFetcher } from "../interface/IWebhookDataFetcher";
|
||||
import type { IWebhookRepository } from "../interface/IWebhookRepository";
|
||||
import { DEFAULT_WEBHOOK_VERSION } from "../interface/IWebhookRepository";
|
||||
import type { ILogger } from "../interface/infrastructure";
|
||||
import type { WebhookTaskPayload } from "../types/webhookTask";
|
||||
import type { IWebhookService } from "../interface/services";
|
||||
import type {
|
||||
BookingWebhookTaskPayload,
|
||||
PaymentWebhookTaskPayload,
|
||||
WebhookTaskPayload,
|
||||
} from "../types/webhookTask";
|
||||
|
||||
/**
|
||||
* Webhook Task Consumer
|
||||
*
|
||||
* Processes webhook delivery tasks from the queue:
|
||||
* 1. Fetches webhook subscribers
|
||||
* 2. Fetches event-specific data from database (via injected data fetchers)
|
||||
* 3. Builds and sends webhook payloads
|
||||
*
|
||||
* Architecture:
|
||||
* - Uses Strategy Pattern: Data fetchers are injected, consumer orchestrates
|
||||
* - Open/Closed: Add new webhook types by registering fetchers, no code modification
|
||||
* - Single Responsibility: Consumer orchestrates, fetchers handle domain logic
|
||||
* - Dependency Inversion: Depends on IWebhookDataFetcher interface
|
||||
*
|
||||
* Phase 0: Scaffold with placeholders for HTTP delivery
|
||||
* Phase 1+: Full implementation with PayloadBuilders and HTTP client
|
||||
*/
|
||||
export class WebhookTaskConsumer {
|
||||
private readonly log: ILogger;
|
||||
|
||||
constructor(
|
||||
private readonly webhookRepository: IWebhookRepository,
|
||||
private readonly dataFetchers: IWebhookDataFetcher[],
|
||||
private readonly payloadBuilderFactory: PayloadBuilderFactory,
|
||||
private readonly webhookService: IWebhookService,
|
||||
logger: ILogger
|
||||
) {
|
||||
this.log = logger.getSubLogger({ prefix: ["[WebhookTaskConsumer]"] });
|
||||
@@ -42,7 +38,6 @@ export class WebhookTaskConsumer {
|
||||
});
|
||||
|
||||
try {
|
||||
// Step 1: Get the appropriate data fetcher for this trigger event
|
||||
const fetcher = this.getDataFetcher(payload.triggerEvent);
|
||||
|
||||
if (!fetcher) {
|
||||
@@ -53,7 +48,6 @@ export class WebhookTaskConsumer {
|
||||
throw new Error(`No data fetcher registered for trigger event: ${payload.triggerEvent}`);
|
||||
}
|
||||
|
||||
// Step 2: Fetch webhook subscribers
|
||||
const subscriberContext = fetcher.getSubscriberContext(payload);
|
||||
const subscribers = await this.webhookRepository.getSubscribers(subscriberContext);
|
||||
|
||||
@@ -66,7 +60,6 @@ export class WebhookTaskConsumer {
|
||||
operationId: payload.operationId,
|
||||
});
|
||||
|
||||
// Step 3: Fetch event-specific data via data fetcher
|
||||
const eventData = await fetcher.fetchEventData(payload);
|
||||
|
||||
if (!eventData) {
|
||||
@@ -77,7 +70,6 @@ export class WebhookTaskConsumer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Build and send webhooks to each subscriber
|
||||
await this.sendWebhooksToSubscribers(subscribers, eventData, payload);
|
||||
|
||||
this.log.debug("Webhook delivery task completed", {
|
||||
@@ -104,18 +96,120 @@ export class WebhookTaskConsumer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build webhook payloads and send to each subscriber.
|
||||
*
|
||||
* TODO: Implement with PayloadBuilders and HTTP client (Phase 1+)
|
||||
* Build webhook payloads and send to each subscriber via WebhookService.
|
||||
* Uses WebhookService.processWebhooks() for HTTP delivery.
|
||||
*/
|
||||
private async sendWebhooksToSubscribers(
|
||||
subscribers: unknown[],
|
||||
subscribers: WebhookSubscriber[],
|
||||
eventData: Record<string, unknown>,
|
||||
payload: WebhookTaskPayload
|
||||
): Promise<void> {
|
||||
this.log.debug("Webhook sending not implemented yet (Phase 0 scaffold)", {
|
||||
subscriberCount: subscribers.length,
|
||||
triggerEvent: payload.triggerEvent,
|
||||
});
|
||||
if (subscribers.length === 0) {
|
||||
this.log.debug("No subscribers to send webhooks to");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dto = this.buildDTO(eventData, payload);
|
||||
|
||||
if (!dto) {
|
||||
this.log.warn("Failed to build DTO for webhook", {
|
||||
triggerEvent: payload.triggerEvent,
|
||||
operationId: payload.operationId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const builder = this.payloadBuilderFactory.getBuilder(DEFAULT_WEBHOOK_VERSION, dto.triggerEvent);
|
||||
const webhookPayload = builder.build(dto);
|
||||
|
||||
await this.webhookService.processWebhooks(dto.triggerEvent, webhookPayload, subscribers);
|
||||
|
||||
this.log.debug("Webhook sending completed", {
|
||||
subscriberCount: subscribers.length,
|
||||
triggerEvent: payload.triggerEvent,
|
||||
operationId: payload.operationId,
|
||||
});
|
||||
} catch (error) {
|
||||
this.log.error("Error in sendWebhooksToSubscribers", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
triggerEvent: payload.triggerEvent,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Build WebhookEventDTO from fetched event data.
|
||||
*
|
||||
* This method maps the data fetched from DB into the DTO structure
|
||||
* expected by PayloadBuilders.
|
||||
*/
|
||||
private buildDTO(eventData: Record<string, unknown>, payload: WebhookTaskPayload): WebhookEventDTO | null {
|
||||
const { triggerEvent, timestamp } = payload;
|
||||
|
||||
const calendarEvent = eventData.calendarEvent as CalendarEvent | undefined;
|
||||
const booking = eventData.booking as BookingForCalEventBuilder | undefined;
|
||||
const eventType = booking?.eventType;
|
||||
|
||||
if (!calendarEvent || !booking || !eventType) {
|
||||
this.log.warn("Missing required data to build DTO", {
|
||||
hasCalendarEvent: !!calendarEvent,
|
||||
hasBooking: !!booking,
|
||||
hasEventType: !!eventType,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventTypeInfo = {
|
||||
id: eventType.id,
|
||||
eventTitle: eventType.title,
|
||||
eventDescription: eventType.description,
|
||||
requiresConfirmation: eventType.requiresConfirmation,
|
||||
price: eventType.price,
|
||||
currency: eventType.currency,
|
||||
length: eventType.length,
|
||||
};
|
||||
|
||||
const bookingPayload = payload as BookingWebhookTaskPayload;
|
||||
const baseDTO = {
|
||||
createdAt: timestamp,
|
||||
bookingId: booking.id,
|
||||
eventTypeId: eventType.id,
|
||||
userId: booking.user?.id ?? null,
|
||||
teamId: bookingPayload.teamId ?? null,
|
||||
orgId: bookingPayload.orgId,
|
||||
platformClientId: bookingPayload.platformClientId ?? bookingPayload.oAuthClientId,
|
||||
evt: calendarEvent,
|
||||
eventType: eventTypeInfo,
|
||||
booking: {
|
||||
id: booking.id,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
userId: booking.userId,
|
||||
startTime: booking.startTime,
|
||||
smsReminderNumber: booking.smsReminderNumber,
|
||||
iCalSequence: booking.iCalSequence,
|
||||
// Raw assignmentReason from DB for legacy format [{ reasonEnum, reasonString }]
|
||||
assignmentReason: booking.assignmentReason,
|
||||
},
|
||||
};
|
||||
|
||||
switch (triggerEvent) {
|
||||
case WebhookTriggerEvents.BOOKING_REQUESTED:
|
||||
return {
|
||||
...baseDTO,
|
||||
triggerEvent,
|
||||
metadata: {
|
||||
...(typeof booking.metadata === "object" &&
|
||||
booking.metadata !== null &&
|
||||
!Array.isArray(booking.metadata)
|
||||
? booking.metadata
|
||||
: {}),
|
||||
...(bookingPayload.metadata ?? {}),
|
||||
},
|
||||
} as WebhookEventDTO;
|
||||
default:
|
||||
this.log.warn("Unsupported trigger event for DTO building", { triggerEvent });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import type { IWebhookDataFetcher, SubscriberContext } from "../../interface/IWebhookDataFetcher";
|
||||
import type { ILogger } from "../../interface/infrastructure";
|
||||
@@ -13,7 +15,10 @@ export class BookingWebhookDataFetcher implements IWebhookDataFetcher {
|
||||
WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
|
||||
]);
|
||||
|
||||
constructor(private readonly logger: ILogger) {}
|
||||
constructor(
|
||||
private readonly logger: ILogger,
|
||||
private readonly bookingRepository: BookingRepository
|
||||
) {}
|
||||
|
||||
canHandle(triggerEvent: WebhookTriggerEvents): boolean {
|
||||
return this.BOOKING_TRIGGERS.has(triggerEvent as never);
|
||||
@@ -27,9 +32,44 @@ export class BookingWebhookDataFetcher implements IWebhookDataFetcher {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Implement using BookingRepository/EventTypeRepository/UserRepository (Phase 1+)
|
||||
this.logger.debug("Booking data fetch not implemented yet (Phase 0 scaffold)", { bookingUid });
|
||||
return { bookingUid, _scaffold: true };
|
||||
try {
|
||||
// Fetch complete booking data with all related entities
|
||||
const booking = await this.bookingRepository.getBookingForCalEventBuilderFromUid(bookingUid);
|
||||
|
||||
if (!booking) {
|
||||
this.logger.warn("Booking not found", { bookingUid });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build CalendarEvent using the builder pattern (same pattern used by email/sms tasks)
|
||||
// Map oAuthClientId to platformClientId for platform metadata
|
||||
const calendarEvent = (
|
||||
await CalendarEventBuilder.fromBooking(booking, {
|
||||
platformClientId: payload.oAuthClientId ?? undefined,
|
||||
platformRescheduleUrl: payload.platformRescheduleUrl ?? undefined,
|
||||
platformCancelUrl: payload.platformCancelUrl ?? undefined,
|
||||
platformBookingUrl: payload.platformBookingUrl ?? undefined,
|
||||
})
|
||||
).build();
|
||||
|
||||
if (!calendarEvent) {
|
||||
this.logger.error("Failed to build CalendarEvent from booking", { bookingUid });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return complete data needed for webhook payload building
|
||||
return {
|
||||
calendarEvent,
|
||||
booking,
|
||||
eventType: booking.eventType,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("Error fetching booking data for webhook", {
|
||||
bookingUid,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getSubscriberContext(payload: BookingWebhookTaskPayload): SubscriberContext {
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
import type { Queue, schemaTask } from "@trigger.dev/sdk";
|
||||
import { queue } from "@trigger.dev/sdk";
|
||||
import { type Queue, queue, type schemaTask } from "@trigger.dev/sdk";
|
||||
|
||||
type WebhookDeliveryTask = Pick<Parameters<typeof schemaTask>[0], "machine" | "retry" | "queue">;
|
||||
|
||||
/**
|
||||
* Queue configuration for webhook delivery tasks
|
||||
*
|
||||
* Webhooks are time-sensitive, so we use a moderate concurrency limit
|
||||
* to ensure timely delivery while not overwhelming external services.
|
||||
*/
|
||||
export const webhookDeliveryQueue: Queue = queue({
|
||||
name: "webhook-delivery",
|
||||
concurrencyLimit: 20,
|
||||
concurrencyLimit: 25,
|
||||
});
|
||||
|
||||
/**
|
||||
* Task configuration for webhook delivery
|
||||
*
|
||||
* - machine: small-2x for lightweight HTTP requests
|
||||
* - retry: 3 attempts with exponential backoff for transient failures
|
||||
*/
|
||||
export const webhookDeliveryTaskConfig: WebhookDeliveryTask = {
|
||||
machine: "small-1x",
|
||||
queue: webhookDeliveryQueue,
|
||||
machine: "small-2x",
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
factor: 1.8,
|
||||
minTimeoutInMs: 500,
|
||||
maxTimeoutInMs: 30_000,
|
||||
factor: 2,
|
||||
minTimeoutInMs: 30000,
|
||||
maxTimeoutInMs: 600000,
|
||||
randomize: true,
|
||||
outOfMemory: {
|
||||
machine: "medium-1x",
|
||||
|
||||
@@ -30,6 +30,10 @@ export const bookingWebhookTaskPayloadSchema = baseWebhookTaskSchema.extend({
|
||||
userId: z.number().optional(),
|
||||
orgId: z.number().optional(),
|
||||
oAuthClientId: z.string().nullable().optional(),
|
||||
platformClientId: z.string().nullable().optional(),
|
||||
platformRescheduleUrl: z.string().nullable().optional(),
|
||||
platformCancelUrl: z.string().nullable().optional(),
|
||||
platformBookingUrl: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,3 +21,5 @@ export { BookingEmailAndSmsTriggerDevTasker } from "@calcom/features/bookings/li
|
||||
export { BookingAttendeesService } from "@calcom/features/bookings/services/BookingAttendeesService";
|
||||
export { PrismaOrgMembershipRepository } from "@calcom/features/membership/repositories/PrismaOrgMembershipRepository";
|
||||
export { addGuestsHandler } from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler";
|
||||
export type { IWebhookProducerService } from "@calcom/features/webhooks/lib/interface/WebhookProducerService";
|
||||
export { getWebhookProducer } from "@calcom/features/di/webhooks/containers/webhook";
|
||||
@@ -28,6 +28,8 @@ export {
|
||||
DEFAULT_WEBHOOK_VERSION,
|
||||
WebhookVersion,
|
||||
} from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
|
||||
export type { IWebhookProducerService } from "@calcom/features/webhooks/lib/interface/WebhookProducerService";
|
||||
export { getWebhookProducer } from "@calcom/features/di/webhooks/containers/webhook";
|
||||
export {
|
||||
AttributeType,
|
||||
CreationSource,
|
||||
|
||||
@@ -5,3 +5,5 @@ export {
|
||||
INCREMENT_USAGE_JOB_ID,
|
||||
RESCHEDULE_USAGE_INCREMENT_JOB_ID,
|
||||
} from "@calcom/features/ee/organizations/lib/billing/tasker/constants";
|
||||
export type { Tasker } from "@calcom/features/tasker/tasker";
|
||||
export { getTasker } from "@calcom/features/tasker/tasker-factory";
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, vi } from "vitest";
|
||||
|
||||
export type MockWebhookProducer = {
|
||||
queueBookingCreatedWebhook: ReturnType<typeof vi.fn>;
|
||||
queueBookingCancelledWebhook: ReturnType<typeof vi.fn>;
|
||||
queueBookingRescheduledWebhook: ReturnType<typeof vi.fn>;
|
||||
queueBookingRequestedWebhook: ReturnType<typeof vi.fn>;
|
||||
queueBookingRejectedWebhook: ReturnType<typeof vi.fn>;
|
||||
queueBookingPaymentInitiatedWebhook: ReturnType<typeof vi.fn>;
|
||||
queueBookingPaidWebhook: ReturnType<typeof vi.fn>;
|
||||
queueBookingNoShowUpdatedWebhook: ReturnType<typeof vi.fn>;
|
||||
queueFormSubmittedWebhook: ReturnType<typeof vi.fn>;
|
||||
queueRecordingReadyWebhook: ReturnType<typeof vi.fn>;
|
||||
queueOOOCreatedWebhook: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
export function createMockWebhookProducer(): MockWebhookProducer {
|
||||
return {
|
||||
queueBookingCreatedWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueBookingCancelledWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueBookingRescheduledWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueBookingRequestedWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueBookingRejectedWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueBookingPaymentInitiatedWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueBookingPaidWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueBookingNoShowUpdatedWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueFormSubmittedWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueRecordingReadyWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
queueOOOCreatedWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export function expectWebhookProducerCalled(
|
||||
mock: MockWebhookProducer,
|
||||
method: keyof MockWebhookProducer,
|
||||
expected: Record<string, unknown>
|
||||
) {
|
||||
expect(mock[method]).toHaveBeenCalledWith(expect.objectContaining(expected));
|
||||
}
|
||||
|
||||
export function expectWebhookProducerNotCalled(mock: MockWebhookProducer, method: keyof MockWebhookProducer) {
|
||||
expect(mock[method]).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
export function resetMockWebhookProducer(mock: MockWebhookProducer) {
|
||||
for (const fn of Object.values(mock)) {
|
||||
fn.mockClear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user