Files
Syed Ali ShahbazGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Hariom Balhara
5d65df9c05 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: [email protected] <[email protected]>

* 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: [email protected] <[email protected]>

* test: remove paid events BOOKING_REQUESTED test (moved to new architecture)

Co-Authored-By: [email protected] <[email protected]>

* 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: [email protected] <[email protected]>

* 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: [email protected] <[email protected]>

* remove unnecessary tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <[email protected]>
2026-02-19 20:21:26 +04:00

385 lines
12 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type {
BookingWebhookEventDTO,
EventPayloadType,
EventTypeInfo,
} from "../../../dto/types";
import { BookingPayloadBuilder } from "./BookingPayloadBuilder";
vi.mock("@calcom/lib/dayjs", () => ({
getUTCOffsetByTimezone: vi.fn(() => 0),
}));
describe("v2021-10-20/BookingPayloadBuilder", () => {
const builder = new BookingPayloadBuilder();
const mockEventType: EventTypeInfo = {
eventTitle: "Test Event",
eventDescription: "Test Description",
requiresConfirmation: false,
price: 0,
currency: "USD",
length: 30,
};
const mockCalendarEvent: CalendarEvent = {
type: "test-event",
title: "Test Meeting",
description: "Meeting description",
additionalNotes: "Additional notes",
startTime: "2024-01-15T10:00:00Z",
endTime: "2024-01-15T10:30:00Z",
organizer: {
id: 1,
email: "[email protected]",
name: "Test Organizer",
timeZone: "UTC",
language: { locale: "en" },
},
attendees: [
{
email: "[email protected]",
name: "Test Attendee",
timeZone: "UTC",
language: { locale: "en" },
},
],
location: "https://cal.com/video/123",
uid: "booking-uid-123",
customInputs: {},
responses: {},
userFieldsResponses: {},
};
const createMockDTO = (
triggerEvent: WebhookTriggerEvents,
extra: Partial<BookingWebhookEventDTO> = {}
): BookingWebhookEventDTO => ({
triggerEvent,
createdAt: "2024-01-15T10:00:00Z",
booking: {
id: 1,
eventTypeId: 1,
userId: 1,
smsReminderNumber: null,
},
eventType: mockEventType,
evt: mockCalendarEvent,
...extra,
});
it("should be instance of correct class", () => {
expect(builder).toBeInstanceOf(BookingPayloadBuilder);
});
it("should correctly build BOOKING_CREATED payload", () => {
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CREATED);
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_CREATED);
expect(payload.payload.status).toBe(BookingStatus.ACCEPTED);
expect(payload.payload.bookingId).toBe(1);
expect(payload.payload.title).toBe("Test Meeting");
});
it("should correctly build BOOKING_CANCELLED payload", () => {
const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CANCELLED, {
cancelledBy: "[email protected]",
cancellationReason: "Schedule conflict",
});
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_CANCELLED);
expect(payload.payload.status).toBe(BookingStatus.CANCELLED);
expect(payload.payload.cancelledBy).toBe("[email protected]");
expect(payload.payload.cancellationReason).toBe("Schedule conflict");
});
it("should handle all booking trigger events", () => {
const triggers = [
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_CANCELLED,
WebhookTriggerEvents.BOOKING_REQUESTED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
WebhookTriggerEvents.BOOKING_REJECTED,
WebhookTriggerEvents.BOOKING_PAID,
WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
];
triggers.forEach((trigger) => {
let dto: BookingWebhookEventDTO;
if (trigger === WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED) {
dto = {
triggerEvent: trigger,
createdAt: "2024-01-15T10:00:00Z",
bookingUid: "booking-uid-123",
bookingId: 1,
attendees: [{ email: "[email protected]", noShow: true }],
message: "No-show",
} as BookingWebhookEventDTO;
} else {
dto = createMockDTO(trigger);
}
const payload = builder.build(dto);
expect(payload.triggerEvent).toBe(trigger);
expect(payload.payload).toBeDefined();
});
});
it("should never throw on missing optional fields", () => {
// Test with minimal DTO
const minimalDTO: BookingWebhookEventDTO = {
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
createdAt: "2024-01-15T10:00:00Z",
booking: {
id: 1,
eventTypeId: 1,
userId: 1,
smsReminderNumber: null,
},
eventType: mockEventType,
evt: {
...mockCalendarEvent,
organizer: null,
attendees: [],
},
};
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: "[email protected]", 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("[email protected]");
});
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: "[email protected]", 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: "[email protected]",
name: "John Doe",
timeZone: "UTC",
language: { locale: "en" },
},
{
email: "[email protected]",
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);
});
});
});