fix: Conditional fetch cache (#24816)
* Fix cache to fetch only when it's available * Non hierarchical feature check * Fix tests: Add missing mock method and comprehensive CalendarCacheWrapper tests - Add checkIfUserHasFeatureNonHierarchical to features.repository mock to fix failing GoogleCalendar tests - Add comprehensive unit tests for CalendarCacheWrapper covering: - Calendars with sync only (cache-only path) - Calendars without sync only (original calendar path) - Mixed calendars (both cache and original) - Timezone handling with UTC defaults - Edge cases (empty arrays, undefined methods, null ids) - Use proper types instead of 'as any' to satisfy lint rules Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * Fix: Sanitize logging to avoid exposing PII - Replace logging full selectedCalendars objects with only calendar IDs and count - Prevents exposure of email fields and other sensitive information in logs - Addresses AI code reviewer feedback Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * Apply suggestion from @volnei --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Volnei Munhoz
Devin AI
parent
7635d338c0
commit
cc1efe1298
@@ -50,7 +50,7 @@ export const getCalendar = async (
|
||||
featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE
|
||||
),
|
||||
featuresRepository.checkIfUserHasFeature(
|
||||
featuresRepository.checkIfUserHasFeatureNonHierarchical(
|
||||
credential.userId as number,
|
||||
CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ const featuresRepositoryModuleMock = {
|
||||
FeaturesRepository: vi.fn().mockImplementation(() => ({
|
||||
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true),
|
||||
checkIfUserHasFeature: vi.fn().mockResolvedValue(true),
|
||||
checkIfUserHasFeatureNonHierarchical: vi.fn().mockResolvedValue(true),
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
+92
-31
@@ -50,54 +50,115 @@ export class CalendarCacheWrapper implements Calendar {
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method to use cache
|
||||
* Retrieves availability combining cache and live sources.
|
||||
*
|
||||
* @param dateFrom
|
||||
* @param dateTo
|
||||
* @param selectedCalendars
|
||||
* @param shouldServeCache
|
||||
* - Calendars **with** both `syncToken` and `syncSubscribedAt` → fetched from cache.
|
||||
* - Calendars **without** one of them → fetched directly from the original calendar.
|
||||
* - Results are merged into a single array.
|
||||
*
|
||||
* @param dateFrom - Start date (ISO string)
|
||||
* @param dateTo - End date (ISO string)
|
||||
* @param selectedCalendars - List of calendars to retrieve availability from
|
||||
* @returns Combined array of busy date ranges
|
||||
*/
|
||||
async getAvailability(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
selectedCalendars: IntegrationCalendar[]
|
||||
// _shouldServeCache?: boolean
|
||||
// _fallbackToPrimary?: boolean
|
||||
): Promise<EventBusyDate[]> {
|
||||
log.debug("getAvailability from cache", { dateFrom, dateTo, selectedCalendars });
|
||||
const selectedCalendarIds = selectedCalendars.map((e) => e.id).filter((id): id is string => Boolean(id));
|
||||
if (!selectedCalendarIds.length) {
|
||||
return Promise.resolve([]);
|
||||
log.debug("getAvailability (mixed cache + original)", {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
calendarIds: selectedCalendars.map((c) => c.id),
|
||||
calendarCount: selectedCalendars.length,
|
||||
});
|
||||
|
||||
if (!selectedCalendars?.length) return [];
|
||||
|
||||
const withSync = selectedCalendars.filter((c) => c.syncToken && c.syncSubscribedAt);
|
||||
const withoutSync = selectedCalendars.filter((c) => !c.syncToken || !c.syncSubscribedAt);
|
||||
|
||||
const results: EventBusyDate[] = [];
|
||||
|
||||
// Fetch from cache for synced calendars
|
||||
if (withSync.length) {
|
||||
const ids = withSync.map((c) => c.id).filter((id): id is string => Boolean(id));
|
||||
const cached = await this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween(
|
||||
ids,
|
||||
new Date(dateFrom),
|
||||
new Date(dateTo)
|
||||
);
|
||||
results.push(...cached);
|
||||
}
|
||||
return this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween(
|
||||
selectedCalendarIds,
|
||||
new Date(dateFrom),
|
||||
new Date(dateTo)
|
||||
);
|
||||
|
||||
// Fetch from original calendar for unsynced ones
|
||||
if (withoutSync.length) {
|
||||
const original = await this.deps.originalCalendar.getAvailability(dateFrom, dateTo, withoutSync);
|
||||
results.push(...original);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method to use cache
|
||||
* Retrieves availability with time zones, combining cache and live data.
|
||||
*
|
||||
* @param dateFrom
|
||||
* @param dateTo
|
||||
* @param selectedCalendars
|
||||
* @returns
|
||||
* - Calendars **with** both `syncToken` and `syncSubscribedAt` → fetched from cache.
|
||||
* - Calendars **without** one of them → fetched directly from the original calendar.
|
||||
* - Results are merged into a single array with `{ start, end, timeZone }` format.
|
||||
*
|
||||
* @param dateFrom - Start date (ISO string)
|
||||
* @param dateTo - End date (ISO string)
|
||||
* @param selectedCalendars - List of calendars to retrieve availability from
|
||||
* @returns Combined array of time-zone-aware availability ranges
|
||||
*/
|
||||
async getAvailabilityWithTimeZones?(
|
||||
async getAvailabilityWithTimeZones(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
selectedCalendars: IntegrationCalendar[]
|
||||
// _fallbackToPrimary?: boolean
|
||||
): Promise<{ start: Date | string; end: Date | string; timeZone: string }[]> {
|
||||
log.debug("getAvailabilityWithTimeZones from cache", { dateFrom, dateTo, selectedCalendars });
|
||||
const selectedCalendarIds = selectedCalendars.map((e) => e.id).filter((id): id is string => Boolean(id));
|
||||
const result = await this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween(
|
||||
selectedCalendarIds,
|
||||
new Date(dateFrom),
|
||||
new Date(dateTo)
|
||||
);
|
||||
return result.map(({ start, end, timeZone }) => ({ start, end, timeZone: timeZone || "UTC" }));
|
||||
log.debug("getAvailabilityWithTimeZones (mixed cache + original)", {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
calendarIds: selectedCalendars.map((c) => c.id),
|
||||
calendarCount: selectedCalendars.length,
|
||||
});
|
||||
|
||||
if (!selectedCalendars?.length) return [];
|
||||
|
||||
const withSync = selectedCalendars.filter((c) => c.syncToken && c.syncSubscribedAt);
|
||||
const withoutSync = selectedCalendars.filter((c) => !c.syncToken || !c.syncSubscribedAt);
|
||||
|
||||
const results: { start: Date | string; end: Date | string; timeZone: string }[] = [];
|
||||
|
||||
// Fetch from cache for synced calendars
|
||||
if (withSync.length) {
|
||||
const ids = withSync.map((c) => c.id).filter((id): id is string => Boolean(id));
|
||||
const cached = await this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween(
|
||||
ids,
|
||||
new Date(dateFrom),
|
||||
new Date(dateTo)
|
||||
);
|
||||
results.push(
|
||||
...cached.map(({ start, end, timeZone }) => ({
|
||||
start,
|
||||
end,
|
||||
timeZone: timeZone || "UTC",
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch from original calendar for unsynced ones
|
||||
if (withoutSync.length) {
|
||||
const original = await this.deps.originalCalendar.getAvailabilityWithTimeZones?.(
|
||||
dateFrom,
|
||||
dateTo,
|
||||
withoutSync
|
||||
);
|
||||
if (original?.length) results.push(...original);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
fetchAvailabilityAndSetCache?(selectedCalendars: IntegrationCalendar[]): Promise<unknown> {
|
||||
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { ICalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface";
|
||||
import type {
|
||||
Calendar,
|
||||
CalendarEvent,
|
||||
CalendarServiceEvent,
|
||||
EventBusyDate,
|
||||
IntegrationCalendar,
|
||||
NewCalendarEventType,
|
||||
} from "@calcom/types/Calendar";
|
||||
|
||||
import { CalendarCacheWrapper } from "../CalendarCacheWrapper";
|
||||
|
||||
describe("CalendarCacheWrapper", () => {
|
||||
let mockOriginalCalendar: Calendar;
|
||||
let mockRepository: ICalendarCacheEventRepository;
|
||||
let wrapper: CalendarCacheWrapper;
|
||||
|
||||
const createMockCalendar = (): Calendar => ({
|
||||
getAvailability: vi.fn().mockResolvedValue([]),
|
||||
getAvailabilityWithTimeZones: vi.fn().mockResolvedValue([]),
|
||||
createEvent: vi.fn(),
|
||||
updateEvent: vi.fn(),
|
||||
deleteEvent: vi.fn(),
|
||||
listCalendars: vi.fn(),
|
||||
});
|
||||
|
||||
const createMockRepository = (): ICalendarCacheEventRepository => ({
|
||||
findAllBySelectedCalendarIdsBetween: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockOriginalCalendar = createMockCalendar();
|
||||
mockRepository = createMockRepository();
|
||||
wrapper = new CalendarCacheWrapper({
|
||||
originalCalendar: mockOriginalCalendar,
|
||||
calendarCacheEventRepository: mockRepository,
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailability", () => {
|
||||
it("should return empty array when no calendars provided", async () => {
|
||||
const result = await wrapper.getAvailability("2025-01-01", "2025-01-02", []);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockRepository.findAllBySelectedCalendarIdsBetween).not.toHaveBeenCalled();
|
||||
expect(mockOriginalCalendar.getAvailability).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fetch only from cache when all calendars have syncToken and syncSubscribedAt", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-1",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "cal-2",
|
||||
externalId: "ext-2",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-2",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const cachedEvents: EventBusyDate[] = [
|
||||
{ start: new Date("2025-01-01T10:00:00Z"), end: new Date("2025-01-01T11:00:00Z") },
|
||||
{ start: new Date("2025-01-01T14:00:00Z"), end: new Date("2025-01-01T15:00:00Z") },
|
||||
];
|
||||
|
||||
vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents);
|
||||
|
||||
const result = await wrapper.getAvailability("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual(cachedEvents);
|
||||
expect(mockRepository.findAllBySelectedCalendarIdsBetween).toHaveBeenCalledWith(
|
||||
["cal-1", "cal-2"],
|
||||
new Date("2025-01-01"),
|
||||
new Date("2025-01-02")
|
||||
);
|
||||
expect(mockOriginalCalendar.getAvailability).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fetch only from original calendar when all calendars lack syncToken or syncSubscribedAt", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: null,
|
||||
syncSubscribedAt: null,
|
||||
},
|
||||
{
|
||||
id: "cal-2",
|
||||
externalId: "ext-2",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-2",
|
||||
syncSubscribedAt: null, // Missing syncSubscribedAt
|
||||
},
|
||||
];
|
||||
|
||||
const originalEvents: EventBusyDate[] = [
|
||||
{ start: new Date("2025-01-01T09:00:00Z"), end: new Date("2025-01-01T10:00:00Z") },
|
||||
];
|
||||
|
||||
vi.mocked(mockOriginalCalendar.getAvailability).mockResolvedValue(originalEvents);
|
||||
|
||||
const result = await wrapper.getAvailability("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual(originalEvents);
|
||||
expect(mockRepository.findAllBySelectedCalendarIdsBetween).not.toHaveBeenCalled();
|
||||
expect(mockOriginalCalendar.getAvailability).toHaveBeenCalledWith(
|
||||
"2025-01-01",
|
||||
"2025-01-02",
|
||||
selectedCalendars
|
||||
);
|
||||
});
|
||||
|
||||
it("should fetch from both cache and original calendar when calendars are mixed", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-1",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "cal-2",
|
||||
externalId: "ext-2",
|
||||
integration: "google_calendar",
|
||||
syncToken: null,
|
||||
syncSubscribedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const cachedEvents: EventBusyDate[] = [
|
||||
{ start: new Date("2025-01-01T10:00:00Z"), end: new Date("2025-01-01T11:00:00Z") },
|
||||
];
|
||||
|
||||
const originalEvents: EventBusyDate[] = [
|
||||
{ start: new Date("2025-01-01T14:00:00Z"), end: new Date("2025-01-01T15:00:00Z") },
|
||||
];
|
||||
|
||||
vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents);
|
||||
vi.mocked(mockOriginalCalendar.getAvailability).mockResolvedValue(originalEvents);
|
||||
|
||||
const result = await wrapper.getAvailability("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual([...cachedEvents, ...originalEvents]);
|
||||
expect(mockRepository.findAllBySelectedCalendarIdsBetween).toHaveBeenCalledWith(
|
||||
["cal-1"],
|
||||
new Date("2025-01-01"),
|
||||
new Date("2025-01-02")
|
||||
);
|
||||
expect(mockOriginalCalendar.getAvailability).toHaveBeenCalledWith("2025-01-01", "2025-01-02", [
|
||||
selectedCalendars[1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("should filter out calendars without id when fetching from cache", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-1",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: null,
|
||||
externalId: "ext-2",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-2",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
await wrapper.getAvailability("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(mockRepository.findAllBySelectedCalendarIdsBetween).toHaveBeenCalledWith(
|
||||
["cal-1"], // Only cal-1 has a valid id
|
||||
new Date("2025-01-01"),
|
||||
new Date("2025-01-02")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailabilityWithTimeZones", () => {
|
||||
it("should return empty array when no calendars provided", async () => {
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", []);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockRepository.findAllBySelectedCalendarIdsBetween).not.toHaveBeenCalled();
|
||||
expect(mockOriginalCalendar.getAvailabilityWithTimeZones).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fetch only from cache and apply UTC default timezone when all calendars are synced", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-1",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const cachedEvents: EventBusyDate[] = [
|
||||
{
|
||||
start: new Date("2025-01-01T10:00:00Z"),
|
||||
end: new Date("2025-01-01T11:00:00Z"),
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
{
|
||||
start: new Date("2025-01-01T14:00:00Z"),
|
||||
end: new Date("2025-01-01T15:00:00Z"),
|
||||
timeZone: null, // Should default to UTC
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents);
|
||||
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
start: cachedEvents[0].start,
|
||||
end: cachedEvents[0].end,
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
{
|
||||
start: cachedEvents[1].start,
|
||||
end: cachedEvents[1].end,
|
||||
timeZone: "UTC", // Default applied
|
||||
},
|
||||
]);
|
||||
expect(mockOriginalCalendar.getAvailabilityWithTimeZones).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fetch only from original calendar when all calendars are unsynced", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: null,
|
||||
syncSubscribedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const originalEvents = [
|
||||
{
|
||||
start: new Date("2025-01-01T09:00:00Z"),
|
||||
end: new Date("2025-01-01T10:00:00Z"),
|
||||
timeZone: "Europe/London",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockOriginalCalendar.getAvailabilityWithTimeZones).mockResolvedValue(originalEvents);
|
||||
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual(originalEvents);
|
||||
expect(mockRepository.findAllBySelectedCalendarIdsBetween).not.toHaveBeenCalled();
|
||||
expect(mockOriginalCalendar.getAvailabilityWithTimeZones).toHaveBeenCalledWith(
|
||||
"2025-01-01",
|
||||
"2025-01-02",
|
||||
selectedCalendars
|
||||
);
|
||||
});
|
||||
|
||||
it("should fetch from both cache and original calendar when calendars are mixed", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-1",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "cal-2",
|
||||
externalId: "ext-2",
|
||||
integration: "google_calendar",
|
||||
syncToken: null,
|
||||
syncSubscribedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const cachedEvents: EventBusyDate[] = [
|
||||
{
|
||||
start: new Date("2025-01-01T10:00:00Z"),
|
||||
end: new Date("2025-01-01T11:00:00Z"),
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
];
|
||||
|
||||
const originalEvents = [
|
||||
{
|
||||
start: new Date("2025-01-01T14:00:00Z"),
|
||||
end: new Date("2025-01-01T15:00:00Z"),
|
||||
timeZone: "Europe/London",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents);
|
||||
vi.mocked(mockOriginalCalendar.getAvailabilityWithTimeZones).mockResolvedValue(originalEvents);
|
||||
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
start: cachedEvents[0].start,
|
||||
end: cachedEvents[0].end,
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
...originalEvents,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle when originalCalendar.getAvailabilityWithTimeZones is undefined", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-1",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "cal-2",
|
||||
externalId: "ext-2",
|
||||
integration: "google_calendar",
|
||||
syncToken: null,
|
||||
syncSubscribedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const cachedEvents: EventBusyDate[] = [
|
||||
{
|
||||
start: new Date("2025-01-01T10:00:00Z"),
|
||||
end: new Date("2025-01-01T11:00:00Z"),
|
||||
timeZone: "UTC",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents);
|
||||
mockOriginalCalendar.getAvailabilityWithTimeZones = undefined;
|
||||
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
start: cachedEvents[0].start,
|
||||
end: cachedEvents[0].end,
|
||||
timeZone: "UTC",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle when originalCalendar.getAvailabilityWithTimeZones returns empty array", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: null,
|
||||
syncSubscribedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockOriginalCalendar.getAvailabilityWithTimeZones).mockResolvedValue([]);
|
||||
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("other Calendar methods", () => {
|
||||
it("should delegate createEvent to original calendar", async () => {
|
||||
const mockEvent: CalendarServiceEvent = {
|
||||
title: "Test Event",
|
||||
startTime: "2025-01-01T10:00:00Z",
|
||||
endTime: "2025-01-01T11:00:00Z",
|
||||
};
|
||||
const mockResult: NewCalendarEventType = {
|
||||
uid: "test-uid",
|
||||
id: "test-id",
|
||||
type: "test-type",
|
||||
password: "",
|
||||
url: "",
|
||||
additionalInfo: {},
|
||||
};
|
||||
|
||||
vi.mocked(mockOriginalCalendar.createEvent).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await wrapper.createEvent(mockEvent, 123, "cal-id");
|
||||
|
||||
expect(result).toBe(mockResult);
|
||||
expect(mockOriginalCalendar.createEvent).toHaveBeenCalledWith(mockEvent, 123, "cal-id");
|
||||
});
|
||||
|
||||
it("should delegate updateEvent to original calendar", async () => {
|
||||
const mockEvent: CalendarServiceEvent = {
|
||||
title: "Updated Event",
|
||||
startTime: "2025-01-01T10:00:00Z",
|
||||
endTime: "2025-01-01T11:00:00Z",
|
||||
};
|
||||
const mockResult: NewCalendarEventType = {
|
||||
uid: "test-uid",
|
||||
id: "test-id",
|
||||
type: "test-type",
|
||||
password: "",
|
||||
url: "",
|
||||
additionalInfo: {},
|
||||
};
|
||||
|
||||
vi.mocked(mockOriginalCalendar.updateEvent).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await wrapper.updateEvent("uid-123", mockEvent, "cal-id");
|
||||
|
||||
expect(result).toBe(mockResult);
|
||||
expect(mockOriginalCalendar.updateEvent).toHaveBeenCalledWith("uid-123", mockEvent, "cal-id");
|
||||
});
|
||||
|
||||
it("should delegate deleteEvent to original calendar", async () => {
|
||||
const mockEvent: CalendarEvent = {
|
||||
uid: "test-uid",
|
||||
title: "Test Event",
|
||||
startTime: "2025-01-01T10:00:00Z",
|
||||
endTime: "2025-01-01T11:00:00Z",
|
||||
};
|
||||
|
||||
vi.mocked(mockOriginalCalendar.deleteEvent).mockResolvedValue(undefined);
|
||||
|
||||
await wrapper.deleteEvent("uid-123", mockEvent, "cal-id");
|
||||
|
||||
expect(mockOriginalCalendar.deleteEvent).toHaveBeenCalledWith("uid-123", mockEvent, "cal-id");
|
||||
});
|
||||
|
||||
it("should delegate listCalendars to original calendar", async () => {
|
||||
const mockCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockOriginalCalendar.listCalendars).mockResolvedValue(mockCalendars);
|
||||
|
||||
const result = await wrapper.listCalendars();
|
||||
|
||||
expect(result).toBe(mockCalendars);
|
||||
expect(mockOriginalCalendar.listCalendars).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -142,6 +142,40 @@ export class FeaturesRepository implements IFeaturesRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific user has access to a feature, ignoring hierarchical (parent) teams.
|
||||
* Only checks direct user assignments and direct team memberships — does not traverse parents.
|
||||
* @param userId - The ID of the user to check
|
||||
* @param slug - The feature identifier to check
|
||||
* @returns Promise<boolean> - True if the user has direct or same-level team access to the feature
|
||||
* @throws Error if the feature access check fails
|
||||
*/
|
||||
async checkIfUserHasFeatureNonHierarchical(userId: number, slug: string) {
|
||||
try {
|
||||
// Prismock limitation: findUnique may fail, use findFirst instead
|
||||
const userHasFeature = await this.prismaClient.userFeatures.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
where: {
|
||||
userId,
|
||||
featureId: slug,
|
||||
},
|
||||
});
|
||||
if (userHasFeature) return true;
|
||||
|
||||
const userBelongsToTeamWithFeature = await this.checkIfUserBelongsToTeamWithFeatureNonHierarchical(
|
||||
userId,
|
||||
slug
|
||||
);
|
||||
|
||||
return userBelongsToTeamWithFeature;
|
||||
} catch (err) {
|
||||
captureException(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private helper method to check if a user belongs to any team that has access to a feature.
|
||||
* @param userId - The ID of the user to check
|
||||
@@ -190,6 +224,40 @@ export class FeaturesRepository implements IFeaturesRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user belongs to any direct team that has access to a feature.
|
||||
* This version ignores parent/child team relationships — no recursion or hierarchy traversal.
|
||||
* @param userId - The ID of the user to check
|
||||
* @param slug - The feature identifier to check
|
||||
* @returns Promise<boolean> - True if the user belongs to a team with the feature (direct only)
|
||||
* @throws Error if the query fails
|
||||
* @private
|
||||
*/
|
||||
private async checkIfUserBelongsToTeamWithFeatureNonHierarchical(userId: number, slug: string) {
|
||||
try {
|
||||
const query = Prisma.sql`
|
||||
SELECT 1
|
||||
FROM "Team" t
|
||||
INNER JOIN "Membership" m ON m."teamId" = t.id
|
||||
WHERE m."userId" = ${userId}
|
||||
AND m.accepted = true
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "TeamFeatures" tf
|
||||
WHERE tf."teamId" = t.id
|
||||
AND tf."featureId" = ${slug}
|
||||
)
|
||||
LIMIT 1;
|
||||
`;
|
||||
|
||||
const result = await this.prismaClient.$queryRaw<unknown[]>(query);
|
||||
return result.length > 0;
|
||||
} catch (err) {
|
||||
captureException(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables a feature for a specific team.
|
||||
* @param teamId - The ID of the team to enable the feature for
|
||||
|
||||
Reference in New Issue
Block a user