829 lines
27 KiB
TypeScript
829 lines
27 KiB
TypeScript
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import handleMarkNoShow, { handleMarkHostNoShow } from "./handleMarkNoShow";
|
|
|
|
const { mockPrismaBookingUpdate, mockPrismaAttendeeUpdate, mockPrismaAttendeeFindMany } = vi.hoisted(() => ({
|
|
mockPrismaBookingUpdate: vi.fn(),
|
|
mockPrismaAttendeeUpdate: vi.fn(),
|
|
mockPrismaAttendeeFindMany: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@calcom/prisma", () => ({
|
|
prisma: {
|
|
booking: { update: mockPrismaBookingUpdate },
|
|
attendee: {
|
|
findMany: mockPrismaAttendeeFindMany,
|
|
update: mockPrismaAttendeeUpdate,
|
|
},
|
|
},
|
|
}));
|
|
|
|
const {
|
|
mockFindByUidIncludeEventTypeAttendeesAndUser,
|
|
mockFindByUidIncludeEventTypeAndReferences,
|
|
mockUpdateNoShowHost,
|
|
MockBookingRepository,
|
|
} = vi.hoisted(() => {
|
|
const mockFindByUidIncludeEventTypeAttendeesAndUser = vi.fn();
|
|
const mockFindByUidIncludeEventTypeAndReferences = vi.fn();
|
|
const mockUpdateNoShowHost = vi.fn();
|
|
class MockBookingRepository {
|
|
findByUidIncludeEventTypeAttendeesAndUser = mockFindByUidIncludeEventTypeAttendeesAndUser;
|
|
findByUidIncludeEventTypeAndReferences = mockFindByUidIncludeEventTypeAndReferences;
|
|
updateNoShowHost = mockUpdateNoShowHost;
|
|
}
|
|
return {
|
|
mockFindByUidIncludeEventTypeAttendeesAndUser,
|
|
mockFindByUidIncludeEventTypeAndReferences,
|
|
mockUpdateNoShowHost,
|
|
MockBookingRepository,
|
|
};
|
|
});
|
|
|
|
vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => ({
|
|
BookingRepository: MockBookingRepository,
|
|
}));
|
|
|
|
const {
|
|
mockFindByBookingUidAndEmails,
|
|
mockFindIdAndEmailByBookingUidAndEmails,
|
|
mockUpdateNoShow,
|
|
MockAttendeeRepository,
|
|
} = vi.hoisted(() => {
|
|
const mockFindByBookingUidAndEmails = vi.fn();
|
|
const mockFindIdAndEmailByBookingUidAndEmails = vi.fn();
|
|
const mockUpdateNoShow = vi.fn();
|
|
class MockAttendeeRepository {
|
|
findByBookingUidAndEmails = mockFindByBookingUidAndEmails;
|
|
findIdAndEmailByBookingUidAndEmails = mockFindIdAndEmailByBookingUidAndEmails;
|
|
updateNoShow = mockUpdateNoShow;
|
|
}
|
|
return {
|
|
mockFindByBookingUidAndEmails,
|
|
mockFindIdAndEmailByBookingUidAndEmails,
|
|
mockUpdateNoShow,
|
|
MockAttendeeRepository,
|
|
};
|
|
});
|
|
|
|
vi.mock("@calcom/features/bookings/repositories/AttendeeRepository", () => ({
|
|
AttendeeRepository: MockAttendeeRepository,
|
|
}));
|
|
|
|
const { mockDoesUserIdHaveAccessToBooking, MockBookingAccessService } = vi.hoisted(() => {
|
|
const mockDoesUserIdHaveAccessToBooking = vi.fn();
|
|
class MockBookingAccessService {
|
|
doesUserIdHaveAccessToBooking = mockDoesUserIdHaveAccessToBooking;
|
|
}
|
|
return { mockDoesUserIdHaveAccessToBooking, MockBookingAccessService };
|
|
});
|
|
|
|
vi.mock("@calcom/features/bookings/services/BookingAccessService", () => ({
|
|
BookingAccessService: MockBookingAccessService,
|
|
}));
|
|
|
|
const { mockWebhookServiceInit, mockSendPayload } = vi.hoisted(() => ({
|
|
mockWebhookServiceInit: vi.fn(),
|
|
mockSendPayload: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@calcom/features/webhooks/lib/WebhookService", () => ({
|
|
WebhookService: {
|
|
init: mockWebhookServiceInit,
|
|
},
|
|
}));
|
|
|
|
const { mockOnNoShowUpdated, mockGetBookingEventHandlerService } = vi.hoisted(() => {
|
|
const mockOnNoShowUpdated = vi.fn();
|
|
const mockGetBookingEventHandlerService = vi.fn().mockReturnValue({
|
|
onNoShowUpdated: mockOnNoShowUpdated,
|
|
});
|
|
return { mockOnNoShowUpdated, mockGetBookingEventHandlerService };
|
|
});
|
|
|
|
vi.mock("@calcom/features/bookings/di/BookingEventHandlerService.container", () => ({
|
|
getBookingEventHandlerService: mockGetBookingEventHandlerService,
|
|
}));
|
|
|
|
vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType", () => ({
|
|
getAllWorkflowsFromEventType: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({
|
|
default: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
vi.mock("@calcom/i18n/server", () => ({
|
|
getTranslation: vi
|
|
.fn()
|
|
.mockResolvedValue((key: string, opts?: { x?: string }) => (opts?.x ? `${opts.x} ${key}` : key)),
|
|
}));
|
|
|
|
vi.mock("./noShow/handleSendingAttendeeNoShowDataToApps", () => ({
|
|
default: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const DB = {
|
|
bookings: {} as Record<string, DBBooking>,
|
|
attendees: {} as Record<string, DBAttendee[]>,
|
|
};
|
|
|
|
type DBBooking = {
|
|
uid: string;
|
|
id: number;
|
|
userId: number;
|
|
noShowHost: boolean | null;
|
|
startTime: Date;
|
|
endTime: Date;
|
|
title: string;
|
|
location: string | null;
|
|
userPrimaryEmail: string | null;
|
|
smsReminderNumber: string | null;
|
|
destinationCalendar: null;
|
|
metadata: null;
|
|
eventType: {
|
|
id: number;
|
|
teamId: number | null;
|
|
userId: number | null;
|
|
slug: string;
|
|
schedulingType: null;
|
|
hosts: never[];
|
|
owner: null;
|
|
hideOrganizerEmail: boolean;
|
|
customReplyToEmail: null;
|
|
team: null;
|
|
} | null;
|
|
user: {
|
|
id: number;
|
|
uuid: string;
|
|
email: string;
|
|
name: string;
|
|
locale: string;
|
|
timeZone: string;
|
|
timeFormat: number;
|
|
username: string;
|
|
destinationCalendar: null;
|
|
hideBranding: boolean;
|
|
} | null;
|
|
attendees: {
|
|
id: number;
|
|
email: string;
|
|
name: string;
|
|
noShow: boolean | null;
|
|
locale: string;
|
|
timeZone: string;
|
|
phoneNumber: string | null;
|
|
}[];
|
|
};
|
|
|
|
type DBAttendee = {
|
|
id: number;
|
|
email: string;
|
|
noShow: boolean | null;
|
|
};
|
|
|
|
const createMockBooking = (overrides: {
|
|
uid: string;
|
|
userId?: number;
|
|
noShowHost?: boolean | null;
|
|
startTime?: Date;
|
|
endTime?: Date;
|
|
hasEventType?: boolean;
|
|
}) => {
|
|
const booking: DBBooking = {
|
|
uid: overrides.uid,
|
|
id: 1,
|
|
userId: overrides.userId ?? 123,
|
|
noShowHost: overrides.noShowHost ?? null,
|
|
startTime: overrides.startTime ?? new Date(Date.now() - 3600000),
|
|
endTime: overrides.endTime ?? new Date(Date.now() + 3600000),
|
|
title: "Test Meeting",
|
|
location: null,
|
|
userPrimaryEmail: "host@example.com",
|
|
smsReminderNumber: null,
|
|
destinationCalendar: null,
|
|
metadata: null,
|
|
eventType:
|
|
overrides.hasEventType !== false
|
|
? {
|
|
id: 1,
|
|
teamId: null,
|
|
userId: overrides.userId ?? 123,
|
|
slug: "test-event",
|
|
schedulingType: null,
|
|
hosts: [],
|
|
owner: null,
|
|
hideOrganizerEmail: false,
|
|
customReplyToEmail: null,
|
|
team: null,
|
|
}
|
|
: null,
|
|
user: {
|
|
id: overrides.userId ?? 123,
|
|
uuid: "host-uuid-123",
|
|
email: "host@example.com",
|
|
name: "Test Host",
|
|
locale: "en",
|
|
timeZone: "UTC",
|
|
timeFormat: 12,
|
|
username: "testhost",
|
|
destinationCalendar: null,
|
|
hideBranding: false,
|
|
},
|
|
attendees: [],
|
|
};
|
|
DB.bookings[booking.uid] = booking;
|
|
DB.attendees[booking.uid] = [];
|
|
return booking;
|
|
};
|
|
|
|
const createMockAttendee = (overrides: {
|
|
bookingUid: string;
|
|
email: string;
|
|
noShow?: boolean | null;
|
|
name?: string;
|
|
}) => {
|
|
const attendee: DBAttendee = {
|
|
id: Date.now() + Math.random(),
|
|
email: overrides.email,
|
|
noShow: overrides.noShow ?? null,
|
|
};
|
|
DB.attendees[overrides.bookingUid] ??= [];
|
|
DB.attendees[overrides.bookingUid].push(attendee);
|
|
|
|
const booking = DB.bookings[overrides.bookingUid];
|
|
if (booking) {
|
|
booking.attendees.push({
|
|
id: attendee.id,
|
|
email: attendee.email,
|
|
name: overrides.name ?? "Test Attendee",
|
|
noShow: attendee.noShow,
|
|
locale: "en",
|
|
timeZone: "UTC",
|
|
phoneNumber: null,
|
|
});
|
|
}
|
|
return attendee;
|
|
};
|
|
|
|
const clearDB = () => {
|
|
DB.bookings = {};
|
|
DB.attendees = {};
|
|
};
|
|
|
|
const expectAttendeeNoShowState = (bookingUid: string, email: string, expectedNoShow: boolean) => {
|
|
const attendee = DB.attendees[bookingUid]?.find((a) => a.email === email);
|
|
expect(attendee).toBeDefined();
|
|
expect(attendee?.noShow).toBe(expectedNoShow);
|
|
};
|
|
|
|
const expectBookingNoShowHostState = (bookingUid: string, expectedNoShowHost: boolean) => {
|
|
const booking = DB.bookings[bookingUid];
|
|
expect(booking).toBeDefined();
|
|
expect(booking?.noShowHost).toBe(expectedNoShowHost);
|
|
};
|
|
|
|
type ExpectedAuditData = {
|
|
bookingUid: string;
|
|
source: string;
|
|
actor: { identifiedBy: string; userUuid?: string; email?: string; name?: null };
|
|
organizationId: number | null;
|
|
host?: { userUuid: string; noShow: { old: boolean | null; new: boolean } };
|
|
attendeesNoShow?: Array<{ attendeeEmail: string; noShow: { old: boolean | null; new: boolean } }>;
|
|
};
|
|
|
|
const expectNoShowBookingAudit = (expected: ExpectedAuditData) => {
|
|
expect(mockOnNoShowUpdated).toHaveBeenCalledTimes(1);
|
|
const call = mockOnNoShowUpdated.mock.calls[0][0];
|
|
|
|
expect(call.bookingUid).toBe(expected.bookingUid);
|
|
expect(call.source).toBe(expected.source);
|
|
expect(call.actor).toMatchObject(expected.actor);
|
|
expect(call.organizationId).toBe(expected.organizationId);
|
|
|
|
if (expected.host) {
|
|
expect(call.auditData.host).toEqual(expected.host);
|
|
}
|
|
|
|
if (expected.attendeesNoShow) {
|
|
expect(call.auditData.attendeesNoShow).toHaveLength(expected.attendeesNoShow.length);
|
|
|
|
for (const expectedAttendee of expected.attendeesNoShow) {
|
|
const actualAttendee = call.auditData.attendeesNoShow.find(
|
|
(a: { attendeeEmail: string }) => a.attendeeEmail === expectedAttendee.attendeeEmail
|
|
);
|
|
expect(actualAttendee).toBeDefined();
|
|
expect(actualAttendee.noShow).toEqual(expectedAttendee.noShow);
|
|
}
|
|
}
|
|
};
|
|
|
|
describe("handleMarkNoShow", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
clearDB();
|
|
|
|
mockFindByUidIncludeEventTypeAttendeesAndUser.mockImplementation(({ bookingUid }) => {
|
|
const booking = DB.bookings[bookingUid];
|
|
if (!booking) return Promise.resolve(null);
|
|
const attendees = DB.attendees[bookingUid] ?? [];
|
|
return Promise.resolve({
|
|
...booking,
|
|
attendees,
|
|
});
|
|
});
|
|
|
|
mockFindByUidIncludeEventTypeAndReferences.mockImplementation(({ bookingUid }) => {
|
|
const booking = DB.bookings[bookingUid];
|
|
if (!booking) throw new Error("Booking not found");
|
|
const attendees = DB.attendees[bookingUid] ?? [];
|
|
return Promise.resolve({
|
|
...booking,
|
|
attendees: attendees.map((a) => ({ ...a })),
|
|
});
|
|
});
|
|
|
|
mockPrismaAttendeeFindMany.mockImplementation(({ where }) => {
|
|
const bookingUid = where?.AND?.[0]?.booking?.uid ?? where?.booking?.uid;
|
|
const emails = where?.AND?.[0]?.email?.in ?? where?.email?.in ?? [];
|
|
const attendees = DB.attendees[bookingUid] ?? [];
|
|
const filteredAttendees = attendees.filter((a) => emails.includes(a.email)).map((a) => ({ ...a }));
|
|
return Promise.resolve(filteredAttendees);
|
|
});
|
|
|
|
mockPrismaAttendeeUpdate.mockImplementation(({ where, data }) => {
|
|
for (const bookingUid of Object.keys(DB.attendees)) {
|
|
const attendee = DB.attendees[bookingUid].find((a) => a.id === where.id);
|
|
if (attendee) {
|
|
attendee.noShow = data.noShow;
|
|
return Promise.resolve({ ...attendee, noShow: data.noShow });
|
|
}
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
|
|
mockPrismaBookingUpdate.mockImplementation(({ where, data }) => {
|
|
const booking = DB.bookings[where.uid];
|
|
if (booking && data.noShowHost !== undefined) {
|
|
booking.noShowHost = data.noShowHost;
|
|
}
|
|
return Promise.resolve({ uid: where.uid, noShowHost: data.noShowHost });
|
|
});
|
|
|
|
mockFindByBookingUidAndEmails.mockImplementation(
|
|
({ bookingUid, emails }: { bookingUid: string; emails: string[] }) => {
|
|
const attendees = DB.attendees[bookingUid] ?? [];
|
|
return Promise.resolve(attendees.filter((a) => emails.includes(a.email)).map((a) => ({ ...a })));
|
|
}
|
|
);
|
|
|
|
mockFindIdAndEmailByBookingUidAndEmails.mockImplementation(
|
|
({ bookingUid, emails }: { bookingUid: string; emails: string[] }) => {
|
|
const attendees = DB.attendees[bookingUid] ?? [];
|
|
return Promise.resolve(
|
|
attendees.filter((a) => emails.includes(a.email)).map((a) => ({ id: a.id, email: a.email }))
|
|
);
|
|
}
|
|
);
|
|
|
|
mockUpdateNoShow.mockImplementation(
|
|
({
|
|
where: { attendeeId },
|
|
data: { noShow },
|
|
}: {
|
|
where: { attendeeId: number };
|
|
data: { noShow: boolean };
|
|
}) => {
|
|
for (const bookingUid of Object.keys(DB.attendees)) {
|
|
const attendee = DB.attendees[bookingUid].find((a) => a.id === attendeeId);
|
|
if (attendee) {
|
|
attendee.noShow = noShow;
|
|
return Promise.resolve({ noShow, email: attendee.email });
|
|
}
|
|
}
|
|
return Promise.resolve(null);
|
|
}
|
|
);
|
|
|
|
mockUpdateNoShowHost.mockImplementation(
|
|
({ bookingUid, noShowHost }: { bookingUid: string; noShowHost: boolean }) => {
|
|
const booking = DB.bookings[bookingUid];
|
|
if (booking) {
|
|
booking.noShowHost = noShowHost;
|
|
}
|
|
return Promise.resolve({ id: booking?.id ?? 1 });
|
|
}
|
|
);
|
|
|
|
mockDoesUserIdHaveAccessToBooking.mockResolvedValue(true);
|
|
|
|
mockWebhookServiceInit.mockResolvedValue({ sendPayload: mockSendPayload });
|
|
mockSendPayload.mockResolvedValue(undefined);
|
|
|
|
mockOnNoShowUpdated.mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe("Core Functionality", () => {
|
|
it("should mark single attendee as no-show", async () => {
|
|
const bookingUid = "test-booking-1";
|
|
createMockBooking({ uid: bookingUid });
|
|
createMockAttendee({ bookingUid, email: "attendee@example.com", noShow: false });
|
|
|
|
const result = await handleMarkNoShow({
|
|
bookingUid,
|
|
attendees: [{ email: "attendee@example.com", noShow: true }],
|
|
userId: 123,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
expect(result.attendees).toHaveLength(1);
|
|
expect(result.attendees[0]).toEqual({ email: "attendee@example.com", noShow: true });
|
|
expectAttendeeNoShowState(bookingUid, "attendee@example.com", true);
|
|
});
|
|
|
|
it("should mark multiple attendees as no-show", async () => {
|
|
const bookingUid = "test-booking-2";
|
|
createMockBooking({ uid: bookingUid });
|
|
createMockAttendee({ bookingUid, email: "attendee1@example.com", noShow: false });
|
|
createMockAttendee({ bookingUid, email: "attendee2@example.com", noShow: false });
|
|
|
|
const result = await handleMarkNoShow({
|
|
bookingUid,
|
|
attendees: [
|
|
{ email: "attendee1@example.com", noShow: true },
|
|
{ email: "attendee2@example.com", noShow: true },
|
|
],
|
|
userId: 123,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
expect(result.attendees).toHaveLength(2);
|
|
expectAttendeeNoShowState(bookingUid, "attendee1@example.com", true);
|
|
expectAttendeeNoShowState(bookingUid, "attendee2@example.com", true);
|
|
});
|
|
|
|
it("should unmark attendee from no-show", async () => {
|
|
const bookingUid = "test-booking-3";
|
|
createMockBooking({ uid: bookingUid });
|
|
createMockAttendee({ bookingUid, email: "attendee@example.com", noShow: true });
|
|
|
|
const result = await handleMarkNoShow({
|
|
bookingUid,
|
|
attendees: [{ email: "attendee@example.com", noShow: false }],
|
|
userId: 123,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
expect(result.attendees[0]).toEqual({ email: "attendee@example.com", noShow: false });
|
|
});
|
|
|
|
it("should mark host as no-show", async () => {
|
|
const bookingUid = "test-booking-4";
|
|
createMockBooking({ uid: bookingUid });
|
|
|
|
const result = await handleMarkNoShow({
|
|
bookingUid,
|
|
noShowHost: true,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
expect(result.noShowHost).toBe(true);
|
|
expectBookingNoShowHostState(bookingUid, true);
|
|
});
|
|
|
|
it("should fire audit event when unmarking host as no-show", async () => {
|
|
const bookingUid = "test-booking-unmark-host";
|
|
createMockBooking({ uid: bookingUid, noShowHost: true });
|
|
|
|
const result = await handleMarkNoShow({
|
|
bookingUid,
|
|
noShowHost: false,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
expect(result.noShowHost).toBe(false);
|
|
expect(mockOnNoShowUpdated).toHaveBeenCalledTimes(1);
|
|
const call = mockOnNoShowUpdated.mock.calls[0][0];
|
|
expect(call.auditData.host).toEqual({
|
|
userUuid: "host-uuid-123",
|
|
noShow: { old: true, new: false },
|
|
});
|
|
expect(call.source).toBe("WEBAPP");
|
|
expect(call.actor).toEqual({ identifiedBy: "user", userUuid: "user-uuid-123" });
|
|
});
|
|
|
|
it("should mark both host and attendees in single call", async () => {
|
|
const bookingUid = "test-booking-5";
|
|
createMockBooking({ uid: bookingUid });
|
|
createMockAttendee({ bookingUid, email: "attendee@example.com", noShow: false });
|
|
|
|
const result = await handleMarkNoShow({
|
|
bookingUid,
|
|
attendees: [{ email: "attendee@example.com", noShow: true }],
|
|
noShowHost: true,
|
|
userId: 123,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
expect(result.noShowHost).toBe(true);
|
|
expect(result.attendees).toHaveLength(1);
|
|
expect(result.attendees[0].noShow).toBe(true);
|
|
expectAttendeeNoShowState(bookingUid, "attendee@example.com", true);
|
|
});
|
|
});
|
|
|
|
describe("Error Handling", () => {
|
|
it("should throw 404 when booking not found", async () => {
|
|
await expect(
|
|
handleMarkNoShow({
|
|
bookingUid: "non-existent",
|
|
noShowHost: true,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
})
|
|
).rejects.toThrow(/Failed to update no-show status/);
|
|
});
|
|
|
|
it("should throw 403 when user lacks access", async () => {
|
|
const bookingUid = "test-booking-access";
|
|
createMockBooking({ uid: bookingUid });
|
|
createMockAttendee({ bookingUid, email: "attendee@example.com" });
|
|
mockDoesUserIdHaveAccessToBooking.mockResolvedValue(false);
|
|
|
|
await expect(
|
|
handleMarkNoShow({
|
|
bookingUid,
|
|
attendees: [{ email: "attendee@example.com", noShow: true }],
|
|
userId: 999,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
})
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("should throw 403 when booking hasn't started yet", async () => {
|
|
const bookingUid = "test-future-booking";
|
|
createMockBooking({
|
|
uid: bookingUid,
|
|
startTime: new Date(Date.now() + 3600000),
|
|
endTime: new Date(Date.now() + 7200000),
|
|
});
|
|
createMockAttendee({ bookingUid, email: "attendee@example.com" });
|
|
|
|
await expect(
|
|
handleMarkNoShow({
|
|
bookingUid,
|
|
attendees: [{ email: "attendee@example.com", noShow: true }],
|
|
userId: 123,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
})
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("should throw 401 when userId not provided for attendee marking", async () => {
|
|
const bookingUid = "test-booking-auth";
|
|
createMockBooking({ uid: bookingUid });
|
|
createMockAttendee({ bookingUid, email: "attendee@example.com" });
|
|
|
|
await expect(
|
|
handleMarkNoShow({
|
|
bookingUid,
|
|
attendees: [{ email: "attendee@example.com", noShow: true }],
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
})
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("Integrations", () => {
|
|
it("should call WebhookService.sendPayload for attendee updates", async () => {
|
|
const bookingUid = "test-booking-webhook";
|
|
createMockBooking({ uid: bookingUid });
|
|
createMockAttendee({ bookingUid, email: "attendee@example.com" });
|
|
|
|
await handleMarkNoShow({
|
|
bookingUid,
|
|
attendees: [{ email: "attendee@example.com", noShow: true }],
|
|
userId: 123,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
expect(mockWebhookServiceInit).toHaveBeenCalled();
|
|
expect(mockSendPayload).toHaveBeenCalledWith(expect.objectContaining({ bookingUid }));
|
|
});
|
|
|
|
it("should call BookingEventHandlerService.onNoShowUpdated with correct args", async () => {
|
|
const bookingUid = "test-booking-audit";
|
|
createMockBooking({ uid: bookingUid });
|
|
createMockAttendee({ bookingUid, email: "attendee@example.com", noShow: false });
|
|
|
|
await handleMarkNoShow({
|
|
bookingUid,
|
|
attendees: [{ email: "attendee@example.com", noShow: true }],
|
|
userId: 123,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
expectNoShowBookingAudit({
|
|
bookingUid,
|
|
source: "WEBAPP",
|
|
actor: { identifiedBy: "user", userUuid: "user-uuid-123" },
|
|
organizationId: null,
|
|
attendeesNoShow: [{ attendeeEmail: "attendee@example.com", noShow: { old: false, new: true } }],
|
|
});
|
|
});
|
|
|
|
it("should include old and new values in audit data for host", async () => {
|
|
const bookingUid = "test-booking-audit-host";
|
|
createMockBooking({ uid: bookingUid, noShowHost: false });
|
|
|
|
await handleMarkNoShow({
|
|
bookingUid,
|
|
noShowHost: true,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
expectNoShowBookingAudit({
|
|
bookingUid,
|
|
source: "WEBAPP",
|
|
actor: { identifiedBy: "user", userUuid: "user-uuid-123" },
|
|
organizationId: null,
|
|
host: { userUuid: "host-uuid-123", noShow: { old: false, new: true } },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Public Route (handleMarkHostNoShow)", () => {
|
|
it("should create guest actor when marking host as no-show via public route", async () => {
|
|
const bookingUid = "test-booking-public-route";
|
|
createMockBooking({ uid: bookingUid });
|
|
|
|
const result = await handleMarkHostNoShow({
|
|
bookingUid,
|
|
noShowHost: true,
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
expect(result.noShowHost).toBe(true);
|
|
expectBookingNoShowHostState(bookingUid, true);
|
|
|
|
expect(mockOnNoShowUpdated).toHaveBeenCalledTimes(1);
|
|
const call = mockOnNoShowUpdated.mock.calls[0][0];
|
|
expect(call.actor.identifiedBy).toBe("guest");
|
|
expect(call.actor.email).toMatch(/@guest\.internal$/);
|
|
expect(call.actor.name).toBeNull();
|
|
expect(call.source).toBe("WEBAPP");
|
|
expect(call.auditData.host).toEqual({
|
|
userUuid: "host-uuid-123",
|
|
noShow: { old: null, new: true },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Edge Cases", () => {
|
|
it("should only update attendees found in booking", async () => {
|
|
const bookingUid = "test-booking-only-found";
|
|
createMockBooking({ uid: bookingUid });
|
|
createMockAttendee({ bookingUid, email: "attendee1@example.com", noShow: false });
|
|
createMockAttendee({ bookingUid, email: "attendee2@example.com", noShow: false });
|
|
|
|
const result = await handleMarkNoShow({
|
|
bookingUid,
|
|
attendees: [{ email: "attendee1@example.com", noShow: true }],
|
|
userId: 123,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
expect(result.attendees).toHaveLength(1);
|
|
expect(result.attendees[0].email).toBe("attendee1@example.com");
|
|
expect(result.attendees[0].noShow).toBe(true);
|
|
expectAttendeeNoShowState(bookingUid, "attendee1@example.com", true);
|
|
expectAttendeeNoShowState(bookingUid, "attendee2@example.com", false);
|
|
});
|
|
|
|
it("should gracefully handle non-existent attendee emails", async () => {
|
|
const bookingUid = "test-booking-non-existent-attendee";
|
|
createMockBooking({ uid: bookingUid });
|
|
createMockAttendee({ bookingUid, email: "attendee1@example.com", noShow: false });
|
|
|
|
const result = await handleMarkNoShow({
|
|
bookingUid,
|
|
attendees: [
|
|
{ email: "attendee1@example.com", noShow: true },
|
|
{ email: "non-existent@example.com", noShow: true },
|
|
],
|
|
userId: 123,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
expect(result.attendees).toHaveLength(1);
|
|
expect(result.attendees[0].email).toBe("attendee1@example.com");
|
|
expect(result.attendees[0].noShow).toBe(true);
|
|
expectAttendeeNoShowState(bookingUid, "attendee1@example.com", true);
|
|
});
|
|
|
|
it("should only audit successfully updated attendees, not failed ones", async () => {
|
|
const bookingUid = "test-booking-audit-only-successful";
|
|
createMockBooking({ uid: bookingUid });
|
|
const attendee1 = createMockAttendee({ bookingUid, email: "attendee1@example.com", noShow: false });
|
|
const attendee2 = createMockAttendee({ bookingUid, email: "attendee2@example.com", noShow: false });
|
|
const attendee3 = createMockAttendee({ bookingUid, email: "attendee3@example.com", noShow: false });
|
|
|
|
// Mock updateNoShow to fail for attendee2
|
|
mockUpdateNoShow.mockImplementation(
|
|
({
|
|
where: { attendeeId },
|
|
data: { noShow },
|
|
}: {
|
|
where: { attendeeId: number };
|
|
data: { noShow: boolean };
|
|
}) => {
|
|
// Simulate database error for attendee2
|
|
if (attendeeId === attendee2.id) {
|
|
return Promise.reject(new Error("Database error"));
|
|
}
|
|
|
|
for (const bookingUid of Object.keys(DB.attendees)) {
|
|
const attendee = DB.attendees[bookingUid].find((a) => a.id === attendeeId);
|
|
if (attendee) {
|
|
attendee.noShow = noShow;
|
|
return Promise.resolve({ noShow, email: attendee.email });
|
|
}
|
|
}
|
|
return Promise.resolve(null);
|
|
}
|
|
);
|
|
|
|
await handleMarkNoShow({
|
|
bookingUid,
|
|
attendees: [
|
|
{ email: "attendee1@example.com", noShow: true },
|
|
{ email: "attendee2@example.com", noShow: true }, // This will fail
|
|
{ email: "attendee3@example.com", noShow: true },
|
|
],
|
|
userId: 123,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
// Audit should only contain attendee1 and attendee3, not attendee2
|
|
expectNoShowBookingAudit({
|
|
bookingUid,
|
|
source: "WEBAPP",
|
|
actor: { identifiedBy: "user", userUuid: "user-uuid-123" },
|
|
organizationId: null,
|
|
attendeesNoShow: [
|
|
{ attendeeEmail: "attendee1@example.com", noShow: { old: false, new: true } },
|
|
{ attendeeEmail: "attendee3@example.com", noShow: { old: false, new: true } },
|
|
],
|
|
});
|
|
|
|
const call = mockOnNoShowUpdated.mock.calls[0][0];
|
|
expect(call.auditData.attendeesNoShow).toHaveLength(2);
|
|
expect(
|
|
call.auditData.attendeesNoShow.map((a: { attendeeEmail: string }) => a.attendeeEmail)
|
|
).not.toContain("attendee2@example.com");
|
|
});
|
|
|
|
it("should not fire audit event when nothing changed", async () => {
|
|
const bookingUid = "test-booking-no-change";
|
|
createMockBooking({ uid: bookingUid });
|
|
|
|
await handleMarkNoShow({
|
|
bookingUid,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
expect(mockOnNoShowUpdated).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle booking without eventType", async () => {
|
|
const bookingUid = "test-booking-no-event-type";
|
|
createMockBooking({ uid: bookingUid, hasEventType: false });
|
|
|
|
const result = await handleMarkNoShow({
|
|
bookingUid,
|
|
noShowHost: true,
|
|
actor: makeUserActor("user-uuid-123"),
|
|
actionSource: "WEBAPP",
|
|
});
|
|
|
|
expect(result.noShowHost).toBe(true);
|
|
});
|
|
});
|
|
});
|