Files
calendar/packages/features/ee/workflows/lib/reminders/utils.test.ts
T
Amit SharmaGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Keith Williams
353f71bdc6 feat: Sink url shortner for sms workflow reminders (#26608)
* feat: Sink url shortner for sms workflow reminders

* fix: remove hardcoded dub values

* update .env.example

* fix: unit tests

* chore: add tests for scheduleSmsReminder and utils

* review refactor

* fix: type check

* review refactor

* fix: update test to account for smsReminderNumber fallback from main

Co-Authored-By: unknown <>

* feat: add feature flag for sink and more tests to verify

* fix: type check

* use proper feature flags for sink

* Apply suggestion from @keithwillcode

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2026-03-16 20:51:28 +00:00

534 lines
20 KiB
TypeScript

import dayjs from "@calcom/dayjs";
import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AttendeeInBookingInfo, BookingInfo } from "../types";
const mockShortenMany = vi.fn();
vi.mock("@calcom/features/url-shortener/UrlShortenerFactory", () => ({
UrlShortenerFactory: {
create: async () => ({
shortenMany: (...args: unknown[]) => mockShortenMany(...args),
}),
},
}));
vi.mock("@calcom/lib/constants", () => ({
DUB_SMS_DOMAIN: "sms.example.com",
DUB_SMS_FOLDER_ID: "folder-123",
WEBSITE_URL: "https://app.cal.com",
}));
const mockCustomTemplate = vi.fn().mockReturnValue({ text: "Final SMS text", html: "<p>Final SMS text</p>" });
const mockTransformResponses = vi.fn((responses) => responses);
vi.mock("./templates/customTemplate", () => ({
default: (...args: unknown[]) => mockCustomTemplate(...args),
transformBookingResponsesToVariableFormat: (...args: unknown[]) => mockTransformResponses(...args),
}));
const mockGetWorkflowRecipientEmail = vi.fn();
vi.mock("../../getWorkflowReminders", () => ({
getWorkflowRecipientEmail: (...args: unknown[]) => mockGetWorkflowRecipientEmail(...args),
}));
import { getAttendeeToBeUsedInSMS, getSMSMessageWithVariables, shouldUseTwilio } from "./utils";
function buildMockAttendee(overrides: Partial<AttendeeInBookingInfo> = {}): AttendeeInBookingInfo {
return {
name: "Test Attendee",
firstName: "Test",
lastName: "Attendee",
email: "attendee@example.com",
phoneNumber: "+15551234567",
timeZone: "America/New_York",
language: { locale: "en" },
...overrides,
};
}
function buildMockBookingInfo(overrides: Partial<BookingInfo> = {}): BookingInfo {
return {
uid: "booking-uid-123",
bookerUrl: "https://cal.example.com",
attendees: [buildMockAttendee()],
organizer: {
language: { locale: "en" },
name: "Test Organizer",
email: "organizer@example.com",
timeZone: "Europe/London",
timeFormat: "HH:mm" as BookingInfo["organizer"]["timeFormat"],
username: "organizer",
},
eventType: {
slug: "test-event",
},
startTime: "2025-06-15T10:00:00Z",
endTime: "2025-06-15T11:00:00Z",
title: "Test Event",
location: "https://meet.google.com/test",
additionalNotes: "Some notes",
responses: null,
metadata: { videoCallUrl: "https://meet.google.com/abc" },
cancellationReason: null,
rescheduleReason: null,
...overrides,
};
}
describe("utils", () => {
beforeEach(() => {
vi.clearAllMocks();
mockShortenMany.mockResolvedValue([
{ shortLink: "https://short.link/meet" },
{ shortLink: "https://short.link/cancel" },
{ shortLink: "https://short.link/reschedule" },
]);
mockCustomTemplate.mockReturnValue({ text: "Final SMS text", html: "<p>Final SMS text</p>" });
mockGetWorkflowRecipientEmail.mockReturnValue("attendee@example.com");
});
describe("getSMSMessageWithVariables", () => {
describe("URL construction", () => {
it("builds meetingUrl from metadata videoCallUrl", async () => {
const evt = buildMockBookingInfo({ metadata: { videoCallUrl: "https://meet.google.com/abc" } });
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
const urls = mockShortenMany.mock.calls[0][0];
expect(urls[0]).toBe("https://meet.google.com/abc");
});
it("uses empty string for meetingUrl when no videoCallUrl in metadata", async () => {
const evt = buildMockBookingInfo({ metadata: {} });
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
const urls = mockShortenMany.mock.calls[0][0];
expect(urls[0]).toBe("");
});
it("builds cancelLink with cancelledBy param for attendee actions", async () => {
mockGetWorkflowRecipientEmail.mockReturnValue("attendee@example.com");
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
const urls = mockShortenMany.mock.calls[0][0];
expect(urls[1]).toBe(
"https://cal.example.com/booking/booking-uid-123?cancel=true&cancelledBy=attendee@example.com"
);
});
it("builds cancelLink without cancelledBy when recipientEmail is null", async () => {
mockGetWorkflowRecipientEmail.mockReturnValue(null);
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_NUMBER);
const urls = mockShortenMany.mock.calls[0][0];
expect(urls[1]).toBe("https://cal.example.com/booking/booking-uid-123?cancel=true");
});
it("builds rescheduleLink with rescheduledBy param", async () => {
mockGetWorkflowRecipientEmail.mockReturnValue("attendee@example.com");
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
const urls = mockShortenMany.mock.calls[0][0];
expect(urls[2]).toBe(
"https://cal.example.com/reschedule/booking-uid-123?rescheduledBy=attendee@example.com"
);
});
it("builds rescheduleLink without rescheduledBy when recipientEmail is null", async () => {
mockGetWorkflowRecipientEmail.mockReturnValue(null);
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_NUMBER);
const urls = mockShortenMany.mock.calls[0][0];
expect(urls[2]).toBe("https://cal.example.com/reschedule/booking-uid-123");
});
it("falls back to WEBSITE_URL when bookerUrl is nullish", async () => {
const evt = buildMockBookingInfo({ bookerUrl: undefined as unknown as string });
const attendee = buildMockAttendee();
mockGetWorkflowRecipientEmail.mockReturnValue(null);
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_NUMBER);
const urls = mockShortenMany.mock.calls[0][0];
expect(urls[1]).toContain("https://app.cal.com/booking/");
expect(urls[2]).toContain("https://app.cal.com/reschedule/");
});
});
describe("URL shortening", () => {
it("calls shortenMany with all three URLs", async () => {
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
expect(mockShortenMany).toHaveBeenCalledTimes(1);
expect(mockShortenMany.mock.calls[0][0]).toHaveLength(3);
});
it("always passes domain and folderId options", async () => {
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
expect(mockShortenMany.mock.calls[0][1]).toEqual({
domain: "sms.example.com",
folderId: "folder-123",
});
});
it("uses shortened URLs in the variables passed to customTemplate", async () => {
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
const variables = mockCustomTemplate.mock.calls[0][1];
expect(variables.meetingUrl).toBe("https://short.link/meet");
expect(variables.cancelLink).toBe("https://short.link/cancel");
expect(variables.rescheduleLink).toBe("https://short.link/reschedule");
});
});
describe("timezone selection", () => {
it("uses attendee timezone for SMS_ATTENDEE action", async () => {
const attendee = buildMockAttendee({ timeZone: "America/New_York" });
const evt = buildMockBookingInfo({
organizer: {
language: { locale: "en" },
name: "Org",
email: "org@test.com",
timeZone: "Europe/London",
},
});
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
const variables = mockCustomTemplate.mock.calls[0][1];
expect(variables.timeZone).toBe("America/New_York");
});
it("uses organizer timezone for SMS_NUMBER action", async () => {
const attendee = buildMockAttendee({ timeZone: "America/New_York" });
const evt = buildMockBookingInfo({
organizer: {
language: { locale: "en" },
name: "Org",
email: "org@test.com",
timeZone: "Europe/London",
},
});
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_NUMBER);
const variables = mockCustomTemplate.mock.calls[0][1];
expect(variables.timeZone).toBe("Europe/London");
});
});
describe("variable assembly", () => {
it("includes all expected variable fields", async () => {
const evt = buildMockBookingInfo({
cancellationReason: "Too busy",
rescheduleReason: "Conflict",
});
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
const variables = mockCustomTemplate.mock.calls[0][1];
expect(variables).toMatchObject({
eventName: "Test Event",
organizerName: "Test Organizer",
attendeeName: "Test Attendee",
attendeeFirstName: "Test",
attendeeLastName: "Attendee",
attendeeEmail: "attendee@example.com",
location: "https://meet.google.com/test",
additionalNotes: "Some notes",
meetingUrl: "https://short.link/meet",
cancelLink: "https://short.link/cancel",
rescheduleLink: "https://short.link/reschedule",
cancelReason: "Too busy",
rescheduleReason: "Conflict",
});
expect(variables.eventDate).toBeDefined();
expect(variables.eventEndTime).toBeDefined();
expect(variables.timeZone).toBeDefined();
expect(variables.attendeeTimezone).toBeDefined();
expect(variables.eventTimeInAttendeeTimezone).toBeDefined();
expect(variables.eventEndTimeInAttendeeTimezone).toBeDefined();
});
it("attendeeTimezone always uses evt.attendees[0].timeZone", async () => {
const evt = buildMockBookingInfo({
attendees: [buildMockAttendee({ timeZone: "Asia/Tokyo" })],
});
const differentAttendee = buildMockAttendee({ timeZone: "US/Pacific" });
await getSMSMessageWithVariables("Hello", evt, differentAttendee, WorkflowActions.SMS_NUMBER);
const variables = mockCustomTemplate.mock.calls[0][1];
expect(variables.attendeeTimezone).toBe("Asia/Tokyo");
});
});
describe("locale and template rendering", () => {
it("uses attendee locale for SMS_ATTENDEE action", async () => {
const attendee = buildMockAttendee();
attendee.language = { locale: "fr" };
const evt = buildMockBookingInfo();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
expect(mockCustomTemplate.mock.calls[0][2]).toBe("fr");
});
it("uses organizer locale for non-attendee action", async () => {
const attendee = buildMockAttendee();
attendee.language = { locale: "fr" };
const evt = buildMockBookingInfo({
organizer: {
language: { locale: "de" },
name: "Org",
email: "org@test.com",
timeZone: "Europe/Berlin",
},
});
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_NUMBER);
expect(mockCustomTemplate.mock.calls[0][2]).toBe("de");
});
it("passes organizer timeFormat to customTemplate", async () => {
const evt = buildMockBookingInfo({
organizer: {
language: { locale: "en" },
name: "Org",
email: "org@test.com",
timeZone: "UTC",
timeFormat: "HH:mm" as BookingInfo["organizer"]["timeFormat"],
},
});
const attendee = buildMockAttendee();
await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_NUMBER);
expect(mockCustomTemplate.mock.calls[0][3]).toBe("HH:mm");
});
it("returns the text property from customTemplate result", async () => {
mockCustomTemplate.mockReturnValue({ text: "Your booking is confirmed", html: "" });
const evt = buildMockBookingInfo();
const attendee = buildMockAttendee();
const result = await getSMSMessageWithVariables("Hello", evt, attendee, WorkflowActions.SMS_ATTENDEE);
expect(result).toBe("Your booking is confirmed");
});
});
});
describe("getAttendeeToBeUsedInSMS", () => {
describe("SMS_ATTENDEE action", () => {
it("returns attendee matching responses email when reminderPhone is truthy", () => {
const matchingAttendee = buildMockAttendee({ email: "match@example.com", name: "Match" });
const firstAttendee = buildMockAttendee({ email: "first@example.com", name: "First" });
const evt = buildMockBookingInfo({
attendees: [firstAttendee, matchingAttendee],
responses: { email: { value: "match@example.com", label: "Email" } },
});
const result = getAttendeeToBeUsedInSMS(WorkflowActions.SMS_ATTENDEE, evt, "+15551234567");
expect(result.name).toBe("Match");
});
it("falls back to first attendee when no attendee matches email", () => {
const firstAttendee = buildMockAttendee({ email: "first@example.com", name: "First" });
const evt = buildMockBookingInfo({
attendees: [firstAttendee],
responses: { email: { value: "nomatch@example.com", label: "Email" } },
});
const result = getAttendeeToBeUsedInSMS(WorkflowActions.SMS_ATTENDEE, evt, "+15551234567");
expect(result.name).toBe("First");
});
it("falls back to first attendee when reminderPhone is null", () => {
const firstAttendee = buildMockAttendee({ email: "first@example.com", name: "First" });
const matchAttendee = buildMockAttendee({ email: "match@example.com", name: "Match" });
const evt = buildMockBookingInfo({
attendees: [firstAttendee, matchAttendee],
responses: { email: { value: "match@example.com", label: "Email" } },
});
const result = getAttendeeToBeUsedInSMS(WorkflowActions.SMS_ATTENDEE, evt, null);
expect(result.name).toBe("First");
});
it("falls back to first attendee when reminderPhone is empty string", () => {
const firstAttendee = buildMockAttendee({ email: "first@example.com", name: "First" });
const evt = buildMockBookingInfo({
attendees: [firstAttendee],
responses: { email: { value: "first@example.com", label: "Email" } },
});
const result = getAttendeeToBeUsedInSMS(WorkflowActions.SMS_ATTENDEE, evt, "");
expect(result.name).toBe("First");
});
it("falls back to first attendee when responses is null", () => {
const firstAttendee = buildMockAttendee({ name: "First" });
const evt = buildMockBookingInfo({
attendees: [firstAttendee],
responses: null,
});
const result = getAttendeeToBeUsedInSMS(WorkflowActions.SMS_ATTENDEE, evt, "+15551234567");
expect(result.name).toBe("First");
});
});
describe("non-attendee actions", () => {
it("returns first attendee for SMS_NUMBER action", () => {
const firstAttendee = buildMockAttendee({ name: "First" });
const secondAttendee = buildMockAttendee({ name: "Second" });
const evt = buildMockBookingInfo({ attendees: [firstAttendee, secondAttendee] });
const result = getAttendeeToBeUsedInSMS(WorkflowActions.SMS_NUMBER, evt, "+15551234567");
expect(result.name).toBe("First");
});
it("returns first attendee for EMAIL_HOST action", () => {
const firstAttendee = buildMockAttendee({ name: "First" });
const evt = buildMockBookingInfo({ attendees: [firstAttendee] });
const result = getAttendeeToBeUsedInSMS(WorkflowActions.EMAIL_HOST, evt, null);
expect(result.name).toBe("First");
});
it("returns first attendee for WHATSAPP_NUMBER action", () => {
const firstAttendee = buildMockAttendee({ name: "First" });
const evt = buildMockBookingInfo({ attendees: [firstAttendee] });
const result = getAttendeeToBeUsedInSMS(WorkflowActions.WHATSAPP_NUMBER, evt, "+15551234567");
expect(result.name).toBe("First");
});
});
});
describe("shouldUseTwilio", () => {
describe("immediate trigger events", () => {
const immediateEvents = [
WorkflowTriggerEvents.NEW_EVENT,
WorkflowTriggerEvents.EVENT_CANCELLED,
WorkflowTriggerEvents.RESCHEDULE_EVENT,
WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED,
WorkflowTriggerEvents.BOOKING_PAID,
WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED,
WorkflowTriggerEvents.BOOKING_REJECTED,
WorkflowTriggerEvents.BOOKING_REQUESTED,
WorkflowTriggerEvents.FORM_SUBMITTED,
WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT,
];
immediateEvents.forEach((trigger) => {
it(`returns true for ${trigger}`, () => {
expect(shouldUseTwilio(trigger, null)).toBe(true);
});
});
it("returns true for immediate triggers regardless of scheduledDate", () => {
expect(shouldUseTwilio(WorkflowTriggerEvents.NEW_EVENT, dayjs().add(5, "hour"))).toBe(true);
});
});
describe("BEFORE_EVENT / AFTER_EVENT", () => {
it("returns true when scheduledDate is 30min from now", () => {
const scheduledDate = dayjs().add(30, "minute");
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, scheduledDate)).toBe(true);
});
it("returns true when scheduledDate is 1hr from now", () => {
const scheduledDate = dayjs().add(1, "hour");
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, scheduledDate)).toBe(true);
});
it("returns false when scheduledDate is null", () => {
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, null)).toBe(false);
});
it("returns false when scheduledDate is 10min from now (too close)", () => {
const scheduledDate = dayjs().add(10, "minute");
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, scheduledDate)).toBe(false);
});
it("returns false when scheduledDate is 3hr from now (beyond 2hr window)", () => {
const scheduledDate = dayjs().add(3, "hour");
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, scheduledDate)).toBe(false);
});
it("returns false at exactly 15min boundary", () => {
const scheduledDate = dayjs().add(15, "minute");
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, scheduledDate)).toBe(false);
});
it("returns true at 16min from now", () => {
const scheduledDate = dayjs().add(16, "minute");
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, scheduledDate)).toBe(true);
});
it("returns false at 2hr+1min from now", () => {
const scheduledDate = dayjs().add(2, "hour").add(1, "minute");
expect(shouldUseTwilio(WorkflowTriggerEvents.BEFORE_EVENT, scheduledDate)).toBe(false);
});
it("works the same for AFTER_EVENT trigger", () => {
const withinWindow = dayjs().add(30, "minute");
const outsideWindow = dayjs().add(3, "hour");
expect(shouldUseTwilio(WorkflowTriggerEvents.AFTER_EVENT, withinWindow)).toBe(true);
expect(shouldUseTwilio(WorkflowTriggerEvents.AFTER_EVENT, outsideWindow)).toBe(false);
});
});
describe("other triggers", () => {
it("returns false for AFTER_HOSTS_CAL_VIDEO_NO_SHOW", () => {
expect(
shouldUseTwilio(WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, dayjs().add(30, "minute"))
).toBe(false);
});
it("returns false for AFTER_GUESTS_CAL_VIDEO_NO_SHOW", () => {
expect(
shouldUseTwilio(WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, dayjs().add(30, "minute"))
).toBe(false);
});
});
});
});