* 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>
453 lines
14 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|