Files
calendar/packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts
T
Anik Dhabal BabuGitHubJoe Au-YeungDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Joe Au-Yeungcubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
d00cf329e8 fix: workflow reminder send to all RR hosts. (#25597)
* fix: wrkflow reminder issue

* Update EmailWorkflowService.ts

* update

* Refactor team member email extraction for clarity

* add tests

* Undo changes in EmailWorkflowService

* Get team members assigned to the booking

* fix: Update test to match EmailWorkflowService behavior for ROUND_ROBIN

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: Update CalendarEventBuilder tests to include host emails in attendees

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Apply suggestion from @cubic-dev-ai[bot]

Filter organizer our of `hostsToInclude`

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Only include organizer and host destination calendar

* Type fix

* Type fix

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-12-04 09:21:48 +02:00

385 lines
13 KiB
TypeScript

import { describe, expect, vi, beforeEach, test } from "vitest";
import type { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository";
import type { WorkflowReminderRepository } from "@calcom/features/ee/workflows/repositories/WorkflowReminderRepository";
import {
SchedulingType,
TimeUnit,
WorkflowActions,
WorkflowTemplates,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { EmailWorkflowService } from "./EmailWorkflowService";
vi.mock("@calcom/emails/workflow-email-service", () => ({
sendCustomWorkflowEmail: vi.fn(),
}));
vi.mock("@calcom/features/profile/lib/hideBranding", () => ({
getHideBranding: vi.fn().mockResolvedValue(false),
}));
vi.mock("@calcom/lib/server/i18n", () => ({
getTranslation: vi.fn().mockResolvedValue((key: string) => key),
}));
vi.mock("@calcom/prisma", () => ({
default: {},
prisma: {},
}));
const mockWorkflowReminderRepository: Pick<WorkflowReminderRepository, "findByIdIncludeStepAndWorkflow"> = {
findByIdIncludeStepAndWorkflow: vi.fn(),
};
const mockBookingSeatRepository: Pick<BookingSeatRepository, "getByUidIncludeAttendee"> = {
getByUidIncludeAttendee: vi.fn(),
};
describe("EmailWorkflowService", () => {
let emailWorkflowService: EmailWorkflowService;
beforeEach(() => {
vi.clearAllMocks();
emailWorkflowService = new EmailWorkflowService(
mockWorkflowReminderRepository as WorkflowReminderRepository,
mockBookingSeatRepository as BookingSeatRepository
);
});
describe("handleSendEmailWorkflowTask", () => {
const mockEvt: Partial<CalendarEvent> = {
uid: "booking-123",
bookerUrl: "https://cal.com",
title: "Test Meeting",
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
organizer: {
name: "Organizer Name",
email: "organizer@example.com",
timeZone: "UTC",
language: { locale: "en", translate: (key: string) => key },
timeFormat: 12,
},
attendees: [
{
name: "Attendee Name",
email: "attendee@example.com",
timeZone: "UTC",
language: { locale: "en", translate: (key: string) => key },
},
],
};
test("should throw error if workflow reminder not found", async () => {
vi.mocked(mockWorkflowReminderRepository.findByIdIncludeStepAndWorkflow).mockResolvedValue(null);
await expect(
emailWorkflowService.handleSendEmailWorkflowTask({
evt: mockEvt as CalendarEvent,
workflowReminderId: 1,
})
).rejects.toThrow("Workflow reminder not found with id 1");
});
test("should throw error if workflow step not verified", async () => {
vi.mocked(mockWorkflowReminderRepository.findByIdIncludeStepAndWorkflow).mockResolvedValue({
id: 1,
workflowStep: {
id: 1,
verifiedAt: null,
action: WorkflowActions.EMAIL_ATTENDEE,
workflow: { userId: 1, teamId: null },
},
});
await expect(
emailWorkflowService.handleSendEmailWorkflowTask({
evt: mockEvt as CalendarEvent,
workflowReminderId: 1,
})
).rejects.toThrow("Workflow step id 1 is not verified");
});
test("should throw error if workflow step not found on reminder", async () => {
vi.mocked(mockWorkflowReminderRepository.findByIdIncludeStepAndWorkflow).mockResolvedValue({
id: 1,
workflowStep: null,
});
await expect(
emailWorkflowService.handleSendEmailWorkflowTask({
evt: mockEvt as CalendarEvent,
workflowReminderId: 1,
})
).rejects.toThrow("Workflow step not found on reminder with id 1");
});
test("should fetch seat attendee email for seated events", async () => {
const mockWorkflowReminder = {
id: 1,
seatReferenceId: "seat-123",
workflowStep: {
id: 1,
action: WorkflowActions.EMAIL_ATTENDEE,
sendTo: null,
template: WorkflowTemplates.REMINDER,
reminderBody: null,
emailSubject: null,
sender: null,
includeCalendarEvent: false,
verifiedAt: new Date(),
workflow: {
userId: 1,
teamId: null,
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
time: 24,
timeUnit: "HOUR",
},
},
};
vi.mocked(mockWorkflowReminderRepository.findByIdIncludeStepAndWorkflow).mockResolvedValue(
mockWorkflowReminder
);
vi.mocked(mockBookingSeatRepository.getByUidIncludeAttendee).mockResolvedValue({
attendee: {
email: "seat-attendee@example.com",
},
});
try {
await emailWorkflowService.handleSendEmailWorkflowTask({
evt: mockEvt as CalendarEvent,
workflowReminderId: 1,
});
} catch {
// Expected to throw due to incomplete mock setup - we only care about the seat lookup
}
expect(mockBookingSeatRepository.getByUidIncludeAttendee).toHaveBeenCalledWith("seat-123");
});
});
describe("generateParametersToBuildEmailWorkflowContent - EMAIL_HOST", () => {
const mockCommonScheduleFunctionParams = {
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
timeSpan: {
time: 24,
timeUnit: TimeUnit.HOUR,
},
workflowStepId: 1,
template: WorkflowTemplates.REMINDER,
userId: 1,
teamId: null,
seatReferenceUid: undefined,
verifiedAt: new Date(),
creditCheckFn: vi.fn().mockResolvedValue(true),
};
const baseMockEvt: Partial<CalendarEvent> = {
uid: "booking-123",
bookerUrl: "https://cal.com",
title: "Test Meeting",
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
organizer: {
name: "Organizer Name",
email: "organizer@example.com",
timeZone: "UTC",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
language: { locale: "en", translate: (() => "") as any },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
timeFormat: "h:mma" as any,
},
attendees: [
{
name: "Attendee Name",
email: "attendee@example.com",
timeZone: "UTC",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
language: { locale: "en", translate: (() => "") as any },
},
],
};
const mockWorkflowStep = {
id: 1,
action: WorkflowActions.EMAIL_HOST,
verifiedAt: new Date(),
sendTo: null,
template: WorkflowTemplates.REMINDER,
reminderBody: null,
emailSubject: null,
sender: null,
includeCalendarEvent: false,
numberVerificationPending: false,
numberRequired: false,
};
test("should send to organizer and team members for ROUND_ROBIN scheduling type", async () => {
// Note: For ROUND_ROBIN, the CalendarEventBuilder filters team members to only include
// those assigned to the booking. EmailWorkflowService sends to all team members in evt.team.members.
const mockEvt: Partial<CalendarEvent> = {
...baseMockEvt,
schedulingType: SchedulingType.ROUND_ROBIN,
team: {
id: 1,
name: "Test Team",
members: [
{
id: 1,
name: "Team Member 1",
email: "team1@example.com",
timeZone: "UTC",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
language: { locale: "en", translate: (() => "") as any },
},
{
id: 2,
name: "Team Member 2",
email: "team2@example.com",
timeZone: "UTC",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
language: { locale: "en", translate: (() => "") as any },
},
],
},
};
const result = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({
evt: mockEvt as CalendarEvent,
workflowStep: mockWorkflowStep,
workflow: { userId: 1 },
commonScheduleFunctionParams: mockCommonScheduleFunctionParams,
hideBranding: false,
});
// EmailWorkflowService sends to organizer + all team members in evt.team.members
// The filtering of team members happens in CalendarEventBuilder, not here
expect(result.sendTo).toContain("organizer@example.com");
expect(result.sendTo).toContain("team1@example.com");
expect(result.sendTo).toContain("team2@example.com");
expect(result.sendTo.length).toBe(3);
});
test("should send to organizer and team members for COLLECTIVE scheduling type", async () => {
const mockEvt: Partial<CalendarEvent> = {
...baseMockEvt,
schedulingType: SchedulingType.COLLECTIVE,
team: {
id: 1,
name: "Test Team",
members: [
{
id: 1,
name: "Team Member 1",
email: "team1@example.com",
timeZone: "UTC",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
language: { locale: "en", translate: (() => "") as any },
},
{
id: 2,
name: "Team Member 2",
email: "team2@example.com",
timeZone: "UTC",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
language: { locale: "en", translate: (() => "") as any },
},
],
},
};
const result = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({
evt: mockEvt as CalendarEvent,
workflowStep: mockWorkflowStep,
workflow: { userId: 1 },
commonScheduleFunctionParams: mockCommonScheduleFunctionParams,
hideBranding: false,
});
expect(result.sendTo).toContain("organizer@example.com");
expect(result.sendTo).toContain("team1@example.com");
expect(result.sendTo).toContain("team2@example.com");
expect(result.sendTo.length).toBe(3);
});
test("should send to organizer only when team is undefined for COLLECTIVE", async () => {
const mockEvt: Partial<CalendarEvent> = {
...baseMockEvt,
schedulingType: SchedulingType.COLLECTIVE,
team: undefined,
} as Partial<CalendarEvent>;
const result = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({
evt: mockEvt as CalendarEvent,
workflowStep: mockWorkflowStep,
workflow: { userId: 1 },
commonScheduleFunctionParams: mockCommonScheduleFunctionParams,
hideBranding: false,
});
expect(result.sendTo).toEqual(["organizer@example.com"]);
expect(result.sendTo.length).toBe(1);
});
test("should send to organizer only when team members array is empty for COLLECTIVE", async () => {
const mockEvt: Partial<CalendarEvent> = {
...baseMockEvt,
schedulingType: SchedulingType.COLLECTIVE,
team: {
id: 1,
name: "Test Team",
members: [],
},
};
const result = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({
evt: mockEvt as CalendarEvent,
workflowStep: mockWorkflowStep,
workflow: { userId: 1 },
commonScheduleFunctionParams: mockCommonScheduleFunctionParams,
hideBranding: false,
});
expect(result.sendTo).toEqual(["organizer@example.com"]);
expect(result.sendTo.length).toBe(1);
});
test("should send to organizer only for other scheduling types (e.g., null)", async () => {
const mockEvt: Partial<CalendarEvent> = {
...baseMockEvt,
schedulingType: null,
team: {
id: 1,
name: "Test Team",
members: [
{
id: 1,
name: "Team Member 1",
email: "team1@example.com",
timeZone: "UTC",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
language: { locale: "en", translate: (() => "") as any },
},
],
},
} as Partial<CalendarEvent>;
const result = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({
evt: mockEvt as CalendarEvent,
workflowStep: mockWorkflowStep,
workflow: { userId: 1 },
commonScheduleFunctionParams: mockCommonScheduleFunctionParams,
hideBranding: false,
});
expect(result.sendTo).toEqual(["organizer@example.com"]);
expect(result.sendTo.length).toBe(1);
});
});
});