From 0391bfe887d1da27d037512973baecd30a875c08 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Thu, 2 May 2024 10:42:38 -0400 Subject: [PATCH] test: add unit tests for sms sending (#14737) * add tests for workflow sms * fix type error * improvements * add tests for team workflow * rename test * add emailsToReceive everywhere * fix type errors * code clean up * fix fresh-booking.test.ts * add destination email to emailsToReceive * remove unused webhooks --------- Co-authored-by: CarinaWolli Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Hariom --- apps/web/test/fixtures/fixtures.ts | 11 + .../utils/bookingScenario/bookingScenario.ts | 5 +- .../web/test/utils/bookingScenario/expects.ts | 68 ++- .../getMockRequestDataForBooking.ts | 1 + apps/web/test/utils/bookingScenario/test.ts | 6 +- .../test/fresh-booking.test.ts | 31 +- .../handleNewBooking/test/reschedule.test.ts | 14 +- .../test/workflow-notifications.test.ts | 541 ++++++++++++++++++ .../lib/reminders/providers/twilioProvider.ts | 22 +- packages/lib/testSMS.ts | 21 + 10 files changed, 674 insertions(+), 46 deletions(-) create mode 100644 packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts create mode 100644 packages/lib/testSMS.ts diff --git a/apps/web/test/fixtures/fixtures.ts b/apps/web/test/fixtures/fixtures.ts index 121c188bb3..438874624b 100644 --- a/apps/web/test/fixtures/fixtures.ts +++ b/apps/web/test/fixtures/fixtures.ts @@ -2,15 +2,20 @@ import { test as base } from "vitest"; import { getTestEmails } from "@calcom/lib/testEmails"; +import { getTestSMS } from "@calcom/lib/testSMS"; export interface Fixtures { emails: ReturnType; + sms: ReturnType; } export const test = base.extend({ emails: async ({}, use) => { await use(getEmailsFixture()); }, + sms: async ({}, use) => { + await use(getSMSFixture()); + }, }); function getEmailsFixture() { @@ -18,3 +23,9 @@ function getEmailsFixture() { get: getTestEmails, }; } + +function getSMSFixture() { + return { + get: getTestSMS, + }; +} diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index de929ff154..e202773b13 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -19,7 +19,7 @@ import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import type { WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/client"; -import type { SchedulingType } from "@calcom/prisma/enums"; +import type { SchedulingType, SMSLockState } from "@calcom/prisma/enums"; import type { BookingStatus } from "@calcom/prisma/enums"; import type { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import type { userMetadataType } from "@calcom/prisma/zod-utils"; @@ -952,6 +952,7 @@ export function getOrganizer({ teams, organizationId, metadata, + smsLockState, }: { name: string; email: string; @@ -965,6 +966,7 @@ export function getOrganizer({ weekStart?: WeekDays; teams?: InputUser["teams"]; metadata?: userMetadataType; + smsLockState?: SMSLockState; }) { return { ...TestData.users.example, @@ -981,6 +983,7 @@ export function getOrganizer({ organizationId, profiles: [], metadata, + smsLockState, }; } diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index a018ebae9d..c01bc1745e 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -307,38 +307,72 @@ export function expectWebhookToHaveBeenCalledWith( export function expectWorkflowToBeTriggered({ emails, - organizer, - destinationEmail, + emailsToReceive, }: { emails: Fixtures["emails"]; - organizer: { email: string; name: string; timeZone: string }; - destinationEmail?: string; + emailsToReceive: string[]; }) { const subjectPattern = /^Reminder: /i; - expect(emails.get()).toEqual( + emailsToReceive.forEach((email) => { + expect(emails.get()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subject: expect.stringMatching(subjectPattern), + to: email, + }), + ]) + ); + }); +} + +export function expectWorkflowToBeNotTriggered({ + emails, + emailsToReceive, +}: { + emails: Fixtures["emails"]; + emailsToReceive: string[]; +}) { + const subjectPattern = /^Reminder: /i; + + emailsToReceive.forEach((email) => { + expect(emails.get()).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subject: expect.stringMatching(subjectPattern), + to: email, + }), + ]) + ); + }); +} + +export function expectSMSWorkflowToBeTriggered({ + sms, + toNumber, +}: { + sms: Fixtures["sms"]; + toNumber: string; +}) { + expect(sms.get()).toEqual( expect.arrayContaining([ expect.objectContaining({ - subject: expect.stringMatching(subjectPattern), - to: destinationEmail ?? organizer.email, + to: toNumber, }), ]) ); } -export function expectWorkflowToBeNotTriggered({ - emails, - organizer, +export function expectSMSWorkflowToBeNotTriggered({ + sms, + toNumber, }: { - emails: Fixtures["emails"]; - organizer: { email: string; name: string; timeZone: string }; + sms: Fixtures["sms"]; + toNumber: string; }) { - const subjectPattern = /^Reminder: /i; - - expect(emails.get()).not.toEqual( + expect(sms.get()).not.toEqual( expect.arrayContaining([ expect.objectContaining({ - subject: expect.stringMatching(subjectPattern), - to: organizer.email, + to: toNumber, }), ]) ); diff --git a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts index 00bddf9fef..49298a98df 100644 --- a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts +++ b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts @@ -30,6 +30,7 @@ export function getMockRequestDataForBooking({ email: string; name: string; location: { optionValue: ""; value: string }; + smsReminderNumber?: string; }; }; }) { diff --git a/apps/web/test/utils/bookingScenario/test.ts b/apps/web/test/utils/bookingScenario/test.ts index 7a00f894fd..b3957f982f 100644 --- a/apps/web/test/utils/bookingScenario/test.ts +++ b/apps/web/test/utils/bookingScenario/test.ts @@ -15,7 +15,7 @@ const _testWithAndWithoutOrg = ( const t = mode === "only" ? test.only : mode === "skip" ? test.skip : test; t( `${description} - With org`, - async ({ emails, meta, task, onTestFailed, expect, skip }) => { + async ({ emails, sms, meta, task, onTestFailed, expect, skip }) => { const org = await createOrganization({ name: "Test Org", slug: "testorg", @@ -27,6 +27,7 @@ const _testWithAndWithoutOrg = ( onTestFailed, expect, emails, + sms, skip, org: { organization: org, @@ -39,9 +40,10 @@ const _testWithAndWithoutOrg = ( t( `${description}`, - async ({ emails, meta, task, onTestFailed, expect, skip }) => { + async ({ emails, sms, meta, task, onTestFailed, expect, skip }) => { await fn({ emails, + sms, meta, task, onTestFailed, diff --git a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index faba12aa50..21e70e2169 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -42,6 +42,7 @@ import { import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest"; import { expectWorkflowToBeTriggered, + expectWorkflowToBeNotTriggered, expectSuccessfulBookingCreationEmails, expectBookingToBeInDatabase, expectAwaitingPaymentEmails, @@ -51,7 +52,6 @@ import { expectBookingPaymentIntiatedWebhookToHaveBeenFired, expectBrokenIntegrationEmails, expectSuccessfulCalendarEventCreationInCalendar, - expectWorkflowToBeNotTriggered, expectICalUIDAsString, } from "@calcom/web/test/utils/bookingScenario/expects"; import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; @@ -215,9 +215,8 @@ describe("handleNewBooking", () => { }); expectWorkflowToBeTriggered({ - organizer, + emailsToReceive: [organizerDestinationCalendarEmailOnEventType], emails, - destinationEmail: organizerDestinationCalendarEmailOnEventType, }); expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { calendarId: "event-type-1@google-calendar.com", @@ -379,7 +378,7 @@ describe("handleNewBooking", () => { iCalUID: createdBooking.iCalUID, }); - expectWorkflowToBeTriggered({ organizer, emails }); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", // We won't be sending evt.destinationCalendar in this case. @@ -541,7 +540,7 @@ describe("handleNewBooking", () => { iCalUID: createdBooking.iCalUID, }); - expectWorkflowToBeTriggered({ organizer, emails }); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { calendarId: "organizer@google-calendar.com", videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", @@ -675,7 +674,7 @@ describe("handleNewBooking", () => { ], }); - expectWorkflowToBeTriggered({ organizer, emails }); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); // FIXME: We should send Broken Integration emails on calendar event creation failure // expectCalendarEventCreationFailureEmails({ booker, organizer, emails }); @@ -825,7 +824,7 @@ describe("handleNewBooking", () => { iCalUID: createdBooking.iCalUID, }); - expectWorkflowToBeTriggered({ organizer, emails }); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { calendarId: "organizer@google-calendar.com", @@ -982,7 +981,7 @@ describe("handleNewBooking", () => { ], }); - expectWorkflowToBeTriggered({ organizer, emails }); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { calendarId: "organizer@google-calendar.com", videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", @@ -1511,7 +1510,7 @@ describe("handleNewBooking", () => { status: BookingStatus.PENDING, }); - expectWorkflowToBeNotTriggered({ organizer, emails }); + expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails }); expectBookingRequestedEmails({ booker, @@ -1636,7 +1635,7 @@ describe("handleNewBooking", () => { }), }); - expectWorkflowToBeNotTriggered({ organizer, emails }); + expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails }); expectBookingRequestedEmails({ booker, @@ -1764,7 +1763,7 @@ describe("handleNewBooking", () => { iCalUID: createdBooking.iCalUID, }); - expectWorkflowToBeTriggered({ organizer, emails }); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); const iCalUID = expectICalUIDAsString(createdBooking.iCalUID); @@ -1897,7 +1896,7 @@ describe("handleNewBooking", () => { iCalUID: createdBooking.iCalUID, }); - expectWorkflowToBeNotTriggered({ organizer, emails }); + expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails }); expectBookingRequestedEmails({ booker, organizer, emails }); @@ -2076,7 +2075,7 @@ describe("handleNewBooking", () => { iCalUID: createdBooking.iCalUID, }); - expectWorkflowToBeTriggered({ organizer, emails }); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); const iCalUID = expectICalUIDAsString(createdBooking.iCalUID); @@ -2220,7 +2219,7 @@ describe("handleNewBooking", () => { }), }); - expectWorkflowToBeNotTriggered({ organizer, emails }); + expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails }); expectAwaitingPaymentEmails({ organizer, booker, emails }); @@ -2244,7 +2243,7 @@ describe("handleNewBooking", () => { status: BookingStatus.ACCEPTED, }); - expectWorkflowToBeTriggered({ organizer, emails }); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); expectBookingCreatedWebhookToHaveBeenFired({ booker, @@ -2374,7 +2373,7 @@ describe("handleNewBooking", () => { status: BookingStatus.PENDING, }); - expectWorkflowToBeNotTriggered({ organizer, emails }); + expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails }); expectAwaitingPaymentEmails({ organizer, booker, emails }); expectBookingPaymentIntiatedWebhookToHaveBeenFired({ diff --git a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts index b942c118b0..f332a03682 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts @@ -240,7 +240,7 @@ describe("handleNewBooking", () => { ], }, }); - expectWorkflowToBeTriggered({ emails, organizer }); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, { calEvent: { @@ -459,7 +459,7 @@ describe("handleNewBooking", () => { }, }); - expectWorkflowToBeTriggered({ emails, organizer }); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, { calEvent: { @@ -656,7 +656,7 @@ describe("handleNewBooking", () => { }, }); - expectWorkflowToBeTriggered({ emails, organizer }); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); // FIXME: We should send Broken Integration emails on calendar event updation failure // expectBrokenIntegrationEmails({ booker, organizer, emails }); @@ -850,7 +850,7 @@ describe("handleNewBooking", () => { }, }); - expectWorkflowToBeTriggered({ emails, organizer }); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); expectBookingRequestedEmails({ booker, @@ -1085,7 +1085,7 @@ describe("handleNewBooking", () => { }, }); - expectWorkflowToBeTriggered({ emails, organizer }); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, { calEvent: { @@ -1312,7 +1312,7 @@ describe("handleNewBooking", () => { }, }); - //expectWorkflowToBeTriggered({emails, organizer}); + //expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); expectBookingRequestedEmails({ booker, @@ -1559,7 +1559,7 @@ describe("handleNewBooking", () => { }, }); - expectWorkflowToBeTriggered({ emails, organizer }); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, { calEvent: { diff --git a/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts b/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts new file mode 100644 index 0000000000..b11ca02128 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts @@ -0,0 +1,541 @@ +import { describe, beforeEach } from "vitest"; + +import { resetTestSMS } from "@calcom/lib/testSMS"; +import { SMSLockState, SchedulingType } from "@calcom/prisma/enums"; +import { test } from "@calcom/web/test/fixtures/fixtures"; +import { + createBookingScenario, + getGoogleCalendarCredential, + TestData, + getOrganizer, + getBooker, + getScenarioData, + mockSuccessfulVideoMeetingCreation, + mockCalendarToHaveNoBusySlots, + BookingLocations, + getDate, + Timezones, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest"; +import { + expectWorkflowToBeTriggered, + expectSMSWorkflowToBeTriggered, + expectSMSWorkflowToBeNotTriggered, +} from "@calcom/web/test/utils/bookingScenario/expects"; +import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +// Local test runs sometime gets too slow +const timeout = process.env.CI ? 5000 : 20000; + +describe("handleNewBooking", () => { + setupAndTeardown(); + + beforeEach(() => { + resetTestSMS(); + }); + + describe("User Workflows", () => { + test( + "should send workflow email and sms when booking is created", + async ({ emails, sms }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizerOtherEmail = "organizer2@example.com"; + const organizerDestinationCalendarEmailOnEventType = "organizerEventTypeEmail@example.com"; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + primaryEmail: organizerOtherEmail, + }, + }); + + await createBookingScenario( + getScenarioData({ + workflows: [ + { + userId: organizer.id, + trigger: "NEW_EVENT", + action: "EMAIL_HOST", + template: "REMINDER", + activeEventTypeId: 1, + }, + { + userId: organizer.id, + trigger: "NEW_EVENT", + action: "SMS_ATTENDEE", + template: "REMINDER", + activeEventTypeId: 1, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + useEventTypeDestinationCalendarEmail: true, + users: [ + { + id: 101, + }, + ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@google-calendar.com", + primaryEmail: organizerDestinationCalendarEmailOnEventType, + }, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + smsReminderNumber: "000", + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await handleNewBooking(req); + + expectSMSWorkflowToBeTriggered({ + sms, + toNumber: "000", + }); + + expectWorkflowToBeTriggered({ + emailsToReceive: [organizerDestinationCalendarEmailOnEventType], + emails, + }); + }, + timeout + ); + test( + "should not send workflow sms when booking is created if the organizer is locked for sms sending", + async ({ sms }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizerOtherEmail = "organizer2@example.com"; + const organizerDestinationCalendarEmailOnEventType = "organizerEventTypeEmail@example.com"; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + primaryEmail: organizerOtherEmail, + }, + smsLockState: SMSLockState.LOCKED, + }); + + await createBookingScenario( + getScenarioData({ + workflows: [ + { + userId: organizer.id, + trigger: "NEW_EVENT", + action: "SMS_ATTENDEE", + template: "REMINDER", + activeEventTypeId: 1, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + useEventTypeDestinationCalendarEmail: true, + users: [ + { + id: 101, + }, + ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@google-calendar.com", + primaryEmail: organizerDestinationCalendarEmailOnEventType, + }, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + smsReminderNumber: "000", + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await handleNewBooking(req); + + expectSMSWorkflowToBeNotTriggered({ + sms, + toNumber: "000", + }); + }, + timeout + ); + }); + describe("Team Workflows", () => { + test( + "should send workflow email and sms when booking is created", + async ({ emails, sms }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizerDestinationCalendarEmailOnEventType = "organizerEventTypeEmail@example.com"; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + primaryEmail: organizerDestinationCalendarEmailOnEventType, + }, + teams: [ + { + membership: { + accepted: true, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + }, + }, + ], + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + defaultScheduleId: null, + email: "other-team-member-1@example.com", + timeZone: Timezones["+0:00"], + id: 102, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + await createBookingScenario( + getScenarioData({ + workflows: [ + { + teamId: 1, + trigger: "NEW_EVENT", + action: "EMAIL_HOST", + template: "REMINDER", + activeEventTypeId: 1, + }, + { + teamId: 1, + trigger: "NEW_EVENT", + action: "SMS_ATTENDEE", + template: "REMINDER", + activeEventTypeId: 1, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 15, + schedulingType: SchedulingType.COLLECTIVE, + length: 15, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + teamId: 1, + 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: "dailyvideo", + 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}T09:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T09:15:00.000Z`, + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + smsReminderNumber: "000", + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await handleNewBooking(req); + + expectSMSWorkflowToBeTriggered({ + sms, + toNumber: "000", + }); + + expectWorkflowToBeTriggered({ + // emailsToReceive: [organizer.email].concat(otherTeamMembers.map(member => member.email)), + emailsToReceive: [organizer.email], + emails, + }); + }, + timeout + ); + + test( + "should not send workflow sms when booking is created if the team is locked for sms sending", + async ({ emails, sms }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizerDestinationCalendarEmailOnEventType = "organizerEventTypeEmail@example.com"; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + primaryEmail: organizerDestinationCalendarEmailOnEventType, + }, + teams: [ + { + membership: { + accepted: true, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + smsLockState: SMSLockState.LOCKED, + }, + }, + ], + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + defaultScheduleId: null, + email: "other-team-member-1@example.com", + timeZone: Timezones["+0:00"], + id: 102, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + await createBookingScenario( + getScenarioData({ + workflows: [ + { + teamId: 1, + trigger: "NEW_EVENT", + action: "SMS_ATTENDEE", + template: "REMINDER", + activeEventTypeId: 1, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 15, + schedulingType: SchedulingType.COLLECTIVE, + length: 15, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + teamId: 1, + 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: "dailyvideo", + 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}T09:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T09:15:00.000Z`, + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + smsReminderNumber: "000", + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await handleNewBooking(req); + + expectSMSWorkflowToBeNotTriggered({ + sms, + toNumber: "000", + }); + }, + timeout + ); + }); +}); diff --git a/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts b/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts index 1f136929dd..bcd466e4da 100644 --- a/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts +++ b/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts @@ -2,6 +2,7 @@ import TwilioClient from "twilio"; import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError"; import logger from "@calcom/lib/logger"; +import { setTestSMS } from "@calcom/lib/testSMS"; import prisma from "@calcom/prisma"; import { SMSLockState } from "@calcom/prisma/enums"; @@ -31,7 +32,7 @@ function getDefaultSender(whatsapp = false) { if (whatsapp) { defaultSender = `whatsapp:+${process.env.TWILIO_WHATSAPP_PHONE_NUMBER}`; } - return defaultSender; + return defaultSender || ""; } function getSMSNumber(phone: string, whatsapp = false) { @@ -46,8 +47,6 @@ export const sendSMS = async ( teamId?: number | null, whatsapp = false ) => { - assertTwilio(twilio); - const isSMSSendingLocked = await isLockedForSMSSending(userId, teamId); if (isSMSSendingLocked) { @@ -55,6 +54,23 @@ export const sendSMS = async ( return; } + const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE; + + if (testMode) { + setTestSMS({ + to: getSMSNumber(phoneNumber, whatsapp), + from: whatsapp ? getDefaultSender(whatsapp) : sender ? sender : getDefaultSender(), + message: body, + }); + console.log( + "Skipped sending SMS because process.env.NEXT_PUBLIC_IS_E2E or process.env.INTEGRATION_TEST_MODE is set. SMS are available in globalThis.testSMS" + ); + + return; + } + + assertTwilio(twilio); + if (!teamId && userId) { await checkSMSRateLimit({ identifier: `sms:user:${userId}`, diff --git a/packages/lib/testSMS.ts b/packages/lib/testSMS.ts new file mode 100644 index 0000000000..60952a606d --- /dev/null +++ b/packages/lib/testSMS.ts @@ -0,0 +1,21 @@ +declare global { + // eslint-disable-next-line no-var + var testSMS: { + to: string; + from: string; + message: string; + }[]; +} + +export const setTestSMS = (sms: (typeof globalThis.testSMS)[number]) => { + globalThis.testSMS = globalThis.testSMS || []; + globalThis.testSMS.push(sms); +}; + +export const getTestSMS = () => { + return globalThis.testSMS; +}; + +export const resetTestSMS = () => { + globalThis.testSMS = []; +};