Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions packages/features/bookings/repositories/BookingRepository.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import type { PrismaClient } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BookingRepository } from "./BookingRepository";

describe("BookingRepository", () => {
let repository: BookingRepository;
let mockPrismaClient: {
$queryRaw: ReturnType<typeof vi.fn>;
booking: {
findUnique: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
};
};

beforeEach(() => {
vi.clearAllMocks();

mockPrismaClient = {
$queryRaw: vi.fn(),
booking: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
};

repository = new BookingRepository(mockPrismaClient as unknown as PrismaClient);
Expand Down Expand Up @@ -58,4 +67,176 @@ describe("BookingRepository", () => {
expect(mockPrismaClient.$queryRaw).toHaveBeenCalledTimes(1);
});
});

describe("findByUidIncludeAttendeeEmails", () => {
it("should query booking by uid with attendee emails and host user email", async () => {
const mockBooking = {
id: 1,
uid: "test-uid",
attendees: [{ email: "guest@example.com" }],
user: { email: "host@example.com" },
};
mockPrismaClient.booking.findUnique.mockResolvedValue(mockBooking);

const result = await repository.findByUidIncludeAttendeeEmails({ uid: "test-uid" });

expect(result).toEqual(mockBooking);
expect(mockPrismaClient.booking.findUnique).toHaveBeenCalledWith({
where: { uid: "test-uid" },
select: {
id: true,
uid: true,
attendees: { select: { email: true } },
user: { select: { email: true } },
},
});
});

it("should return null when booking does not exist", async () => {
mockPrismaClient.booking.findUnique.mockResolvedValue(null);

const result = await repository.findByUidIncludeAttendeeEmails({ uid: "nonexistent" });

expect(result).toBeNull();
});
});

describe("findByUserIdsAndDateRange", () => {
const dateFrom = new Date("2026-04-01T00:00:00Z");
const dateTo = new Date("2026-04-30T23:59:59Z");

it("should return empty array when both userIds and userEmails are empty", async () => {
const result = await repository.findByUserIdsAndDateRange({
userIds: [],
userEmails: [],
dateFrom,
dateTo,
});

expect(result).toEqual([]);
expect(mockPrismaClient.booking.findMany).not.toHaveBeenCalled();
});

it("should query bookings by userId when userIds are provided", async () => {
const mockBookings = [
{
uid: "booking-1",
startTime: new Date("2026-04-10T09:00:00Z"),
endTime: new Date("2026-04-10T10:00:00Z"),
title: "Meeting",
userId: 10,
status: BookingStatus.ACCEPTED,
},
];
mockPrismaClient.booking.findMany.mockResolvedValue(mockBookings);

const result = await repository.findByUserIdsAndDateRange({
userIds: [10],
userEmails: [],
dateFrom,
dateTo,
});

expect(result).toEqual(mockBookings);
expect(mockPrismaClient.booking.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: { in: [BookingStatus.ACCEPTED, BookingStatus.PENDING] },
AND: [{ startTime: { lt: dateTo } }, { endTime: { gt: dateFrom } }],
}),
})
);
});

it("should query bookings by email when userEmails are provided", async () => {
mockPrismaClient.booking.findMany.mockResolvedValue([]);

await repository.findByUserIdsAndDateRange({
userIds: [],
userEmails: ["guest@example.com"],
dateFrom,
dateTo,
});

expect(mockPrismaClient.booking.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: expect.arrayContaining([
{
attendees: {
some: { email: { in: ["guest@example.com"], mode: "insensitive" } },
},
},
]),
}),
})
);
});

it("should combine userId and email conditions in OR clause", async () => {
mockPrismaClient.booking.findMany.mockResolvedValue([]);

await repository.findByUserIdsAndDateRange({
userIds: [10, 20],
userEmails: ["guest@example.com"],
dateFrom,
dateTo,
});

const callArgs = mockPrismaClient.booking.findMany.mock.calls[0][0];
expect(callArgs.where.OR).toHaveLength(2);
expect(callArgs.where.OR).toEqual(
expect.arrayContaining([
{ userId: { in: [10, 20] } },
{
attendees: {
some: { email: { in: ["guest@example.com"], mode: "insensitive" } },
},
},
])
);
});

it("should select the correct fields", async () => {
mockPrismaClient.booking.findMany.mockResolvedValue([]);

await repository.findByUserIdsAndDateRange({
userIds: [10],
userEmails: [],
dateFrom,
dateTo,
});

expect(mockPrismaClient.booking.findMany).toHaveBeenCalledWith(
expect.objectContaining({
select: {
uid: true,
startTime: true,
endTime: true,
title: true,
userId: true,
status: true,
},
})
);
});

it("should include excludeUid in query when provided", async () => {
await repository.findByUserIdsAndDateRange({
userIds: [1],
userEmails: [],
dateFrom,
dateTo,
excludeUid: "booking-to-exclude",
});

expect(mockPrismaClient.booking.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
uid: { not: "booking-to-exclude" },
}),
})
);
});
});
});
50 changes: 50 additions & 0 deletions packages/features/bookings/repositories/BookingRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2137,4 +2137,54 @@ export class BookingRepository implements IBookingRepository {
},
});
}

