Files
cal.diy/packages/features/handleMarkNoShow.test.ts
T
54da93ab02 chore: Integrate mark-no-show booking audit (#26570)
* chore: Integrate mark-no-show booking audit

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Simplify host no-show audit and add comment for attendee actor

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Make actionSource required with ValidActionSource type and remove unnecessary comments

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Remove merge conflict markers from bookings.service.ts

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* feat: implement SYSTEM as a booking audit source for background tasks, including no-show triggers.

* refactor: Move prisma query to BookingRepository for audit logging

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Conflict resolution

* refactor: introduce `handleMarkHostNoShow` for public viewer and standardize actor and action source parameters for no-show actions.

* refactor: Combine HostNoShowUpdatedAuditActionService and AttendeeNoShowUpdatedAuditActionService into NoShowUpdatedActionService

- Create new NoShowUpdatedAuditActionService with combined schema supporting both noShowHost and noShowAttendee as optional fields
- Update BookingAuditActionServiceRegistry to use combined service with NO_SHOW_UPDATED action type
- Update BookingAuditTaskerProducerService with single queueNoShowUpdatedAudit method
- Update BookingAuditProducerService.interface.ts with combined method
- Update BookingEventHandlerService with single onNoShowUpdated method
- Update handleMarkNoShow.ts to use combined audit service
- Update tasker tasks (triggerGuestNoShow, triggerHostNoShow) to use combined service
- Add NO_SHOW_UPDATED to Prisma BookingAuditAction enum
- Remove old separate HostNoShowUpdatedAuditActionService and AttendeeNoShowUpdatedAuditActionService files

This allows a single API action (e.g., API V2 markAbsent) that updates both host and attendee no-show status to be logged as a single audit event.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* chore: Add migration for NO_SHOW_UPDATED audit action enum value

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Update no-show audit schema to use host and attendees array

- Rename noShowHost to host and noShowAttendee to attendees (remove redundant prefix)
- Change attendees from single value to array to support multiple attendees in single audit entry
- Update handleMarkNoShow.ts to create single audit entry for all attendees
- Update tasker tasks (triggerGuestNoShow, triggerHostNoShow) to use new schema
- Update display data types to reflect new schema structure

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Update schema to be more clear. Call onNoShowUpdated immediately after DB update as audit only cares about DB update, and this would avoid any accidental Audit update on DB change

* chore: accommodate schema changes and fix type errors for no-show audit

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Update migration to remove old no-show enum values

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Use updated attendees in webhook payload for guest no-show

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Remove duplicate imports and fix actor parameter in bookings.service.ts

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Move booking query to BookingRepository and remove unused method

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: centralize no-show audit event firing and attendee fetching within `handleMarkNoShow` for improved clarity.

* fix: Build attendeesNoShow as Record with attendee IDs as keys

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Fix and add tests for handleMarkBoShow

* refactor: Move prisma queries to AttendeeRepository and BookingRepository

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Correct return type for updateNoShow method (noShow can be null)

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Update handleMarkNoShow tests to mock new repository methods

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix triggerGuestNoShow

* refactor: Move triggerGuestNoShow queries to AttendeeRepository and add unit tests

- Add new methods to AttendeeRepository:
  - findByBookingId: Get attendees with id, email, noShow
  - findByBookingIdWithDetails: Get full attendee details
  - updateManyNoShowByBookingIdAndEmails: Update specific attendees
  - updateManyNoShowByBookingIdExcludingEmails: Update all except specific emails
- Refactor triggerGuestNoShow.ts to use AttendeeRepository instead of direct Prisma calls
- Add comprehensive unit tests for triggerGuestNoShow following handleMarkNoShow.test.ts pattern:
  - Mock repositories and external services
  - In-memory DB simulation
  - Test core functionality, audit logging, webhook payload, error handling, and edge cases

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Move triggerHostNoShow queries to repositories and delete unit test file

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Convert repository methods to use named parameters objects

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Enhance no-show handling by consolidating audit logging and updating repository interactions

- Refactor `buildResultPayload` to accept a structured argument for better clarity.
- Update `updateAttendees` to use a named parameter object for improved readability.
- Introduce `fireNoShowUpdatedEvent` to centralize no-show audit logging for both hosts and guests.
- Remove redundant methods and streamline attendee retrieval in `AttendeeRepository`.
- Add comprehensive unit tests for no-show event handling, ensuring accurate audit logging and webhook triggering.

* test: Add tests for handleMarkHostNoShow guest actor and unmarking host no-show

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: update attendeesNoShow validation to handle numeric keys

- Changed attendeesNoShow schema to use z.coerce.number() for key validation, ensuring string keys are correctly coerced to numbers.
- Updated test to validate attendeesNoShow data using numeric key comparison, improving robustness of the audit data checks.

* test: Add integration tests for NoShowUpdatedAuditActionService coerce fix

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* feat: add graceful error handling for audit log enrichment

- Add hasError field to EnrichedAuditLog type
- Create buildFallbackAuditLog() method for failed enrichments
- Wrap enrichAuditLog() in try-catch to handle errors gracefully
- Add booking_audit_action.error_processing translation key
- Update BookingHistory.tsx to show warning icon for error logs
- Hide 'Show details' button for logs with hasError
- Add comprehensive test cases for error handling scenarios

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* feat: Enhance booking audit action services with new display fields and improved validation

- Added "attendee_no_show_updated" and "no_show_updated" translations to common.json.
- Updated IAuditActionService to include new methods for handling display fields and migration.
- Enhanced NoShowUpdatedAuditActionService to differentiate between host and attendee no-show updates.
- Improved ReassignmentAuditActionService to ensure consistent handling of audit data.
- Refactored BookingAuditViewerService for better clarity and maintainability.

* refactor: Use attendeeEmail instead of attendeeId as key in audit data and store host's userUuid

- Updated schema to use attendee email as key instead of attendee ID because attendee records can be reused with different person's data
- Store host's userUuid in audit data with format: host: { userUuid, noShow: { old, new } }
- Updated fireNoShowUpdated to accept hostUserUuid parameter
- Added uuid field to BookingRepository.findByUidIncludeEventTypeAttendeesAndUser
- Updated NoShowUpdatedAuditActionService getDisplayFields to work with email-based keys
- Added attendeeRepository to DI modules for BookingAuditTaskConsumer
- Updated tests to match new schema format

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* test: Update integration tests to use new schema format with host.userUuid and email keys

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: correct audit schema for SYSTEM source and ensure attendee lookup by bookingId

- Fix schema inconsistency: use host.userUuid format instead of hostNoShow
- Add user.uuid to getBooking select for audit logging
- Log warning when hostUserUuid is undefined but host no-show is being updated
- Use email as key for attendeesNoShow (not attendee ID) to match schema
- Fetch attendees by bookingId before updating to ensure correct scoping
- Remove unused safeStringify import

* refactor: Change attendeesNoShow schema from Record to Array format

- Update NoShowUpdatedAuditActionService schema to use array format:
  attendeesNoShow: Array<{attendeeEmail: string, noShow: {old, new}}>
- Update handleMarkNoShow.ts to build attendeesNoShow as array
- Update common.ts fireNoShowUpdatedEvent to convert Map to array
- Update all tests to use new array format with attendeeEmail property

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Update attendeesNoShow schema to use array format in triggerNoShow tasks

- Refactor `triggerGuestNoShow` and `triggerHostNoShow` to replace Map with array for `attendeesNoShowAudit`.
- Update `fireNoShowUpdatedEvent` to handle the new array format for attendees.
- Ensure consistent data structure across no-show audit logging for better clarity and maintainability.

* fix: Update ReassignmentAuditActionService to use GetDisplayFieldsParams interface

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Update ReassignmentAuditActionService tests to use GetDisplayFieldsParams interface

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Use handleMarkAttendeeNoShow for API v2 mark absent endpoint

- Export handleMarkAttendeeNoShow from platform-libraries
- Update API v2 bookings service to use handleMarkAttendeeNoShow with actionSource
- Add validation for userUuid before calling handleMarkAttendeeNoShow

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Enhance display fields structure in booking audit components

- Updated `BookingHistory.tsx` to allow for more flexible display fields, including optional raw values and arrays of values.
- Introduced `DisplayFieldValue` component for rendering display fields, improving clarity and maintainability.
- Adjusted `IAuditActionService` and `NoShowUpdatedAuditActionService` to align with the new display fields structure.
- Ensured consistent handling of display fields across the booking audit service for better data representation.

* fix: Remove debug code that duplicated first attendee in display fields

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Update attendee repository methods to use structured parameters

- Modified `updateNoShow`, `updateManyNoShowByBookingIdAndEmails`, and `updateManyNoShowByBookingIdExcludingEmails` methods to accept structured `where` and `data` parameters for better clarity and maintainability.
- Updated calls to these methods throughout the codebase to reflect the new parameter structure.
- Removed unused `findByIdWithNoShow` method and adjusted related logic to streamline attendee data retrieval.

* feat: Add infrastructure for no-show audit integration

- Add Prisma migrations for SYSTEM source and NO_SHOW_UPDATED audit action
- Add NoShowUpdatedAuditActionService with array-based attendeesNoShow schema
- Update BookingAuditActionServiceRegistry to include NO_SHOW_UPDATED
- Update BookingAuditTaskConsumer and BookingAuditViewerService
- Add AttendeeRepository methods for no-show queries
- Update IAuditActionService interface with values array support
- Update locales with no-show audit translation keys

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Add NO_SHOW_UPDATED to BookingAuditAction and SYSTEM to ActionSource types

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Remove HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED from BookingAuditAction type to match Prisma schema

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Update BookingAuditActionSchema to use NO_SHOW_UPDATED instead of HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Remove deprecated no-show audit services and unify to NoShowUpdatedAuditActionService

- Delete HostNoShowUpdatedAuditActionService and AttendeeNoShowUpdatedAuditActionService
- Update BookingAuditProducerService.interface.ts to use queueNoShowUpdatedAudit
- Update BookingAuditTaskerProducerService.ts to use queueNoShowUpdatedAudit
- Update BookingEventHandlerService.ts to use onNoShowUpdated
- Add integration tests for NoShowUpdatedAuditActionService

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Update test mock to match new AttendeeRepository.updateNoShow signature

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: rename handleMarkAttendeeNoShow to handleMarkNoShow and update related types

- Renamed `handleMarkAttendeeNoShow` to `handleMarkNoShow` for clarity.
- Updated type definitions to reflect the new naming convention.
- Modified the `markNoShow` handler to require `userUuid` and adjusted related logic.
- Enhanced the `fireNoShowUpdatedEvent` to accommodate changes in event type structure.
- Updated references across various files to ensure consistency with the new function name.

* handle null value

* fix: add explicit parentheses for operator precedence clarity

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Fix cubic reported bug

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-06 09:27:25 -03:00

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/lib/server/i18n", () => ({
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);
});
});
});