* 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>
209 lines
6.1 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|