Files
cal.diy/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.test.ts
T
25857c07e1 feat(bookings): add booking audit logging to instant bookings (#28176)
* feat(bookings): add booking audit logging to instant bookings

wire up BookingEventHandlerService.onBookingCreated in
InstantBookingCreateService to emit audit events, matching the
pattern already used in RegularBookingService and
RecurringBookingService.

* refactor: extract fireBookingEvents and reuse existing orgId

* refactor: derive orgId once and pass to both webhook trigger and audit event

* fix: add missing return in webhook map callback

* refactor: make creationSource required for instant bookings

Both callers (WEBAPP and API_V2) always set creationSource, so validate
it upfront and use CreationSource enum type instead of string | null.

* fix: use ErrorWithCode instead of Error, pass userUuid to audit events

* fix: pass null for hostUserUuid in instant booking audit data

Instant bookings have status AWAITING_HOST with no assigned host,
so the booker's UUID should not be recorded as hostUserUuid.

* fix: address devin review - hostUserUuid and creationSource validation

* fix: address review - bookingMeta, getOrgIdFromMemberOrTeamId, required creationSource

- pass userUuid via bookingMeta instead of separate param (matches RegularBookingService pattern)
- restore getOrgIdFromMemberOrTeamId for proper org resolution instead of eventType.team.parentId
- make creationSource required with runtime validation instead of defaulting to WEBAPP

* fix: enforce creationSource at compile time instead of runtime

use Required<Pick<>> to make creationSource required in the type
signature. removes the runtime check since TypeScript catches
missing creationSource at build time.

* fix: simplify type signature and derive hostUserUuid from booking relation

- Replace Required<Pick<CreateInstantBookingData, 'creationSource'>> with inline { creationSource: CreationSource }
- Include user relation in booking create query to derive hostUserUuid
- Pass newBooking.user?.uuid instead of hardcoding null for userUuid in audit data

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

* chore: trigger CI

* fix: add missing impersonatedByUserUuid to instant booking meta

The CreateBookingMeta type requires impersonatedByUserUuid. Set it to
null for non-impersonated instant bookings.

* fix: show 'awaiting host' in audit log for instant bookings

Use booking status AWAITING_HOST to display "Booked (awaiting host)"
instead of "Booked with Unknown" when no host has accepted yet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add booking audit for instant meeting accept via connect-and-join

Extract fireInstantBookingAcceptedAuditEvent to InstantBookingCreateService
and fire it right after the DB update, before side-effect notifications.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add audit logging tests for instant booking creation

* fix: simulate audit failure in resilience test (identified by cubic)

The test 'should not throw when booking audit event fails' was not
actually simulating an audit failure. Added vi.spyOn on
BookingEventHandlerService.prototype.onBookingCreated to reject with
an error, and assert the spy was called, proving the try/catch in
fireBookingEvents properly catches the error without breaking the
booking flow.

Co-Authored-By: bot_apk <apk@cognition.ai>

* test: add audit event tests for connectAndJoin and fix InstantBooking audit test

* test

---------

Co-authored-by: Hariom Balhara <1780212+hariombalhara@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariombalhara@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: bot_apk <apk@cognition.ai>
2026-04-04 13:30:03 -03:00

209 lines
6.1 KiB
TypeScript

import { prisma } from "@calcom/prisma/__mocks__/prisma";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { BookingStatus } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { Handler } from "./connectAndJoin.handler";
vi.mock("@calcom/prisma", () => ({
prisma,
}));
vi.mock("@calcom/emails/email-manager", () => ({
sendScheduledEmailsAndSMS: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@calcom/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers", () => ({
scheduleNoShowTriggers: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@calcom/i18n/server", () => ({
getTranslation: vi.fn().mockResolvedValue((key: string) => key),
}));
vi.mock("@calcom/features/eventtypes/di/EventTypeService.container", () => ({
getEventTypeService: vi.fn(() => ({
shouldHideBrandingForEventType: vi.fn().mockResolvedValue(false),
})),
}));
vi.mock("@calcom/features/bookings/lib/getCalEventResponses", () => ({
getCalEventResponses: vi.fn().mockReturnValue({}),
}));
vi.mock("@calcom/features/di/containers/FeaturesRepository");
vi.mock("@calcom/features/bookings/di/BookingEventHandlerService.container");
const MOCK_BOOKING_UID = "booking-uid-123";
const MOCK_USER_UUID = "user-uuid-456";
const MOCK_ORG_ID = 100;
const MOCK_TOKEN = "instant-meeting-token";
function createMockUser(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 1,
uuid: MOCK_USER_UUID,
email: "host@example.com",
name: "Host User",
username: "hostuser",
timeZone: "UTC",
timeFormat: 12,
locale: "en",
organization: { id: MOCK_ORG_ID },
organizationId: MOCK_ORG_ID,
...overrides,
} as unknown as NonNullable<TrpcSessionUser>;
}
function mockPrismaForSuccessfulJoin({ oldStatus = BookingStatus.AWAITING_HOST } = {}) {
prisma.user.findUnique.mockResolvedValue({
hideBranding: false,
profiles: [],
} as any);
prisma.instantMeetingToken.findUnique.mockResolvedValue({
expires: new Date(Date.now() + 60_000),
teamId: 1,
booking: {
id: 10,
status: oldStatus,
user: { id: 99 },
},
} as any);
prisma.booking.update.mockResolvedValue({
id: 10,
uid: MOCK_BOOKING_UID,
title: "Instant Meeting",
description: null,
customInputs: {},
startTime: new Date("2026-04-04T10:00:00Z"),
endTime: new Date("2026-04-04T10:45:00Z"),
location: "integrations:daily",
userId: 1,
status: BookingStatus.ACCEPTED,
responses: {},
metadata: { videoCallUrl: "https://daily.co/mock-meeting" },
attendees: [
{
name: "Attendee",
email: "attendee@example.com",
timeZone: "UTC",
locale: "en",
},
],
references: [
{
type: "daily_video",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "https://daily.co/mock-meeting",
},
],
eventTypeId: 1,
eventType: {
id: 1,
owner: null,
teamId: 1,
title: "Instant Meeting",
slug: "instant-meeting",
requiresConfirmation: false,
currency: "usd",
length: 45,
description: null,
price: 0,
bookingFields: null,
disableGuests: false,
metadata: null,
hideOrganizerEmail: false,
customInputs: [],
parentId: null,
customReplyToEmail: null,
team: {
id: 1,
name: "Test Team",
hideBranding: false,
parent: null,
},
},
} as any);
}
describe("connectAndJoin.handler", () => {
const mockOnBookingAccepted = vi.fn().mockResolvedValue(undefined);
const mockCheckIfTeamHasFeature = vi.fn();
beforeEach(async () => {
vi.clearAllMocks();
const { getBookingEventHandlerService } = await import(
"@calcom/features/bookings/di/BookingEventHandlerService.container"
);
vi.mocked(getBookingEventHandlerService).mockReturnValue({
onBookingAccepted: mockOnBookingAccepted,
} as any);
const { getFeaturesRepository } = await import("@calcom/features/di/containers/FeaturesRepository");
vi.mocked(getFeaturesRepository).mockReturnValue({
checkIfTeamHasFeature: mockCheckIfTeamHasFeature,
} as any);
});
describe("booking audit event", () => {
it("should fire booking accepted audit event with correct data", async () => {
mockCheckIfTeamHasFeature.mockResolvedValue(true);
mockPrismaForSuccessfulJoin({ oldStatus: BookingStatus.AWAITING_HOST });
await Handler({
ctx: { user: createMockUser() },
input: { token: MOCK_TOKEN },
});
expect(mockCheckIfTeamHasFeature).toHaveBeenCalledWith(MOCK_ORG_ID, "booking-audit");
expect(mockOnBookingAccepted).toHaveBeenCalledTimes(1);
expect(mockOnBookingAccepted).toHaveBeenCalledWith({
bookingUid: MOCK_BOOKING_UID,
actor: { identifiedBy: "user", userUuid: MOCK_USER_UUID },
organizationId: MOCK_ORG_ID,
auditData: {
status: { old: BookingStatus.AWAITING_HOST, new: BookingStatus.ACCEPTED },
},
source: "WEBAPP",
isBookingAuditEnabled: true,
});
});
it("should pass isBookingAuditEnabled=false when feature is disabled", async () => {
mockCheckIfTeamHasFeature.mockResolvedValue(false);
mockPrismaForSuccessfulJoin();
await Handler({
ctx: { user: createMockUser() },
input: { token: MOCK_TOKEN },
});
expect(mockOnBookingAccepted).toHaveBeenCalledTimes(1);
expect(mockOnBookingAccepted).toHaveBeenCalledWith(
expect.objectContaining({ isBookingAuditEnabled: false })
);
});
it("should not throw when audit event fails", async () => {
mockCheckIfTeamHasFeature.mockResolvedValue(true);
mockOnBookingAccepted.mockRejectedValue(new Error("Audit handler failure"));
mockPrismaForSuccessfulJoin();
const result = await Handler({
ctx: { user: createMockUser() },
input: { token: MOCK_TOKEN },
});
expect(result.meetingUrl).toBe("https://daily.co/mock-meeting");
expect(mockOnBookingAccepted).toHaveBeenCalled();
});
});
});