* feat: remove attendee endpoint * fix: remove attendee email from error logs to avoid logging PII Co-Authored-By: unknown <> * fix: add isBookingAuditEnabled to removeAttendee handler Align removeAttendee.handler.ts with the new onAttendeeRemoved interface that requires isBookingAuditEnabled, following the same pattern used in addGuests.handler.ts. Co-Authored-By: bot_apk <apk@cognition.ai> * style: apply biome formatting to conflict-resolved files Co-Authored-By: bot_apk <apk@cognition.ai> * chore: implement PR feedback * fixup * revert: biome formatting changes * chore: implement feedback part 1 * chore: implement feedback part 2 * fix: await cancellation email flow to prevent uncaught promise rejections The fire-and-forget .then() chain on prepareAttendeePerson() left rejections from that promise uncaught. Await both prepareAttendeePerson() and sendCancelledEmailToAttendee() so errors are properly handled. sendCancelledEmailToAttendee() already has an internal try/catch, so awaiting it will not cause the overall removeAttendee flow to fail on email errors. Addresses Cubic AI review (confidence 9/10). Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore: implement feedback part 3 * chore: implement devin feedback --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: bot_apk <apk@cognition.ai>
375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
|
import { PrismaBookingAttendeeRepository } from "@calcom/features/bookings/repositories/PrismaBookingAttendeeRepository";
|
|
import { prisma } from "@calcom/prisma";
|
|
import { BookingStatus, CreationSource } from "@calcom/prisma/enums";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mockUpdateCalendarAttendees = vi.fn().mockResolvedValue({});
|
|
vi.mock("@calcom/features/bookings/lib/EventManager", () => ({
|
|
default: class MockEventManager {
|
|
updateCalendarAttendees = mockUpdateCalendarAttendees;
|
|
},
|
|
}));
|
|
|
|
const mockSendEmail = vi.fn().mockResolvedValue(undefined);
|
|
vi.mock("@calcom/emails/templates/attendee-cancelled-email", () => ({
|
|
default: class MockAttendeeCancelledEmail {
|
|
constructor() {}
|
|
sendEmail = mockSendEmail;
|
|
},
|
|
}));
|
|
|
|
const mockOnAttendeeRemoved = vi.fn().mockResolvedValue(undefined);
|
|
|
|
const mockBookingEventHandlerService = {
|
|
onAttendeeRemoved: mockOnAttendeeRemoved,
|
|
};
|
|
|
|
const mockFeaturesRepository = {
|
|
checkIfTeamHasFeature: vi.fn().mockResolvedValue(false),
|
|
};
|
|
|
|
vi.mock("@calcom/trpc/server/routers/viewer/bookings/addGuests.handler", async (importOriginal) => {
|
|
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
return {
|
|
...actual,
|
|
validateUserPermissions: vi.fn().mockResolvedValue(undefined),
|
|
updateCalendarEvent: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
});
|
|
|
|
import type { BookingAttendeesRemoveServiceDeps } from "./BookingAttendeesRemoveService";
|
|
import { BookingAttendeesRemoveService } from "./BookingAttendeesRemoveService";
|
|
|
|
const createdBookingIds: number[] = [];
|
|
const createdUserIds: number[] = [];
|
|
const createdEventTypeIds: number[] = [];
|
|
|
|
describe("BookingAttendeesRemoveService.removeAttendee (Integration Tests)", () => {
|
|
const timestamp = Date.now();
|
|
const bookingRepo = new BookingRepository(prisma);
|
|
const service = new BookingAttendeesRemoveService({
|
|
bookingEventHandlerService: mockBookingEventHandlerService,
|
|
featuresRepository: mockFeaturesRepository,
|
|
bookingAttendeeRepository: new PrismaBookingAttendeeRepository(prisma),
|
|
} as unknown as BookingAttendeesRemoveServiceDeps);
|
|
|
|
let organizerId: number;
|
|
let bookingId: number;
|
|
let primaryAttendeeId: number;
|
|
let secondaryAttendeeId: number;
|
|
|
|
const testUser = {
|
|
id: 0,
|
|
email: "",
|
|
organizationId: null as number | null,
|
|
uuid: `test-uuid-${timestamp}`,
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
const organizer = await prisma.user.create({
|
|
data: {
|
|
email: `attendee-svc-organizer-${timestamp}@test.com`,
|
|
username: `attendee-svc-organizer-${timestamp}`,
|
|
name: "Test Organizer",
|
|
timeZone: "UTC",
|
|
locale: "en",
|
|
creationSource: CreationSource.WEBAPP,
|
|
schedules: {
|
|
create: {
|
|
name: "Default Schedule",
|
|
timeZone: "UTC",
|
|
availability: {
|
|
create: [
|
|
{
|
|
days: [0, 1, 2, 3, 4, 5, 6],
|
|
startTime: new Date("1970-01-01T09:00:00.000Z"),
|
|
endTime: new Date("1970-01-01T17:00:00.000Z"),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
select: { id: true, email: true },
|
|
});
|
|
organizerId = organizer.id;
|
|
createdUserIds.push(organizer.id);
|
|
|
|
testUser.id = organizer.id;
|
|
testUser.email = organizer.email;
|
|
|
|
const eventType = await prisma.eventType.create({
|
|
data: {
|
|
title: `attendee-svc-event-${timestamp}`,
|
|
slug: `attendee-svc-event-${timestamp}`,
|
|
length: 30,
|
|
userId: organizerId,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
createdEventTypeIds.push(eventType.id);
|
|
|
|
const booking = await bookingRepo.createBookingForManagedEventReassignment({
|
|
uid: `attendee-svc-booking-${timestamp}`,
|
|
userId: organizerId,
|
|
userPrimaryEmail: organizer.email,
|
|
eventTypeId: eventType.id,
|
|
title: `Attendee Service Test Booking ${timestamp}`,
|
|
description: null,
|
|
startTime: new Date("2030-06-01T10:00:00.000Z"),
|
|
endTime: new Date("2030-06-01T10:30:00.000Z"),
|
|
status: BookingStatus.ACCEPTED,
|
|
location: null,
|
|
smsReminderNumber: null,
|
|
idempotencyKey: `attendee-svc-idempotency-${timestamp}`,
|
|
iCalUID: `attendee-svc-ical-${timestamp}@test.com`,
|
|
iCalSequence: 0,
|
|
attendees: [
|
|
{ email: "primary@test.com", name: "Primary Attendee", timeZone: "UTC", locale: "en" },
|
|
{
|
|
email: "secondary@test.com",
|
|
name: "Secondary Attendee",
|
|
timeZone: "America/New_York",
|
|
locale: "en",
|
|
},
|
|
],
|
|
});
|
|
bookingId = booking.id;
|
|
createdBookingIds.push(booking.id);
|
|
|
|
const attendees = await prisma.attendee.findMany({
|
|
where: { bookingId: booking.id },
|
|
orderBy: { id: "asc" },
|
|
select: { id: true, email: true },
|
|
});
|
|
primaryAttendeeId = attendees[0].id;
|
|
secondaryAttendeeId = attendees[1].id;
|
|
|
|
await prisma.booking.update({
|
|
where: { id: booking.id },
|
|
data: {
|
|
responses: {
|
|
name: "Primary Attendee",
|
|
email: "primary@test.com",
|
|
guests: ["secondary@test.com"],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (createdBookingIds.length > 0) {
|
|
await prisma.attendee.deleteMany({ where: { bookingId: { in: createdBookingIds } } });
|
|
await prisma.booking.deleteMany({ where: { id: { in: createdBookingIds } } });
|
|
}
|
|
if (createdEventTypeIds.length > 0) {
|
|
await prisma.eventType.deleteMany({ where: { id: { in: createdEventTypeIds } } });
|
|
}
|
|
if (createdUserIds.length > 0) {
|
|
await prisma.availability.deleteMany({ where: { Schedule: { userId: { in: createdUserIds } } } });
|
|
await prisma.schedule.deleteMany({ where: { userId: { in: createdUserIds } } });
|
|
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
|
|
}
|
|
});
|
|
|
|
it("should remove a secondary attendee, update DB, and emit audit event", async () => {
|
|
const result = await service.removeAttendee({
|
|
bookingId,
|
|
attendeeId: secondaryAttendeeId,
|
|
user: testUser,
|
|
emailsEnabled: true,
|
|
actionSource: "API_V2",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
id: secondaryAttendeeId,
|
|
bookingId,
|
|
name: "Secondary Attendee",
|
|
email: "secondary@test.com",
|
|
timeZone: "America/New_York",
|
|
});
|
|
|
|
const attendee = await prisma.attendee.findUnique({
|
|
where: { id: secondaryAttendeeId },
|
|
});
|
|
expect(attendee).toBeNull();
|
|
|
|
const updatedBooking = await prisma.booking.findUnique({
|
|
where: { id: bookingId },
|
|
select: { responses: true },
|
|
});
|
|
const responses = updatedBooking?.responses as { guests?: string[] } | null;
|
|
expect(responses?.guests ?? []).not.toContain("secondary@test.com");
|
|
|
|
expect(mockOnAttendeeRemoved).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
bookingUid: `attendee-svc-booking-${timestamp}`,
|
|
auditData: expect.objectContaining({
|
|
attendees: expect.objectContaining({
|
|
old: expect.arrayContaining(["primary@test.com", "secondary@test.com"]),
|
|
new: expect.arrayContaining(["primary@test.com"]),
|
|
}),
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
describe("primary attendee guard", () => {
|
|
it("should throw when trying to remove the primary attendee", async () => {
|
|
await expect(
|
|
service.removeAttendee({
|
|
bookingId,
|
|
attendeeId: primaryAttendeeId,
|
|
user: testUser,
|
|
emailsEnabled: true,
|
|
actionSource: "API_V2",
|
|
})
|
|
).rejects.toThrow("cannot_remove_primary_attendee");
|
|
});
|
|
});
|
|
|
|
describe("non-existent attendee", () => {
|
|
it("should throw when attendee does not exist", async () => {
|
|
await expect(
|
|
service.removeAttendee({
|
|
bookingId,
|
|
attendeeId: 999999,
|
|
user: testUser,
|
|
emailsEnabled: true,
|
|
actionSource: "API_V2",
|
|
})
|
|
).rejects.toThrow("attendee_not_found");
|
|
});
|
|
});
|
|
|
|
describe("emails disabled", () => {
|
|
let emailsDisabledBookingId: number;
|
|
let emailsDisabledAttendeeId: number;
|
|
|
|
beforeAll(async () => {
|
|
const booking = await bookingRepo.createBookingForManagedEventReassignment({
|
|
uid: `attendee-svc-no-email-${timestamp}`,
|
|
userId: organizerId,
|
|
userPrimaryEmail: testUser.email,
|
|
eventTypeId: createdEventTypeIds[0],
|
|
title: `No Email Test Booking ${timestamp}`,
|
|
description: null,
|
|
startTime: new Date("2030-06-02T10:00:00.000Z"),
|
|
endTime: new Date("2030-06-02T10:30:00.000Z"),
|
|
status: BookingStatus.ACCEPTED,
|
|
location: null,
|
|
smsReminderNumber: null,
|
|
idempotencyKey: `attendee-svc-no-email-idempotency-${timestamp}`,
|
|
iCalUID: `attendee-svc-no-email-ical-${timestamp}@test.com`,
|
|
iCalSequence: 0,
|
|
attendees: [
|
|
{ email: "primary-noemail@test.com", name: "Primary", timeZone: "UTC", locale: "en" },
|
|
{ email: "secondary-noemail@test.com", name: "Secondary", timeZone: "UTC", locale: "en" },
|
|
],
|
|
});
|
|
emailsDisabledBookingId = booking.id;
|
|
createdBookingIds.push(booking.id);
|
|
|
|
await prisma.booking.update({
|
|
where: { id: booking.id },
|
|
data: {
|
|
responses: {
|
|
name: "Primary",
|
|
email: "primary-noemail@test.com",
|
|
guests: ["secondary-noemail@test.com"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const attendees = await prisma.attendee.findMany({
|
|
where: { bookingId: booking.id },
|
|
orderBy: { id: "asc" },
|
|
select: { id: true },
|
|
});
|
|
emailsDisabledAttendeeId = attendees[1].id;
|
|
});
|
|
|
|
it("should not send cancellation email when emailsEnabled is false", async () => {
|
|
await service.removeAttendee({
|
|
bookingId: emailsDisabledBookingId,
|
|
attendeeId: emailsDisabledAttendeeId,
|
|
user: testUser,
|
|
emailsEnabled: false,
|
|
actionSource: "API_V2",
|
|
});
|
|
|
|
expect(mockSendEmail).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("responses.guests cleanup with plus-addressed email", () => {
|
|
let plusAddressBookingId: number;
|
|
let plusAddressAttendeeId: number;
|
|
|
|
beforeAll(async () => {
|
|
const booking = await bookingRepo.createBookingForManagedEventReassignment({
|
|
uid: `attendee-svc-plus-addr-${timestamp}`,
|
|
userId: organizerId,
|
|
userPrimaryEmail: testUser.email,
|
|
eventTypeId: createdEventTypeIds[0],
|
|
title: `Plus Address Test Booking ${timestamp}`,
|
|
description: null,
|
|
startTime: new Date("2030-06-03T10:00:00.000Z"),
|
|
endTime: new Date("2030-06-03T10:30:00.000Z"),
|
|
status: BookingStatus.ACCEPTED,
|
|
location: null,
|
|
smsReminderNumber: null,
|
|
idempotencyKey: `attendee-svc-plus-addr-idempotency-${timestamp}`,
|
|
iCalUID: `attendee-svc-plus-addr-ical-${timestamp}@test.com`,
|
|
iCalSequence: 0,
|
|
attendees: [
|
|
{ email: "primary-plus@test.com", name: "Primary", timeZone: "UTC", locale: "en" },
|
|
{ email: "guest+abc123@test.com", name: "Plus Guest", timeZone: "UTC", locale: "en" },
|
|
],
|
|
});
|
|
plusAddressBookingId = booking.id;
|
|
createdBookingIds.push(booking.id);
|
|
|
|
await prisma.booking.update({
|
|
where: { id: booking.id },
|
|
data: {
|
|
responses: {
|
|
name: "Primary",
|
|
email: "primary-plus@test.com",
|
|
guests: ["guest+abc123@test.com"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const attendees = await prisma.attendee.findMany({
|
|
where: { bookingId: booking.id },
|
|
orderBy: { id: "asc" },
|
|
select: { id: true },
|
|
});
|
|
plusAddressAttendeeId = attendees[1].id;
|
|
});
|
|
|
|
it("should remove plus-addressed email from responses.guests using extractBaseEmail", async () => {
|
|
await service.removeAttendee({
|
|
bookingId: plusAddressBookingId,
|
|
attendeeId: plusAddressAttendeeId,
|
|
user: testUser,
|
|
emailsEnabled: false,
|
|
actionSource: "API_V2",
|
|
});
|
|
|
|
const updatedBooking = await prisma.booking.findUnique({
|
|
where: { id: plusAddressBookingId },
|
|
select: { responses: true },
|
|
});
|
|
const responses = updatedBooking?.responses as { guests?: string[] } | null;
|
|
expect(responses?.guests ?? []).toEqual([]);
|
|
});
|
|
});
|
|
});
|