* 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>
927 lines
30 KiB
TypeScript
927 lines
30 KiB
TypeScript
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
|
|
import { WorkflowActions, WorkflowMethods, WorkflowTemplates } from "@calcom/prisma/enums";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mockShortenMany = vi.fn();
|
|
vi.mock("@calcom/features/url-shortener/UrlShortenerFactory", () => ({
|
|
UrlShortenerFactory: {
|
|
create: async () => ({
|
|
shortenMany: (...args: unknown[]) => mockShortenMany(...args),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
const mockScheduleSmsOrFallbackEmail = vi.fn();
|
|
vi.mock("@calcom/features/ee/workflows/lib/reminders/messageDispatcher", () => ({
|
|
scheduleSmsOrFallbackEmail: (...args: unknown[]) => mockScheduleSmsOrFallbackEmail(...args),
|
|
}));
|
|
|
|
const mockHasAvailableCredits = vi.fn().mockResolvedValue(true);
|
|
vi.mock("@calcom/features/ee/billing/credit-service", () => {
|
|
return {
|
|
CreditService: class {
|
|
hasAvailableCredits = mockHasAvailableCredits;
|
|
},
|
|
};
|
|
});
|
|
|
|
const mockGetByReferenceUid = vi.fn();
|
|
vi.mock("@calcom/features/bookings/repositories/BookingSeatRepository", () => {
|
|
return {
|
|
BookingSeatRepository: class {
|
|
getByReferenceUidWithAttendeeDetails = (...args: unknown[]) => mockGetByReferenceUid(...args);
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@calcom/features/ee/organizations/lib/getBookerUrlServer", () => ({
|
|
getBookerBaseUrl: vi.fn().mockResolvedValue("https://app.cal.com"),
|
|
}));
|
|
|
|
vi.mock("@calcom/features/bookings/lib/getCalEventResponses", () => ({
|
|
getCalEventResponses: vi.fn().mockReturnValue({ responses: {} }),
|
|
}));
|
|
|
|
vi.mock("@calcom/i18n/server", () => ({
|
|
getTranslation: vi.fn().mockResolvedValue(((key: string) => key) as unknown),
|
|
}));
|
|
|
|
vi.mock("@calcom/lib/constants", () => ({
|
|
DUB_SMS_DOMAIN: "sms.example.com",
|
|
DUB_SMS_FOLDER_ID: "folder-123",
|
|
}));
|
|
|
|
vi.mock("../lib/alphanumericSenderIdSupport", () => ({
|
|
getSenderId: vi.fn().mockReturnValue("CalCom"),
|
|
}));
|
|
|
|
vi.mock("../lib/getWorkflowReminders", () => ({
|
|
select: { id: true },
|
|
getWorkflowRecipientEmail: vi.fn().mockReturnValue(null),
|
|
}));
|
|
|
|
const mockCustomTemplate = vi.fn().mockReturnValue({ text: "Custom SMS message", html: "" });
|
|
vi.mock("../lib/reminders/templates/customTemplate", () => ({
|
|
default: (...args: unknown[]) => mockCustomTemplate(...args),
|
|
}));
|
|
|
|
const mockSmsReminderTemplate = vi.fn().mockReturnValue("Reminder: Your event is coming up");
|
|
vi.mock("../lib/reminders/templates/smsReminderTemplate", () => ({
|
|
default: (...args: unknown[]) => mockSmsReminderTemplate(...args),
|
|
}));
|
|
|
|
const mockAddOptOutMessage = vi.fn().mockResolvedValue("message with opt-out footer");
|
|
vi.mock("../lib/service/workflowOptOutService", () => ({
|
|
WorkflowOptOutService: {
|
|
addOptOutMessage: (...args: unknown[]) => mockAddOptOutMessage(...args),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@calcom/features/ee/workflows/lib/actionHelperFunctions", () => ({
|
|
isAttendeeAction: vi.fn(
|
|
(action: string) =>
|
|
action === "SMS_ATTENDEE" || action === "EMAIL_ATTENDEE" || action === "WHATSAPP_ATTENDEE"
|
|
),
|
|
}));
|
|
|
|
vi.mock("@calcom/prisma/zod-utils", () => ({
|
|
bookingMetadataSchema: {
|
|
parse: vi.fn((metadata: Record<string, unknown>) => metadata || {}),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@calcom/lib/timeFormat", () => ({
|
|
getTimeFormatStringFromUserTimeFormat: vi.fn().mockReturnValue("h:mma"),
|
|
}));
|
|
|
|
import { handler } from "./scheduleSMSReminders";
|
|
|
|
const MOCK_CRON_API_KEY = "test-cron-api-key-123";
|
|
|
|
function createMockRequest({
|
|
apiKeyParam = MOCK_CRON_API_KEY,
|
|
authorizationHeader,
|
|
skipAuth = false,
|
|
}: {
|
|
apiKeyParam?: string;
|
|
authorizationHeader?: string;
|
|
skipAuth?: boolean;
|
|
} = {}) {
|
|
const url = new URL("https://app.cal.com/api/workflows/scheduleSMSReminders");
|
|
if (!skipAuth && !authorizationHeader && apiKeyParam) {
|
|
url.searchParams.set("apiKey", apiKeyParam);
|
|
}
|
|
const headers = new Headers();
|
|
if (authorizationHeader) {
|
|
headers.set("authorization", authorizationHeader);
|
|
}
|
|
return {
|
|
headers: {
|
|
get: (name: string) => headers.get(name),
|
|
},
|
|
nextUrl: url,
|
|
};
|
|
}
|
|
|
|
function buildMockReminder(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 1,
|
|
scheduledDate: new Date("2025-06-15T10:00:00Z"),
|
|
isMandatoryReminder: false,
|
|
uuid: "uuid-123",
|
|
seatReferenceId: null,
|
|
retryCount: 0,
|
|
workflowStep: {
|
|
action: WorkflowActions.SMS_ATTENDEE,
|
|
sendTo: "+15559876543",
|
|
reminderBody: "Hello {ATTENDEE_NAME}, your event {EVENT_NAME} is coming up!",
|
|
emailSubject: null,
|
|
template: WorkflowTemplates.CUSTOM,
|
|
sender: "CalCom",
|
|
includeCalendarEvent: false,
|
|
id: 10,
|
|
workflow: {
|
|
userId: 1,
|
|
teamId: null,
|
|
},
|
|
},
|
|
booking: {
|
|
startTime: new Date("2025-06-15T10:00:00Z"),
|
|
endTime: new Date("2025-06-15T11:00:00Z"),
|
|
location: "https://meet.google.com/test",
|
|
description: "Test meeting",
|
|
smsReminderNumber: "+15551234567",
|
|
userPrimaryEmail: "organizer@example.com",
|
|
metadata: {},
|
|
uid: "booking-uid-123",
|
|
customInputs: {},
|
|
responses: {},
|
|
title: "Test Event",
|
|
attendees: [
|
|
{
|
|
name: "Test Attendee",
|
|
email: "attendee@example.com",
|
|
phoneNumber: "+15551234567",
|
|
timeZone: "America/New_York",
|
|
locale: "en",
|
|
},
|
|
],
|
|
user: {
|
|
id: 1,
|
|
email: "organizer@example.com",
|
|
name: "Test Organizer",
|
|
timeZone: "Europe/London",
|
|
locale: "en",
|
|
username: "organizer",
|
|
timeFormat: 12,
|
|
hideBranding: false,
|
|
},
|
|
eventType: {
|
|
bookingFields: null,
|
|
title: "Test Event",
|
|
slug: "test-event",
|
|
hosts: [],
|
|
recurringEvent: null,
|
|
team: { parentId: null, hideBranding: false },
|
|
customReplyToEmail: null,
|
|
},
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("scheduleSMSReminders handler", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.unstubAllEnvs();
|
|
vi.stubEnv("CRON_API_KEY", MOCK_CRON_API_KEY);
|
|
mockShortenMany.mockResolvedValue([
|
|
{ shortLink: "https://short.link/meet" },
|
|
{ shortLink: "https://short.link/cancel" },
|
|
{ shortLink: "https://short.link/reschedule" },
|
|
]);
|
|
mockCustomTemplate.mockReturnValue({ text: "Custom SMS message", html: "" });
|
|
mockSmsReminderTemplate.mockReturnValue("Reminder: Your event is coming up");
|
|
mockAddOptOutMessage.mockResolvedValue("message with opt-out footer");
|
|
mockScheduleSmsOrFallbackEmail.mockResolvedValue({ sid: "SM123456", emailReminderId: null });
|
|
prismaMock.profile.findFirst.mockResolvedValue(null);
|
|
});
|
|
|
|
describe("authentication", () => {
|
|
it("returns 401 when no apiKey is provided", async () => {
|
|
const req = createMockRequest({ skipAuth: true });
|
|
|
|
const response = await handler(req as any);
|
|
const body = await response.json();
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(body.message).toBe("Not authenticated");
|
|
});
|
|
|
|
it("returns 401 when apiKey does not match CRON_API_KEY", async () => {
|
|
const req = createMockRequest({ apiKeyParam: "wrong-key" });
|
|
|
|
const response = await handler(req as any);
|
|
|
|
expect(response.status).toBe(401);
|
|
});
|
|
|
|
it("authenticates via authorization header", async () => {
|
|
const req = createMockRequest({ skipAuth: true, authorizationHeader: MOCK_CRON_API_KEY });
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([]);
|
|
|
|
const response = await handler(req as any);
|
|
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it("authenticates via query parameter", async () => {
|
|
const req = createMockRequest({ apiKeyParam: MOCK_CRON_API_KEY });
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([]);
|
|
|
|
const response = await handler(req as any);
|
|
|
|
expect(response.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("no reminders found", () => {
|
|
it("returns ok when no unscheduled reminders exist", async () => {
|
|
const req = createMockRequest();
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([]);
|
|
|
|
const response = await handler(req as any);
|
|
const body = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body.ok).toBe(true);
|
|
});
|
|
|
|
it("queries with correct filters", async () => {
|
|
const req = createMockRequest();
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([]);
|
|
|
|
await handler(req as any);
|
|
|
|
const callArgs = prismaMock.workflowReminder.findMany.mock.calls[0][0];
|
|
expect(callArgs.where.method).toBe(WorkflowMethods.SMS);
|
|
expect(callArgs.where.scheduled).toBe(false);
|
|
expect(callArgs.where.scheduledDate).toEqual(
|
|
expect.objectContaining({
|
|
gte: expect.any(Date),
|
|
lte: expect.any(String),
|
|
})
|
|
);
|
|
expect(callArgs.where.retryCount).toEqual({ lt: 3 });
|
|
});
|
|
});
|
|
|
|
describe("skipping invalid reminders", () => {
|
|
it("skips when workflowStep is null", async () => {
|
|
const req = createMockRequest();
|
|
const invalidReminder = buildMockReminder({ workflowStep: null });
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([invalidReminder as any]);
|
|
|
|
const response = await handler(req as any);
|
|
|
|
expect(mockScheduleSmsOrFallbackEmail).not.toHaveBeenCalled();
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it("skips when booking is null", async () => {
|
|
const req = createMockRequest();
|
|
const invalidReminder = buildMockReminder({ booking: null });
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([invalidReminder as any]);
|
|
|
|
const response = await handler(req as any);
|
|
|
|
expect(mockScheduleSmsOrFallbackEmail).not.toHaveBeenCalled();
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it("continues processing valid reminders after skipping invalid ones", async () => {
|
|
const req = createMockRequest();
|
|
const invalidReminder = buildMockReminder({ id: 1, workflowStep: null });
|
|
const validReminder = buildMockReminder({ id: 2 });
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([invalidReminder as any, validReminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
expect(mockScheduleSmsOrFallbackEmail).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("seated events", () => {
|
|
it("looks up seat attendee when seatReferenceId is present", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder({ seatReferenceId: "seat-ref-123" });
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
mockGetByReferenceUid.mockResolvedValue({
|
|
attendee: {
|
|
name: "Seat Attendee",
|
|
email: "seat@example.com",
|
|
phoneNumber: "+15559999999",
|
|
timeZone: "US/Pacific",
|
|
locale: "en",
|
|
},
|
|
});
|
|
|
|
await handler(req as any);
|
|
|
|
expect(mockGetByReferenceUid).toHaveBeenCalledWith("seat-ref-123");
|
|
});
|
|
|
|
it("uses seat attendee data when found", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder({ seatReferenceId: "seat-ref-123" });
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
mockGetByReferenceUid.mockResolvedValue({
|
|
attendee: {
|
|
name: "Seat Attendee",
|
|
email: "seat@example.com",
|
|
phoneNumber: "+15559999999",
|
|
timeZone: "US/Pacific",
|
|
locale: "en",
|
|
},
|
|
});
|
|
|
|
await handler(req as any);
|
|
|
|
const callArgs = mockScheduleSmsOrFallbackEmail.mock.calls[0][0];
|
|
expect(callArgs.twilioData.phoneNumber).toBe("+15559999999");
|
|
});
|
|
|
|
it("falls back to booking attendee when seat lookup returns null", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder({ seatReferenceId: "seat-ref-123" });
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
mockGetByReferenceUid.mockResolvedValue(null);
|
|
|
|
await handler(req as any);
|
|
|
|
const callArgs = mockScheduleSmsOrFallbackEmail.mock.calls[0][0];
|
|
expect(callArgs.twilioData.phoneNumber).toBe("+15551234567");
|
|
});
|
|
|
|
it("does not query seat repository when seatReferenceId is null", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder({ seatReferenceId: null });
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
expect(mockGetByReferenceUid).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("action-based field selection", () => {
|
|
it("uses workflowStep.sendTo for SMS_NUMBER action", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder({
|
|
workflowStep: {
|
|
...buildMockReminder().workflowStep,
|
|
action: WorkflowActions.SMS_NUMBER,
|
|
sendTo: "+15559876543",
|
|
},
|
|
});
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
const callArgs = mockScheduleSmsOrFallbackEmail.mock.calls[0][0];
|
|
expect(callArgs.twilioData.phoneNumber).toBe("+15559876543");
|
|
});
|
|
|
|
it("uses targetAttendee.phoneNumber for SMS_ATTENDEE action", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder();
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
const callArgs = mockScheduleSmsOrFallbackEmail.mock.calls[0][0];
|
|
expect(callArgs.twilioData.phoneNumber).toBe("+15551234567");
|
|
});
|
|
});
|
|
|
|
describe("custom reminderBody processing", () => {
|
|
it("shortens meetingUrl, cancelLink, and rescheduleLink", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder();
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
const [urls] = mockShortenMany.mock.calls[0];
|
|
expect(urls).toHaveLength(3);
|
|
expect(urls[1]).toContain("/booking/booking-uid-123?cancel=true");
|
|
expect(urls[2]).toContain("/reschedule/booking-uid-123");
|
|
});
|
|
|
|
it("passes shortened URLs to customTemplate variables", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder();
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
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");
|
|
});
|
|
|
|
it("fetches organizer profile for booker URL", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder();
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
expect(prismaMock.profile.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { userId: 1 },
|
|
})
|
|
);
|
|
});
|
|
|
|
it("falls back to 'en' locale when computed locale is null", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder();
|
|
(reminder.booking as any).attendees[0].locale = null;
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
const templateLocale = mockCustomTemplate.mock.calls[0][2];
|
|
expect(templateLocale).toBe("en");
|
|
});
|
|
});
|
|
|
|
describe("REMINDER template processing", () => {
|
|
it("uses smsReminderTemplate when no reminderBody and template is REMINDER", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder({
|
|
workflowStep: {
|
|
...buildMockReminder().workflowStep,
|
|
reminderBody: null,
|
|
template: WorkflowTemplates.REMINDER,
|
|
},
|
|
});
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
expect(mockSmsReminderTemplate).toHaveBeenCalled();
|
|
expect(mockCustomTemplate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not send SMS when neither reminderBody nor REMINDER template", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder({
|
|
workflowStep: {
|
|
...buildMockReminder().workflowStep,
|
|
reminderBody: null,
|
|
template: WorkflowTemplates.CUSTOM,
|
|
},
|
|
});
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
expect(mockScheduleSmsOrFallbackEmail).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("SMS sending and DB updates", () => {
|
|
it("calls addOptOutMessage before sending", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder();
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
expect(mockAddOptOutMessage).toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls scheduleSmsOrFallbackEmail with correct twilioData", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder();
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
const callArgs = mockScheduleSmsOrFallbackEmail.mock.calls[0][0];
|
|
expect(callArgs.twilioData).toMatchObject({
|
|
phoneNumber: "+15551234567",
|
|
scheduledDate: reminder.scheduledDate,
|
|
sender: "CalCom",
|
|
bookingUid: "booking-uid-123",
|
|
userId: 1,
|
|
teamId: null,
|
|
});
|
|
expect(callArgs.twilioData.body).toBeDefined();
|
|
expect(callArgs.twilioData.bodyWithoutOptOut).toBeDefined();
|
|
});
|
|
|
|
it("includes fallbackData for attendee actions", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder();
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
const callArgs = mockScheduleSmsOrFallbackEmail.mock.calls[0][0];
|
|
expect(callArgs.fallbackData).toBeDefined();
|
|
expect(callArgs.fallbackData.email).toBe("attendee@example.com");
|
|
expect(callArgs.fallbackData.replyTo).toBe("organizer@example.com");
|
|
expect(callArgs.fallbackData.workflowStepId).toBe(10);
|
|
});
|
|
|
|
it("does not include fallbackData for non-attendee actions", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder({
|
|
workflowStep: {
|
|
...buildMockReminder().workflowStep,
|
|
action: WorkflowActions.SMS_NUMBER,
|
|
},
|
|
});
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
const callArgs = mockScheduleSmsOrFallbackEmail.mock.calls[0][0];
|
|
expect(callArgs.fallbackData).toBeUndefined();
|
|
});
|
|
|
|
it("updates reminder to scheduled=true with SID on success", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder();
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
mockScheduleSmsOrFallbackEmail.mockResolvedValue({ sid: "SM123456", emailReminderId: null });
|
|
|
|
await handler(req as any);
|
|
|
|
expect(prismaMock.workflowReminder.update).toHaveBeenCalledWith({
|
|
where: { id: 1 },
|
|
data: { scheduled: true, referenceId: "SM123456" },
|
|
});
|
|
});
|
|
|
|
it("deletes SMS reminder when email fallback was used", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder();
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
mockScheduleSmsOrFallbackEmail.mockResolvedValue({ sid: null, emailReminderId: 456 });
|
|
|
|
await handler(req as any);
|
|
|
|
expect(prismaMock.workflowReminder.delete).toHaveBeenCalledWith({
|
|
where: { id: 1 },
|
|
});
|
|
});
|
|
|
|
it("increments retryCount when scheduleSmsOrFallbackEmail returns null", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder({ retryCount: 1 });
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
mockScheduleSmsOrFallbackEmail.mockResolvedValue(null);
|
|
|
|
await handler(req as any);
|
|
|
|
expect(prismaMock.workflowReminder.update).toHaveBeenCalledWith({
|
|
where: { id: 1 },
|
|
data: { retryCount: 2 },
|
|
});
|
|
});
|
|
|
|
it("does not send when sendTo is undefined", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder();
|
|
(reminder.booking as any).attendees[0].phoneNumber = undefined;
|
|
(reminder.booking as any).smsReminderNumber = null;
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
expect(mockScheduleSmsOrFallbackEmail).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("error handling", () => {
|
|
it("increments retryCount on caught exception", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder({ retryCount: 0 });
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
mockScheduleSmsOrFallbackEmail.mockRejectedValue(new Error("Twilio error"));
|
|
|
|
await handler(req as any);
|
|
|
|
expect(prismaMock.workflowReminder.update).toHaveBeenCalledWith({
|
|
where: { id: 1 },
|
|
data: { retryCount: 1 },
|
|
});
|
|
});
|
|
|
|
it("continues processing after one reminder fails", async () => {
|
|
const req = createMockRequest();
|
|
const failingReminder = buildMockReminder({ id: 1, retryCount: 0 });
|
|
const successReminder = buildMockReminder({ id: 2, retryCount: 0 });
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([
|
|
failingReminder as any,
|
|
successReminder as any,
|
|
]);
|
|
mockScheduleSmsOrFallbackEmail
|
|
.mockRejectedValueOnce(new Error("Twilio error"))
|
|
.mockResolvedValueOnce({ sid: "SM789", emailReminderId: null });
|
|
|
|
const response = await handler(req as any);
|
|
|
|
expect(prismaMock.workflowReminder.update).toHaveBeenCalledWith({
|
|
where: { id: 1 },
|
|
data: { retryCount: 1 },
|
|
});
|
|
expect(prismaMock.workflowReminder.update).toHaveBeenCalledWith({
|
|
where: { id: 2 },
|
|
data: { scheduled: true, referenceId: "SM789" },
|
|
});
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it("returns 200 with message after processing all reminders", async () => {
|
|
const req = createMockRequest();
|
|
const reminder = buildMockReminder();
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder as any]);
|
|
|
|
const response = await handler(req as any);
|
|
const body = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body.message).toBe("SMS scheduled");
|
|
});
|
|
});
|
|
|
|
describe("multiple reminders", () => {
|
|
it("processes each reminder independently", async () => {
|
|
const req = createMockRequest();
|
|
const reminder1 = buildMockReminder({ id: 1 });
|
|
const reminder2 = buildMockReminder({
|
|
id: 2,
|
|
workflowStep: {
|
|
...buildMockReminder().workflowStep,
|
|
action: WorkflowActions.SMS_NUMBER,
|
|
sendTo: "+15559876543",
|
|
reminderBody: null,
|
|
template: WorkflowTemplates.REMINDER,
|
|
},
|
|
});
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder1 as any, reminder2 as any]);
|
|
|
|
await handler(req as any);
|
|
|
|
expect(mockScheduleSmsOrFallbackEmail).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
});
|
|
|
|
function createMockNextRequest(): {
|
|
headers: { get: (key: string) => string | null };
|
|
nextUrl: { searchParams: { get: (key: string) => string | null } };
|
|
} {
|
|
return {
|
|
headers: {
|
|
get: (key: string) => (key === "authorization" ? "test-api-key" : null),
|
|
},
|
|
nextUrl: {
|
|
searchParams: {
|
|
get: () => null,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createMockReminderForFallback(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
const now = new Date();
|
|
const scheduledDate = new Date(now.getTime() + 60 * 60 * 1000);
|
|
|
|
return {
|
|
id: 1,
|
|
scheduledDate,
|
|
isMandatoryReminder: false,
|
|
uuid: "test-uuid",
|
|
seatReferenceId: null,
|
|
retryCount: 0,
|
|
workflowStep: {
|
|
id: 100,
|
|
action: WorkflowActions.SMS_ATTENDEE,
|
|
sendTo: null,
|
|
reminderBody: null,
|
|
emailSubject: null,
|
|
template: WorkflowTemplates.REMINDER,
|
|
sender: null,
|
|
includeCalendarEvent: false,
|
|
workflow: {
|
|
userId: 1,
|
|
teamId: null,
|
|
},
|
|
},
|
|
booking: {
|
|
startTime: new Date(now.getTime() + 2 * 60 * 60 * 1000),
|
|
endTime: new Date(now.getTime() + 3 * 60 * 60 * 1000),
|
|
location: "https://meet.google.com/test",
|
|
description: "Test booking",
|
|
metadata: {},
|
|
customInputs: {},
|
|
responses: {},
|
|
uid: "booking-uid-123",
|
|
userPrimaryEmail: "organizer@example.com",
|
|
smsReminderNumber: null,
|
|
title: "Test Meeting",
|
|
attendees: [
|
|
{
|
|
name: "Attendee One",
|
|
email: "attendee@example.com",
|
|
phoneNumber: "+1111111111",
|
|
timeZone: "America/New_York",
|
|
locale: "en",
|
|
},
|
|
],
|
|
eventType: {
|
|
bookingFields: null,
|
|
title: "Test Event",
|
|
slug: "test-event",
|
|
hosts: [],
|
|
recurringEvent: null,
|
|
team: { parentId: null, hideBranding: false },
|
|
customReplyToEmail: null,
|
|
},
|
|
user: {
|
|
id: 1,
|
|
email: "organizer@example.com",
|
|
name: "Organizer",
|
|
timeZone: "America/New_York",
|
|
locale: "en",
|
|
username: "organizer",
|
|
timeFormat: 12,
|
|
hideBranding: false,
|
|
},
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("scheduleSMSReminders handler - smsReminderNumber fallback", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.unstubAllEnvs();
|
|
vi.stubEnv("CRON_API_KEY", "test-api-key");
|
|
mockScheduleSmsOrFallbackEmail.mockResolvedValue({ sid: "SM123", emailReminderId: null });
|
|
mockSmsReminderTemplate.mockReturnValue("Test SMS reminder");
|
|
mockAddOptOutMessage.mockResolvedValue("message without opt out");
|
|
prismaMock.workflowReminder.update.mockResolvedValue({} as never);
|
|
prismaMock.profile.findFirst.mockResolvedValue(null);
|
|
});
|
|
|
|
it("should use attendee phone when booking has no smsReminderNumber", async () => {
|
|
const reminder = createMockReminderForFallback();
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder] as never);
|
|
|
|
const req = createMockNextRequest();
|
|
await handler(req as never);
|
|
|
|
expect(mockScheduleSmsOrFallbackEmail).toHaveBeenCalledTimes(1);
|
|
expect(mockScheduleSmsOrFallbackEmail).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
twilioData: expect.objectContaining({
|
|
phoneNumber: "+1111111111",
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should use smsReminderNumber when booking has it set", async () => {
|
|
const base = createMockReminderForFallback();
|
|
const reminder = createMockReminderForFallback({
|
|
booking: {
|
|
...(base.booking as Record<string, unknown>),
|
|
smsReminderNumber: "+2222222222",
|
|
},
|
|
});
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder] as never);
|
|
|
|
const req = createMockNextRequest();
|
|
await handler(req as never);
|
|
|
|
expect(mockScheduleSmsOrFallbackEmail).toHaveBeenCalledTimes(1);
|
|
expect(mockScheduleSmsOrFallbackEmail).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
twilioData: expect.objectContaining({
|
|
phoneNumber: "+2222222222",
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should prefer smsReminderNumber over attendee phone for non-seated events", async () => {
|
|
const baseReminder = createMockReminderForFallback();
|
|
const booking = baseReminder.booking as Record<string, unknown>;
|
|
const reminder = createMockReminderForFallback({
|
|
booking: {
|
|
...booking,
|
|
smsReminderNumber: "+3333333333",
|
|
attendees: [
|
|
{
|
|
name: "Attendee",
|
|
email: "attendee@example.com",
|
|
phoneNumber: "+1111111111",
|
|
timeZone: "America/New_York",
|
|
locale: "en",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder] as never);
|
|
|
|
const req = createMockNextRequest();
|
|
await handler(req as never);
|
|
|
|
expect(mockScheduleSmsOrFallbackEmail).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
twilioData: expect.objectContaining({
|
|
phoneNumber: "+3333333333",
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should use workflowStep.sendTo when action is SMS_NUMBER", async () => {
|
|
const base = createMockReminderForFallback();
|
|
const reminder = createMockReminderForFallback({
|
|
workflowStep: {
|
|
...(base.workflowStep as Record<string, unknown>),
|
|
action: WorkflowActions.SMS_NUMBER,
|
|
sendTo: "+4444444444",
|
|
},
|
|
booking: {
|
|
...(base.booking as Record<string, unknown>),
|
|
smsReminderNumber: "+2222222222",
|
|
},
|
|
});
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder] as never);
|
|
|
|
const req = createMockNextRequest();
|
|
await handler(req as never);
|
|
|
|
expect(mockScheduleSmsOrFallbackEmail).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
twilioData: expect.objectContaining({
|
|
phoneNumber: "+4444444444",
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should use smsReminderNumber when attendee has no phone number", async () => {
|
|
const baseReminder = createMockReminderForFallback();
|
|
const booking = baseReminder.booking as Record<string, unknown>;
|
|
const reminder = createMockReminderForFallback({
|
|
booking: {
|
|
...booking,
|
|
smsReminderNumber: "+5555555555",
|
|
attendees: [
|
|
{
|
|
name: "Attendee",
|
|
email: "attendee@example.com",
|
|
phoneNumber: null,
|
|
timeZone: "America/New_York",
|
|
locale: "en",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([reminder] as never);
|
|
|
|
const req = createMockNextRequest();
|
|
await handler(req as never);
|
|
|
|
expect(mockScheduleSmsOrFallbackEmail).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
twilioData: expect.objectContaining({
|
|
phoneNumber: "+5555555555",
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should return early with ok:true when no unscheduled reminders exist", async () => {
|
|
prismaMock.workflowReminder.findMany.mockResolvedValue([] as never);
|
|
|
|
const req = createMockNextRequest();
|
|
const response = await handler(req as never);
|
|
const body = await response.json();
|
|
|
|
expect(body).toEqual({ ok: true });
|
|
expect(mockScheduleSmsOrFallbackEmail).not.toHaveBeenCalled();
|
|
});
|
|
});
|