* feat: add toggle to opt out of booking title translation in instant meetings (#25547) * feat: add toggle to opt out of booking title translation in instant meetings Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add autoTranslateTitleEnabled to test builder Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: respect autoTranslateTitleEnabled toggle in InstantBookingCreateService Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: fetch autoTranslateTitleEnabled directly in InstantBookingCreateService Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * refactor: rename autoTranslateTitleEnabled to autoTranslateInstantMeetingTitleEnabled - Renamed field to clarify it only applies to instant meeting title translation - Moved Prisma query to EventTypeRepository.findInstantMeetingConfigById() - Removed title translation logic from update.handler.ts (flag only controls instant meeting title) - Updated all references across the codebase - Added new i18n translation keys for the renamed field Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add autoTranslateInstantMeetingTitleEnabled to test destructuring patterns Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: use Prisma.TeamCreateInput type for metadata in test helper Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add autoTranslateInstantMeetingTitleEnabled to mockUpdatedEventType in test Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * refactor: use generic findByIdMinimal instead of business-specific method - Add autoTranslateInstantMeetingTitleEnabled to eventTypeSelect constant - Remove findInstantMeetingConfigById from EventTypeRepository - Update InstantBookingCreateService to use findByIdMinimal Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * refactor: remove autoTranslateInstantMeetingTitleEnabled from eventTypeSelect (not needed for findByIdMinimal) Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat: add autoTranslateInstantMeetingTitleEnabled to event type form defaults - Rename shouldTranslateTitle to shouldAutoTranslateInstantMeetingTitle for clarity - Add autoTranslateInstantMeetingTitleEnabled to findById and findByIdForOrgAdmin selects - Add autoTranslateInstantMeetingTitleEnabled to useEventTypeForm defaultValues Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: only update autoTranslateInstantMeetingTitleEnabled when explicitly provided Fixes issue where omitting the field in update requests would overwrite the saved opt-out setting with the default value (true). Now the field is only included in the update payload when explicitly provided. Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore: Create standard for rate limits --------- Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
262 lines
7.7 KiB
TypeScript
262 lines
7.7 KiB
TypeScript
import type { TFunction } from "i18next";
|
|
import { describe, expect, test, vi, beforeEach } from "vitest";
|
|
|
|
import { sendSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/reminders/messageDispatcher";
|
|
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
|
|
import prisma from "@calcom/prisma";
|
|
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
|
|
|
import SMSManager from "../sms-manager";
|
|
|
|
vi.mock("@calcom/lib/smsLockState");
|
|
vi.mock("@calcom/features/ee/workflows/lib/reminders/messageDispatcher");
|
|
vi.mock("@calcom/prisma", () => {
|
|
const mockObj = {
|
|
team: {
|
|
findUnique: vi.fn(),
|
|
},
|
|
};
|
|
return {
|
|
default: mockObj,
|
|
prisma: mockObj,
|
|
};
|
|
});
|
|
|
|
interface TestAttendee extends Person {
|
|
name: string;
|
|
email: string;
|
|
phoneNumber?: string;
|
|
timeZone: string;
|
|
language: {
|
|
translate: TFunction;
|
|
locale: string;
|
|
};
|
|
}
|
|
|
|
interface TestCalEvent extends CalendarEvent {
|
|
uid: string;
|
|
type: string;
|
|
title: string;
|
|
startTime: string;
|
|
endTime: string;
|
|
attendees: TestAttendee[];
|
|
organizer: {
|
|
id: number;
|
|
name: string;
|
|
email: string;
|
|
timeZone: string;
|
|
language: {
|
|
translate: TFunction;
|
|
locale: string;
|
|
};
|
|
};
|
|
team?: {
|
|
id: number;
|
|
name: string;
|
|
members: unknown[];
|
|
};
|
|
}
|
|
|
|
// Create a concrete implementation of SMSManager for testing
|
|
class TestSMSManager extends SMSManager {
|
|
getMessage(attendee: TestAttendee): string {
|
|
return `Test message for ${attendee.name}`;
|
|
}
|
|
}
|
|
|
|
describe("SMSManager", () => {
|
|
const mockTranslate = ((key: string) => key) as TFunction;
|
|
const mockCalEvent: TestCalEvent = {
|
|
uid: "test-booking-uid",
|
|
type: "test",
|
|
title: "Test Event",
|
|
startTime: "2024-03-20T10:00:00Z",
|
|
endTime: "2024-03-20T11:00:00Z",
|
|
attendees: [
|
|
{
|
|
name: "John Doe",
|
|
email: "john@sms.cal.com",
|
|
phoneNumber: "+1234567890",
|
|
timeZone: "America/New_York",
|
|
language: { translate: mockTranslate, locale: "en" },
|
|
},
|
|
{
|
|
name: "Jane Smith",
|
|
email: "jane@example.com",
|
|
phoneNumber: "+1987654321",
|
|
timeZone: "America/New_York",
|
|
language: { translate: mockTranslate, locale: "en" },
|
|
},
|
|
],
|
|
organizer: {
|
|
id: 1,
|
|
name: "Organizer",
|
|
email: "organizer@example.com",
|
|
timeZone: "America/New_York",
|
|
language: { translate: mockTranslate, locale: "en" },
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("sendSMSToAttendee", () => {
|
|
test("should not send SMS if phone number is missing", async () => {
|
|
const smsManager = new TestSMSManager(mockCalEvent);
|
|
const attendeeWithoutPhone: TestAttendee = {
|
|
...mockCalEvent.attendees[0],
|
|
phoneNumber: undefined,
|
|
timeZone: "America/New_York",
|
|
language: { translate: mockTranslate, locale: "en" },
|
|
};
|
|
|
|
await smsManager.sendSMSToAttendee(attendeeWithoutPhone);
|
|
|
|
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("should not send SMS if email is not @sms.cal.com", async () => {
|
|
const smsManager = new TestSMSManager(mockCalEvent);
|
|
const attendeeWithRegularEmail: TestAttendee = {
|
|
...mockCalEvent.attendees[0],
|
|
email: "john@example.com",
|
|
phoneNumber: "+1234567890",
|
|
timeZone: "America/New_York",
|
|
language: { translate: mockTranslate, locale: "en" },
|
|
};
|
|
|
|
await smsManager.sendSMSToAttendee(attendeeWithRegularEmail);
|
|
|
|
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("should send SMS only when both phone number and @sms.cal.com email are present", async () => {
|
|
const smsManager = new TestSMSManager(mockCalEvent);
|
|
const mockSmsResponse = { success: true };
|
|
|
|
(sendSmsOrFallbackEmail as jest.Mock).mockResolvedValue(mockSmsResponse);
|
|
(checkSMSRateLimit as jest.Mock).mockResolvedValue(undefined);
|
|
|
|
const result = await smsManager.sendSMSToAttendee(mockCalEvent.attendees[0], "test-booking-uid");
|
|
|
|
expect(checkSMSRateLimit).toHaveBeenCalledWith({
|
|
identifier: "handleSendingSMS:org-user-1",
|
|
rateLimitingType: "sms",
|
|
});
|
|
|
|
expect(sendSmsOrFallbackEmail).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
twilioData: expect.objectContaining({
|
|
phoneNumber: mockCalEvent.attendees[0].phoneNumber,
|
|
body: expect.stringContaining(mockCalEvent.attendees[0].name),
|
|
sender: expect.any(String),
|
|
userId: 1,
|
|
bookingUid: "test-booking-uid",
|
|
}),
|
|
creditCheckFn: expect.any(Function),
|
|
})
|
|
);
|
|
|
|
expect(result).toEqual(mockSmsResponse);
|
|
});
|
|
|
|
test("should not send SMS if SMS notifications are disabled for team", async () => {
|
|
const mockTeamId = 123;
|
|
const mockTeam = {
|
|
id: mockTeamId,
|
|
name: "Test Team",
|
|
members: [],
|
|
};
|
|
const smsManager = new TestSMSManager({
|
|
...mockCalEvent,
|
|
team: mockTeam,
|
|
});
|
|
|
|
// Mock team settings to disable SMS
|
|
(prisma.team.findUnique as jest.Mock).mockResolvedValue({
|
|
parent: {
|
|
isOrganization: true,
|
|
organizationSettings: {
|
|
disablePhoneOnlySMSNotifications: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
await smsManager.sendSMSToAttendee(mockCalEvent.attendees[0]);
|
|
|
|
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("should handle SMS sending errors", async () => {
|
|
const smsManager = new TestSMSManager(mockCalEvent);
|
|
const mockError = new Error("SMS sending failed");
|
|
|
|
(sendSmsOrFallbackEmail as jest.Mock).mockRejectedValue(mockError);
|
|
(checkSMSRateLimit as jest.Mock).mockResolvedValue(undefined);
|
|
|
|
await expect(smsManager.sendSMSToAttendee(mockCalEvent.attendees[0])).rejects.toThrow(mockError);
|
|
});
|
|
});
|
|
|
|
describe("sendSMSToAttendees", () => {
|
|
test("should send SMS only to attendees with phone number and @sms.cal.com email", async () => {
|
|
const smsManager = new TestSMSManager(mockCalEvent);
|
|
const mockSmsResponse = { success: true };
|
|
|
|
(sendSmsOrFallbackEmail as jest.Mock).mockResolvedValue(mockSmsResponse);
|
|
(checkSMSRateLimit as jest.Mock).mockResolvedValue(undefined);
|
|
|
|
await smsManager.sendSMSToAttendees();
|
|
|
|
// Only one attendee has both phone number and @sms.cal.com email
|
|
expect(sendSmsOrFallbackEmail).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("should not send SMS if notifications are disabled", async () => {
|
|
const mockTeamId = 123;
|
|
const mockTeam = {
|
|
id: mockTeamId,
|
|
name: "Test Team",
|
|
members: [],
|
|
};
|
|
const smsManager = new TestSMSManager({
|
|
...mockCalEvent,
|
|
team: mockTeam,
|
|
});
|
|
|
|
(prisma.team.findUnique as jest.Mock).mockResolvedValue({
|
|
parent: {
|
|
isOrganization: true,
|
|
organizationSettings: {
|
|
disablePhoneOnlySMSNotifications: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
await smsManager.sendSMSToAttendees();
|
|
|
|
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("getFormattedTime and getFormattedDate", () => {
|
|
test("should format time correctly", () => {
|
|
const smsManager = new TestSMSManager(mockCalEvent);
|
|
const formattedTime = smsManager.getFormattedTime("America/New_York", "en", "2024-03-20T10:00:00Z");
|
|
|
|
expect(formattedTime).toContain("2024");
|
|
expect(formattedTime).toContain("6:00am");
|
|
});
|
|
|
|
test("should format date range correctly", () => {
|
|
const smsManager = new TestSMSManager(mockCalEvent);
|
|
const formattedDate = smsManager.getFormattedDate("America/New_York", "en");
|
|
|
|
expect(formattedDate).toContain("2024");
|
|
expect(formattedDate).toContain("6:00am");
|
|
expect(formattedDate).toContain("7:00am");
|
|
});
|
|
});
|
|
});
|