Files
calendar/packages/features/availability/lib/getUserAvailabilityIncludingBusyTimesFromLimits.test.ts
Hariom BalharaGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Udit Takkarbot_apk
a4621da2be feat: make source required on EventBusyDetails for Troubleshooter display (#27088)
* feat: make source required on EventBusyDetails for Troubleshooter display

- Make source a required property on EventBusyDetails type
- Update LimitManager to accept and store source when adding busy times
- Add user-friendly source names for all busy time types:
  - 'Booking Limit' for booking limit busy times
  - 'Duration Limit' for duration limit busy times
  - 'Team Booking Limit' for team booking limit busy times
  - 'Buffer Time' for seated event buffer times
  - 'Calendar' for external calendar busy times
- Ensure all entries in detailedBusyTimes have source set
- Cover includeManagedEventsInLimits and teamBookingLimits branches

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

* feat: add limit value and unit metadata to busy time sources

- Include limit value and unit in source strings for Troubleshooter display
- Booking Limit: shows as 'Booking Limit: 5 per day'
- Duration Limit: shows as 'Duration Limit: 120 min per week'
- Team Booking Limit: shows as 'Team Booking Limit: 10 per month'
- Preserves existing calendar sources (e.g., 'google-calendar')

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

* feat: enhance busy time management with new limit sources

- Introduced new limit sources for busy times, including event booking and duration limits, with user-friendly titles for display.
- Updated LimitManager to accept and store detailed busy time information, including title and source.
- Refactored busy time addition logic across various services to utilize the new structure, improving clarity and maintainability.

* fixes

* feat: conditionally include source and translate busy time titles

- Add withSource parameter to conditionally include/exclude source from response
- Translate busy time titles on frontend using useLocale hook
- Source is only included when withSource=true (for Troubleshooter display)

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

* feat: enhance user availability service and busy time handling

- Updated LargeCalendar component to include event ID check for enabling busy times.
- Added translation for "busy" in common.json for better user experience.
- Refactored getUserAvailability service to include new method for fetching user availability with busy times from limits.
- Introduced parseLimits function to streamline booking and duration limit parsing.
- Improved error handling in user handler for better user feedback.

* refactor: remove unnecessary timeZone: undefined from addBusyTime calls

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

* feat: add buffer_time and calendar translation keys for busy times

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

* feat: add event source to calendar components and improve busy time handling

- Updated EventList component to include event source in data attributes for better tracking.
- Enhanced LargeCalendar component to pass event

* fix: add missing source property to Date Override calendar event

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

* fix: use 'date-override' as source for Date Override calendar events

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

* Avoid type assertion

* fix: pass both bookingLimits and durationLimits to getStartEndDateforLimitCheck (#27898)

* test: add unit tests for getUserAvailabilityIncludingBusyTimesFromLimits

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

* fix: pass both bookingLimits and durationLimits to getStartEndDateforLimitCheck

- Fix bug where bookingLimits || durationLimits was passed as single param
- Skip getBusyTimesForLimitChecks when eventType has no limits
- Remove as never casts, use proper typing for mock dependencies
- Replace expect.any(String) with exact ISO date assertions

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

* refactor: replace loose assertions with exact values in tests

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: address review feedback on busy time sources

- Fix t("busy") fallback to t("busy_time.busy") for correct translation lookup
- Add missing title property to buffer time entries for Troubleshooter display
- Use descriptive debug strings for buffer time source field
- Fix import ordering in LargeCalendar.tsx

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-Authored-By: unknown <>

* fix: preserve pre-existing busyTimesFromLimitsBookings from initialData

Address review feedback from @hariombalhara (comment #30, #31):

- Initialize busyTimesFromLimitsBookings from initialData instead of []
  to avoid silently overwriting pre-existing data with an empty array
- Use conditional spread to only include busyTimesFromLimitsBookings
  when it has a value
- Add test verifying pre-existing busyTimesFromLimitsBookings is
  preserved and passed through to _getUserAvailability
- Add test verifying busyTimesFromLimitsBookings is not passed when
  there are no limits and no initialData bookings

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-Authored-By: bot_apk <apk@cognition.ai>

* fix: pass fetched eventType to _getUserAvailability to avoid duplicate DB query

Addresses Devin Review comment r2863593564: the wrapper method fetches
eventType but wasn't passing it through, causing _getUserAvailability to
re-fetch the same eventType from the database.

Also adds a test verifying eventType is forwarded correctly.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: bot_apk <apk@cognition.ai>
2026-03-06 14:53:14 -03:00

453 lines
14 KiB
TypeScript

import dayjs from "@calcom/dayjs";
import type { EventBusyDetails } from "@calcom/types/Calendar";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { EventType, GetUserAvailabilityInitialData } from "./getUserAvailability";
import { UserAvailabilityService } from "./getUserAvailability";
vi.mock("@calcom/features/di/containers/BusyTimes", () => ({
getBusyTimesService: vi.fn(() => mockBusyTimesService),
}));
vi.mock("@calcom/features/busyTimes/lib/getBusyTimesFromLimits", () => ({
getBusyTimesFromLimits: vi.fn().mockResolvedValue([]),
getBusyTimesFromTeamLimits: vi.fn().mockResolvedValue([]),
}));
vi.mock("@calcom/app-store/_utils/getCalendar", () => ({
getCalendar: vi.fn(),
}));
vi.mock("@calcom/lib/holidays", () => ({
getHolidayService: vi.fn(() => ({
getHolidayDatesInRange: vi.fn().mockResolvedValue([]),
})),
}));
const mockBusyTimesService = {
getStartEndDateforLimitCheck: vi.fn().mockReturnValue({
limitDateFrom: dayjs("2025-01-01T00:00:00Z"),
limitDateTo: dayjs("2025-01-31T23:59:59Z"),
}),
getBusyTimesForLimitChecks: vi.fn().mockResolvedValue([]),
getBusyTimes: vi.fn().mockResolvedValue([]),
};
const mockDependencies: ConstructorParameters<typeof UserAvailabilityService>[0] = {
oooRepo: {
findUserOOODays: vi.fn().mockResolvedValue([]),
},
bookingRepo: {
findAcceptedBookingByEventTypeId: vi.fn().mockResolvedValue([]),
},
redisClient: {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
},
eventTypeRepo: {
findByIdForUserAvailability: vi.fn().mockResolvedValue(null),
findForSlots: vi.fn().mockResolvedValue(null),
},
holidayRepo: {
findUserSettingsSelect: vi.fn().mockResolvedValue(null),
},
};
const DATE_FROM = "2025-01-06T00:00:00Z";
const DATE_TO = "2025-01-10T23:59:59Z";
const DATE_FROM_ISO = "2025-01-06T00:00:00.000Z";
const DATE_TO_ISO = "2025-01-10T23:59:59.000Z";
const LIMIT_DATE_FROM_FORMATTED = "2025-01-01T00:00:00+00:00";
const LIMIT_DATE_TO_FORMATTED = "2025-01-31T23:59:59+00:00";
const createMockUser = (
overrides?: Partial<NonNullable<GetUserAvailabilityInitialData["user"]>>
): NonNullable<GetUserAvailabilityInitialData["user"]> => ({
id: 1,
username: "testuser",
email: "test@example.com",
bufferTime: 0,
timeZone: "UTC",
availability: [
{
id: 1,
userId: 1,
eventTypeId: null,
scheduleId: 1,
days: [1, 2, 3, 4, 5],
startTime: new Date("1970-01-01T09:00:00Z"),
endTime: new Date("1970-01-01T17:00:00Z"),
date: null,
},
],
timeFormat: 12,
defaultScheduleId: 1,
isPlatformManaged: false,
schedules: [
{
id: 1,
availability: [
{
days: [1, 2, 3, 4, 5],
startTime: new Date("1970-01-01T09:00:00Z"),
endTime: new Date("1970-01-01T17:00:00Z"),
date: null,
},
],
timeZone: "UTC",
},
],
credentials: [],
allSelectedCalendars: [],
userLevelSelectedCalendars: [],
travelSchedules: [],
...overrides,
});
const createMockEventType = (overrides?: Partial<NonNullable<EventType>>): NonNullable<EventType> => ({
id: 100,
length: 30,
seatsPerTimeSlot: null,
timeZone: null,
schedule: null,
availability: [],
hosts: [],
bookingLimits: null,
durationLimits: null,
metadata: null,
useEventLevelSelectedCalendars: false,
team: null,
parent: null,
schedulingType: null,
assignAllTeamMembers: false,
...overrides,
});
const createParams = (overrides?: Record<string, unknown>) => ({
dateFrom: dayjs(DATE_FROM),
dateTo: dayjs(DATE_TO),
returnDateOverrides: false,
...overrides,
});
describe("UserAvailabilityService.getUserAvailabilityIncludingBusyTimesFromLimits", () => {
let service: UserAvailabilityService;
beforeEach(() => {
vi.clearAllMocks();
service = new UserAvailabilityService(mockDependencies);
});
it("skips fetching limit busy times when busyTimesFromLimitsBookings is already in initialData", async () => {
const user = createMockUser();
const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } });
const existingBusyTimes: EventBusyDetails[] = [
{
start: "2025-01-06T10:00:00Z",
end: "2025-01-06T23:59:59Z",
source: "Event Booking Limit",
},
];
const result = await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
user,
eventType,
busyTimesFromLimitsBookings: existingBusyTimes,
});
expect(mockBusyTimesService.getBusyTimesForLimitChecks).not.toHaveBeenCalled();
expect(result).toBeDefined();
expect(result.busy).toBeDefined();
});
it("preserves pre-existing busyTimesFromLimitsBookings from initialData instead of overwriting with empty array", async () => {
const user = createMockUser();
const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } });
const existingBusyTimes: EventBusyDetails[] = [
{
start: "2025-01-06T10:00:00Z",
end: "2025-01-06T23:59:59Z",
source: "Event Booking Limit for User: 3 per day",
},
];
const getUserAvailabilitySpy = vi.spyOn(service, "_getUserAvailability" as keyof typeof service);
await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
user,
eventType,
busyTimesFromLimitsBookings: existingBusyTimes,
});
// Verify that the pre-existing busyTimesFromLimitsBookings is passed through
// and NOT overwritten with an empty array
expect(getUserAvailabilitySpy).toHaveBeenCalledWith(
createParams(),
expect.objectContaining({
user,
eventType,
busyTimesFromLimitsBookings: existingBusyTimes,
})
);
});
it("fetches busy times when eventType has booking limits", async () => {
const user = createMockUser();
const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } });
await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams({ eventTypeId: 100 }), {
user,
eventType,
});
expect(mockBusyTimesService.getStartEndDateforLimitCheck).toHaveBeenCalledWith(
DATE_FROM_ISO,
DATE_TO_ISO,
{ PER_DAY: 3 },
null
);
expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({
userIds: [1],
eventTypeId: 100,
startDate: LIMIT_DATE_FROM_FORMATTED,
endDate: LIMIT_DATE_TO_FORMATTED,
rescheduleUid: undefined,
bookingLimits: { PER_DAY: 3 },
durationLimits: null,
});
});
it("fetches busy times when eventType has duration limits", async () => {
const user = createMockUser();
const eventType = createMockEventType({ durationLimits: { PER_WEEK: 120 } });
await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
user,
eventType,
});
expect(mockBusyTimesService.getStartEndDateforLimitCheck).toHaveBeenCalledWith(
DATE_FROM_ISO,
DATE_TO_ISO,
null,
{ PER_WEEK: 120 }
);
expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({
userIds: [1],
eventTypeId: 100,
startDate: LIMIT_DATE_FROM_FORMATTED,
endDate: LIMIT_DATE_TO_FORMATTED,
rescheduleUid: undefined,
bookingLimits: null,
durationLimits: { PER_WEEK: 120 },
});
});
it("passes both bookingLimits and durationLimits separately to getStartEndDateforLimitCheck", async () => {
const user = createMockUser();
const eventType = createMockEventType({
bookingLimits: { PER_DAY: 3 },
durationLimits: { PER_WEEK: 120 },
});
await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
user,
eventType,
});
expect(mockBusyTimesService.getStartEndDateforLimitCheck).toHaveBeenCalledWith(
DATE_FROM_ISO,
DATE_TO_ISO,
{ PER_DAY: 3 },
{ PER_WEEK: 120 }
);
});
it("skips fetching limit busy times when eventType has no limits", async () => {
const user = createMockUser();
const eventType = createMockEventType();
await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
user,
eventType,
});
expect(mockBusyTimesService.getStartEndDateforLimitCheck).not.toHaveBeenCalled();
expect(mockBusyTimesService.getBusyTimesForLimitChecks).not.toHaveBeenCalled();
});
it("skips fetching limit busy times when no eventType exists", async () => {
const user = createMockUser();
await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
user,
});
expect(mockBusyTimesService.getBusyTimesForLimitChecks).not.toHaveBeenCalled();
});
it("uses host user IDs for team events with booking limits", async () => {
const user = createMockUser();
const eventType = createMockEventType({
bookingLimits: { PER_DAY: 5 },
hosts: [
{ user: { id: 1, email: "host1@test.com" }, schedule: null },
{ user: { id: 2, email: "host2@test.com" }, schedule: null },
{ user: { id: 3, email: "host3@test.com" }, schedule: null },
],
});
await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
user,
eventType,
});
expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({
userIds: [1, 2, 3],
eventTypeId: 100,
startDate: LIMIT_DATE_FROM_FORMATTED,
endDate: LIMIT_DATE_TO_FORMATTED,
rescheduleUid: undefined,
bookingLimits: { PER_DAY: 5 },
durationLimits: null,
});
});
it("uses only the user ID for solo events with booking limits", async () => {
const user = createMockUser({ id: 42 });
const eventType = createMockEventType({
bookingLimits: { PER_DAY: 2 },
hosts: [],
});
await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
user,
eventType,
});
expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({
userIds: [42],
eventTypeId: 100,
startDate: LIMIT_DATE_FROM_FORMATTED,
endDate: LIMIT_DATE_TO_FORMATTED,
rescheduleUid: undefined,
bookingLimits: { PER_DAY: 2 },
durationLimits: null,
});
});
it("fetches eventType by eventTypeId when not provided in initialData", async () => {
const user = createMockUser();
const fetchedEventType = createMockEventType({ bookingLimits: { PER_DAY: 1 } });
vi.mocked(mockDependencies.eventTypeRepo.findByIdForUserAvailability).mockResolvedValueOnce(
fetchedEventType
);
await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams({ eventTypeId: 100 }), {
user,
});
expect(mockDependencies.eventTypeRepo.findByIdForUserAvailability).toHaveBeenCalledWith({ id: 100 });
expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({
userIds: [1],
eventTypeId: 100,
startDate: LIMIT_DATE_FROM_FORMATTED,
endDate: LIMIT_DATE_TO_FORMATTED,
rescheduleUid: undefined,
bookingLimits: { PER_DAY: 1 },
durationLimits: null,
});
});
it("passes fetched busyTimesFromLimitsBookings to _getUserAvailability", async () => {
const user = createMockUser();
const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } });
const fetchedBusyTimes: EventBusyDetails[] = [
{
start: "2025-01-06T09:00:00Z",
end: "2025-01-06T10:00:00Z",
source: "eventType-100-booking-1",
title: "Test Booking",
},
];
mockBusyTimesService.getBusyTimesForLimitChecks.mockResolvedValueOnce(fetchedBusyTimes);
const getUserAvailabilitySpy = vi.spyOn(service, "_getUserAvailability" as keyof typeof service);
await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
user,
eventType,
});
expect(getUserAvailabilitySpy).toHaveBeenCalledWith(
createParams(),
expect.objectContaining({
user,
eventType,
busyTimesFromLimitsBookings: fetchedBusyTimes,
})
);
});
it("does not pass busyTimesFromLimitsBookings when no limits and no initialData bookings", async () => {
const user = createMockUser();
const eventType = createMockEventType(); // no limits
const getUserAvailabilitySpy = vi.spyOn(service, "_getUserAvailability" as keyof typeof service);
await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
user,
eventType,
});
// When there are no limits and no pre-existing busyTimesFromLimitsBookings,
// the property should not be spread into the call
const callArgs = getUserAvailabilitySpy.mock.calls[0];
expect(callArgs[1]).not.toHaveProperty("busyTimesFromLimitsBookings");
});
it("passes fetched eventType to _getUserAvailability to avoid duplicate DB query", async () => {
const user = createMockUser();
const fetchedEventType = createMockEventType({ bookingLimits: { PER_DAY: 1 } });
vi.mocked(mockDependencies.eventTypeRepo.findByIdForUserAvailability).mockResolvedValueOnce(
fetchedEventType
);
const getUserAvailabilitySpy = vi.spyOn(service, "_getUserAvailability" as keyof typeof service);
await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams({ eventTypeId: 100 }), {
user,
});
expect(getUserAvailabilitySpy).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
eventType: fetchedEventType,
})
);
});
it("passes rescheduleUid to getBusyTimesForLimitChecks when provided", async () => {
const user = createMockUser();
const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } });
await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
user,
eventType,
rescheduleUid: "reschedule-uid-123",
});
expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({
userIds: [1],
eventTypeId: 100,
startDate: LIMIT_DATE_FROM_FORMATTED,
endDate: LIMIT_DATE_TO_FORMATTED,
rescheduleUid: "reschedule-uid-123",
bookingLimits: { PER_DAY: 3 },
durationLimits: null,
});
});
});