async findByUidIncludeAttendeeEmails({ uid }: { uid: string }) {
return this.prismaClient.booking.findUnique({
where: { uid },
select: {
id: true,
uid: true,
attendees: { select: { email: true } },
user: { select: { email: true } },
},
Comment on lines +2147 to +2149
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include host email fallback in booking lookup select

findByUidIncludeAttendeeEmails only selects user.email, so when a booking has no related user row (the Booking.userId relation is nullable and other codepaths already handle missing user data), this method returns no organizer email at all. That makes downstream reschedule logic unable to reliably determine host-vs-guest initiator for those bookings; selecting userPrimaryEmail (or an equivalent fallback) here would avoid dropping host identity.

Useful? React with 👍 / 👎.

});
}

async findByUserIdsAndDateRange({
userIds,
userEmails,
dateFrom,
dateTo,
excludeUid,
}: {
userIds: number[];
userEmails: string[];
dateFrom: Date;
dateTo: Date;
excludeUid?: string;
}) {
if (!userIds.length && !userEmails.length) return [];

return this.prismaClient.booking.findMany({
where: {
status: { in: [BookingStatus.ACCEPTED, BookingStatus.PENDING] },
AND: [{ startTime: { lt: dateTo } }, { endTime: { gt: dateFrom } }],
OR: [
...(userIds.length > 0 ? [{ userId: { in: userIds } }] : []),
...(userEmails.length > 0
? [{ attendees: { some: { email: { in: userEmails, mode: "insensitive" as const } } } }]
: []),
Comment on lines +2172 to +2176
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match bookings by host email when userId is missing

findByUserIdsAndDateRange only matches bookings via userId or attendee email, so organizer-owned bookings with userId = null but a populated userPrimaryEmail are skipped unless the organizer also appears as an attendee. In those cases, busy windows are undercounted and reschedule availability can include invalid slots; adding a userPrimaryEmail email-match branch to this OR would cover that data shape.

Useful? React with 👍 / 👎.

],
...(excludeUid ? { uid: { not: excludeUid } } : {}),
},
select: {
uid: true,
startTime: true,
endTime: true,
title: true,
userId: true,
status: true,
},
});
}
}
101 changes: 101 additions & 0 deletions packages/features/users/repositories/UserRepository.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import prismock from "@calcom/testing/lib/__mocks__/prisma";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { PrismaClient } from "@calcom/prisma";
import { CreationSource } from "@calcom/prisma/enums";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@calcom/app-store/delegationCredential", () => ({
Expand Down Expand Up @@ -111,4 +112,104 @@ describe("UserRepository", () => {
);
});
});

describe("findByEmails", () => {
let mockPrismaClient: {
user: {
findMany: ReturnType<typeof vi.fn>;
};
};
let repo: UserRepository;

beforeEach(() => {
mockPrismaClient = {
user: {
findMany: vi.fn(),
},
};
repo = new UserRepository(mockPrismaClient as unknown as PrismaClient);
});

test("should return empty array when emails list is empty", async () => {
const result = await repo.findByEmails({ emails: [] });

expect(result).toEqual([]);
expect(mockPrismaClient.user.findMany).not.toHaveBeenCalled();
});

test("should look up users by primary email", async () => {
mockPrismaClient.user.findMany
.mockResolvedValueOnce([{ id: 1, email: "user@example.com" }])
.mockResolvedValueOnce([]);

const result = await repo.findByEmails({ emails: ["user@example.com"] });

expect(result).toEqual([{ id: 1, email: "user@example.com" }]);
expect(mockPrismaClient.user.findMany).toHaveBeenCalledTimes(2);
});

test("should look up users by secondary (verified) email", async () => {
mockPrismaClient.user.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 2, email: "primary@example.com" }]);

const result = await repo.findByEmails({ emails: ["secondary@example.com"] });

expect(result).toEqual([{ id: 2, email: "primary@example.com" }]);
expect(mockPrismaClient.user.findMany).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
where: {
secondaryEmails: {
some: {
email: { in: ["secondary@example.com"], mode: "insensitive" },
emailVerified: { not: null },
},
},
},
})
);
});

test("should deduplicate users found via both primary and secondary email", async () => {
mockPrismaClient.user.findMany
.mockResolvedValueOnce([{ id: 1, email: "user@example.com" }])
.mockResolvedValueOnce([{ id: 1, email: "user@example.com" }]);

const result = await repo.findByEmails({ emails: ["user@example.com", "alias@example.com"] });

expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
});

test("should normalize emails to lowercase and deduplicate input", async () => {
mockPrismaClient.user.findMany
.mockResolvedValueOnce([{ id: 1, email: "user@example.com" }])
.mockResolvedValueOnce([]);

await repo.findByEmails({ emails: ["User@Example.COM", "user@example.com"] });

expect(mockPrismaClient.user.findMany).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: { email: { in: ["user@example.com"], mode: "insensitive" } },
})
);
});

test("should return multiple distinct users", async () => {
mockPrismaClient.user.findMany
.mockResolvedValueOnce([
{ id: 1, email: "user1@example.com" },
{ id: 2, email: "user2@example.com" },
])
.mockResolvedValueOnce([]);

const result = await repo.findByEmails({
emails: ["user1@example.com", "user2@example.com"],
});

expect(result).toHaveLength(2);
});
});
});
Loading
Loading