* 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>
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/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);
|
|
});
|
|
});
|
|
});
|