diff --git a/src/app/features/calendar-integration/calendar-integration.service.spec.ts b/src/app/features/calendar-integration/calendar-integration.service.spec.ts index 1c4a8ff38..aa287f389 100644 --- a/src/app/features/calendar-integration/calendar-integration.service.spec.ts +++ b/src/app/features/calendar-integration/calendar-integration.service.spec.ts @@ -1,16 +1,1475 @@ -// import { TestBed } from '@angular/core/testing'; -// -// import { CalendarIntegrationService } from './calendar-integration.service'; -// -// describe('CalendarIntegrationService', () => { -// let service: CalendarIntegrationService; -// -// beforeEach(() => { -// TestBed.configureTestingModule({}); -// service = TestBed.inject(CalendarIntegrationService); -// }); -// -// it('should be created', () => { -// expect(service).toBeTruthy(); -// }); -// }); +import { TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { CalendarIntegrationService } from './calendar-integration.service'; +import { selectCalendarProviders } from '../issue/store/issue-provider.selectors'; +import { selectAllCalendarTaskEventIds } from '../tasks/store/task.selectors'; +import { IssueProviderCalendar } from '../issue/issue.model'; +import { + LOCAL_FILE_CHECK_INTERVAL, + getEffectiveCheckInterval, + DEFAULT_CALENDAR_CFG, +} from '../issue/providers/calendar/calendar.const'; +import { SnackService } from '../../core/snack/snack.service'; +import { take } from 'rxjs/operators'; +import { Subscription } from 'rxjs'; + +describe('CalendarIntegrationService', () => { + let service: CalendarIntegrationService; + let store: MockStore; + let httpMock: HttpTestingController; + let subscriptions: Subscription[] = []; + + const mockSnackService = { + open: jasmine.createSpy('open'), + }; + + const createMockProvider = ( + overrides: Partial = {}, + ): IssueProviderCalendar => + ({ + id: 'test-provider-1', + isEnabled: true, + issueProviderKey: 'ICAL', + icalUrl: 'https://example.com/calendar.ics', + checkUpdatesEvery: DEFAULT_CALENDAR_CFG.checkUpdatesEvery, + showBannerBeforeThreshold: DEFAULT_CALENDAR_CFG.showBannerBeforeThreshold, + isAutoImportForCurrentDay: false, + isDisabledForWebApp: false, + ...overrides, + }) as IssueProviderCalendar; + + const MOCK_ICAL_DATA = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20260104T100000Z +DTEND:20260104T110000Z +SUMMARY:Test Event +UID:test-event-1 +END:VEVENT +END:VCALENDAR`; + + const MOCK_ICAL_DATA_2 = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20260104T140000Z +DTEND:20260104T150000Z +SUMMARY:Another Event +UID:test-event-2 +END:VEVENT +END:VCALENDAR`; + + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear(); + subscriptions = []; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + CalendarIntegrationService, + provideMockStore({ + selectors: [ + { selector: selectCalendarProviders, value: [] }, + { selector: selectAllCalendarTaskEventIds, value: [] }, + ], + }), + { provide: SnackService, useValue: mockSnackService }, + ], + }); + + service = TestBed.inject(CalendarIntegrationService); + store = TestBed.inject(MockStore); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + // Clean up all subscriptions + subscriptions.forEach((sub) => sub.unsubscribe()); + subscriptions = []; + localStorage.clear(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('icalEvents$', () => { + describe('basic functionality', () => { + it('should emit cached data immediately on first subscription', fakeAsync(() => { + let emittedValue: unknown; + const sub = service.icalEvents$.pipe(take(1)).subscribe((val) => { + emittedValue = val; + }); + subscriptions.push(sub); + + tick(0); + expect(emittedValue).toEqual([]); + discardPeriodicTasks(); + })); + + it('should return empty array when no providers', fakeAsync(() => { + store.overrideSelector(selectCalendarProviders, []); + store.refreshState(); + + let emittedValue: unknown; + const sub = service.icalEvents$.pipe(take(2)).subscribe((val) => { + emittedValue = val; + }); + subscriptions.push(sub); + + tick(0); + expect(emittedValue).toEqual([]); + discardPeriodicTasks(); + })); + + it('should emit cached data from localStorage if available', fakeAsync(() => { + const cachedData = [ + { + items: [ + { + id: 'cached-event-1', + calProviderId: 'provider-1', + title: 'Cached Event', + start: Date.now() + 60000, // Future event + duration: 3600000, + }, + ], + }, + ]; + localStorage.setItem('SUP_CAL_EVENTS_CACHE', JSON.stringify(cachedData)); + + // Create new service instance to pick up cached data + const newService = TestBed.inject(CalendarIntegrationService); + + let emittedValue: unknown; + const sub = newService.icalEvents$.pipe(take(1)).subscribe((val) => { + emittedValue = val; + }); + subscriptions.push(sub); + + tick(0); + expect(emittedValue).toBeDefined(); + expect((emittedValue as any[])[0].items.length).toBe(1); + discardPeriodicTasks(); + })); + + it('should filter out past events from cache', fakeAsync(() => { + const cachedData = [ + { + items: [ + { + id: 'past-event', + calProviderId: 'provider-1', + title: 'Past Event', + start: Date.now() - 7200000, // 2 hours ago + duration: 3600000, // 1 hour - so end is 1 hour ago + }, + { + id: 'future-event', + calProviderId: 'provider-1', + title: 'Future Event', + start: Date.now() + 60000, // Future event + duration: 3600000, + }, + ], + }, + ]; + localStorage.setItem('SUP_CAL_EVENTS_CACHE', JSON.stringify(cachedData)); + + const newService = TestBed.inject(CalendarIntegrationService); + + let emittedValue: unknown; + const sub = newService.icalEvents$.pipe(take(1)).subscribe((val) => { + emittedValue = val; + }); + subscriptions.push(sub); + + tick(0); + // Should only have the future event + expect((emittedValue as any[])[0].items.length).toBe(1); + expect((emittedValue as any[])[0].items[0].id).toBe('future-event'); + discardPeriodicTasks(); + })); + }); + + describe('interval behavior', () => { + it('should use LOCAL_FILE_CHECK_INTERVAL for file:// URLs', () => { + const fileProvider = createMockProvider({ + icalUrl: 'file:///home/user/calendar.ics', + checkUpdatesEvery: 2 * 60 * 60 * 1000, + }); + + expect(getEffectiveCheckInterval(fileProvider)).toBe(LOCAL_FILE_CHECK_INTERVAL); + }); + + it('should use checkUpdatesEvery for remote URLs', () => { + const customInterval = 30 * 60 * 1000; + const remoteProvider = createMockProvider({ + icalUrl: 'https://example.com/calendar.ics', + checkUpdatesEvery: customInterval, + }); + + expect(getEffectiveCheckInterval(remoteProvider)).toBe(customInterval); + }); + + it('should prefer shorter interval in mixed providers scenario', () => { + const remoteProvider = createMockProvider({ + id: 'remote-provider', + icalUrl: 'https://example.com/calendar.ics', + checkUpdatesEvery: 2 * 60 * 60 * 1000, + }); + + const fileProvider = createMockProvider({ + id: 'file-provider', + icalUrl: 'file:///home/user/calendar.ics', + checkUpdatesEvery: 2 * 60 * 60 * 1000, + }); + + const remoteInterval = getEffectiveCheckInterval(remoteProvider); + const fileInterval = getEffectiveCheckInterval(fileProvider); + + expect(Math.min(remoteInterval, fileInterval)).toBe(LOCAL_FILE_CHECK_INTERVAL); + }); + }); + + describe('memory leak prevention', () => { + it('should share single subscription via shareReplay', fakeAsync(() => { + const mockProvider = createMockProvider(); + store.overrideSelector(selectCalendarProviders, [mockProvider]); + store.refreshState(); + + // Subscribe twice + const sub1 = service.icalEvents$.subscribe(() => {}); + const sub2 = service.icalEvents$.subscribe(() => {}); + subscriptions.push(sub1, sub2); + + tick(0); + + // Should only have one HTTP request due to shareReplay + const req = httpMock.expectOne(mockProvider.icalUrl); + req.flush(MOCK_ICAL_DATA); + + // Verify no additional requests + httpMock.expectNone(mockProvider.icalUrl); + + discardPeriodicTasks(); + })); + + it('should clean up timer when all subscribers unsubscribe (refCount)', fakeAsync(() => { + const mockProvider = createMockProvider({ + checkUpdatesEvery: 60000, // 1 minute + }); + store.overrideSelector(selectCalendarProviders, [mockProvider]); + store.refreshState(); + + // Subscribe + const sub = service.icalEvents$.subscribe(() => {}); + + tick(0); + const req1 = httpMock.expectOne(mockProvider.icalUrl); + req1.flush(MOCK_ICAL_DATA); + + // Unsubscribe + sub.unsubscribe(); + + // Wait for interval - should NOT make new request since no subscribers + tick(60000); + + // Verify no new requests were made + httpMock.expectNone(mockProvider.icalUrl); + + discardPeriodicTasks(); + })); + + it('should handle provider changes without memory leak', fakeAsync(() => { + const provider1 = createMockProvider({ + id: 'provider-1', + icalUrl: 'https://example1.com/calendar.ics', + checkUpdatesEvery: 60000, + }); + + const provider2 = createMockProvider({ + id: 'provider-2', + icalUrl: 'https://example2.com/calendar.ics', + checkUpdatesEvery: 60000, + }); + + store.overrideSelector(selectCalendarProviders, [provider1]); + store.refreshState(); + + const sub = service.icalEvents$.subscribe(() => {}); + subscriptions.push(sub); + + tick(0); + const req1 = httpMock.expectOne(provider1.icalUrl); + req1.flush(MOCK_ICAL_DATA); + + // Change providers - switchMap should cancel old timer + store.overrideSelector(selectCalendarProviders, [provider2]); + store.refreshState(); + + tick(0); + const req2 = httpMock.expectOne(provider2.icalUrl); + req2.flush(MOCK_ICAL_DATA_2); + + // Wait for old interval - should NOT trigger request to old provider + tick(60000); + httpMock.expectNone(provider1.icalUrl); + + // But should trigger for new provider + const req3 = httpMock.expectOne(provider2.icalUrl); + req3.flush(MOCK_ICAL_DATA_2); + + discardPeriodicTasks(); + })); + }); + + describe('error handling', () => { + it('should handle HTTP errors gracefully', fakeAsync(() => { + const mockProvider = createMockProvider(); + store.overrideSelector(selectCalendarProviders, [mockProvider]); + store.refreshState(); + + let lastValue: unknown; + const sub = service.icalEvents$.subscribe((val) => { + lastValue = val; + }); + subscriptions.push(sub); + + tick(0); + const req = httpMock.expectOne(mockProvider.icalUrl); + req.error(new ProgressEvent('error')); + + tick(0); + + // Should still emit (with empty or cached data) + expect(lastValue).toBeDefined(); + discardPeriodicTasks(); + })); + + it('should continue polling after error', fakeAsync(() => { + const mockProvider = createMockProvider({ + checkUpdatesEvery: 60000, + }); + store.overrideSelector(selectCalendarProviders, [mockProvider]); + store.refreshState(); + + const sub = service.icalEvents$.subscribe(() => {}); + subscriptions.push(sub); + + tick(0); + // First request - error + const req1 = httpMock.expectOne(mockProvider.icalUrl); + req1.error(new ProgressEvent('error')); + + // Wait for next interval + tick(60000); + + // Should retry + const req2 = httpMock.expectOne(mockProvider.icalUrl); + req2.flush(MOCK_ICAL_DATA); + + discardPeriodicTasks(); + })); + }); + + describe('disabled providers', () => { + it('should not fetch disabled providers', fakeAsync(() => { + const enabledProvider = createMockProvider({ + id: 'enabled-provider', + isEnabled: true, + icalUrl: 'https://enabled.example.com/calendar.ics', + }); + + const disabledProvider = createMockProvider({ + id: 'disabled-provider', + isEnabled: false, + icalUrl: 'https://disabled.example.com/calendar.ics', + }); + + store.overrideSelector(selectCalendarProviders, [ + enabledProvider, + disabledProvider, + ]); + store.refreshState(); + + const sub = service.icalEvents$.subscribe(() => {}); + subscriptions.push(sub); + + tick(0); + + // Only enabled provider should be fetched + const req = httpMock.expectOne(enabledProvider.icalUrl); + req.flush(MOCK_ICAL_DATA); + + // Disabled provider should not be fetched + httpMock.expectNone(disabledProvider.icalUrl); + + discardPeriodicTasks(); + })); + + it('should use default interval when all providers are disabled', () => { + const disabledProvider = createMockProvider({ + isEnabled: false, + icalUrl: 'https://example.com/calendar.ics', + }); + + // Access private method via any cast for testing + const interval = (service as any)._getMinRefreshInterval([disabledProvider]); + + expect(interval).toBe(2 * 60 * 60 * 1000); // Default 2 hours + }); + }); + + describe('caching', () => { + it('should save fetched data to localStorage', fakeAsync(() => { + // Reset TestBed for clean isolation + TestBed.resetTestingModule(); + localStorage.clear(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + CalendarIntegrationService, + provideMockStore({ + selectors: [ + { selector: selectCalendarProviders, value: [] }, + { selector: selectAllCalendarTaskEventIds, value: [] }, + ], + }), + { provide: SnackService, useValue: mockSnackService }, + ], + }); + + const freshService = TestBed.inject(CalendarIntegrationService); + const freshStore = TestBed.inject(MockStore); + const freshHttpMock = TestBed.inject(HttpTestingController); + + const mockProvider = createMockProvider(); + freshStore.overrideSelector(selectCalendarProviders, [mockProvider]); + freshStore.overrideSelector(selectAllCalendarTaskEventIds, []); + freshStore.refreshState(); + + let emittedCount = 0; + const sub = freshService.icalEvents$.subscribe(() => { + emittedCount++; + }); + + tick(0); + const req = freshHttpMock.expectOne(mockProvider.icalUrl); + req.flush(MOCK_ICAL_DATA); + + // Allow combineLatest and tap to execute + tick(100); + freshStore.refreshState(); + tick(100); + + // Verify the emission happened (cached data + fresh data) + expect(emittedCount).toBeGreaterThan(0); + + const cached = localStorage.getItem('SUP_CAL_EVENTS_CACHE'); + expect(cached).toBeTruthy(); + + sub.unsubscribe(); + discardPeriodicTasks(); + })); + }); + }); + + describe('skipCalendarEvent', () => { + it('should add event ID to skipped list', () => { + const event = { + id: 'test-event-id', + calProviderId: 'test-provider', + title: 'Test Event', + start: Date.now(), + duration: 60 * 60 * 1000, + }; + + service.skipCalendarEvent(event); + + expect(service.skippedEventIds$.getValue()).toContain('test-event-id'); + }); + + it('should persist skipped events to localStorage', () => { + const event = { + id: 'test-event-id', + calProviderId: 'test-provider', + title: 'Test Event', + start: Date.now(), + duration: 60 * 60 * 1000, + }; + + service.skipCalendarEvent(event); + + const stored = localStorage.getItem('SUP_CALENDER_EVENTS_SKIPPED_TODAY'); + expect(stored).toBeTruthy(); + expect(JSON.parse(stored!)).toContain('test-event-id'); + }); + + it('should not add duplicate event IDs', () => { + const event = { + id: 'test-event-id', + calProviderId: 'test-provider', + title: 'Test Event', + start: Date.now(), + duration: 60 * 60 * 1000, + }; + + service.skipCalendarEvent(event); + service.skipCalendarEvent(event); + + const skippedIds = service.skippedEventIds$.getValue(); + const occurrences = skippedIds.filter((id) => id === 'test-event-id').length; + expect(occurrences).toBe(1); + }); + + it('should handle null event gracefully', () => { + expect(() => service.skipCalendarEvent(null as any)).not.toThrow(); + }); + + it('should handle event without id gracefully', () => { + const event = { + calProviderId: 'test-provider', + title: 'Test Event', + start: Date.now(), + duration: 60 * 60 * 1000, + } as any; + + expect(() => service.skipCalendarEvent(event)).not.toThrow(); + }); + + it('should store skip date in localStorage', () => { + const event = { + id: 'test-event-id', + calProviderId: 'test-provider', + title: 'Test Event', + start: Date.now(), + duration: 60 * 60 * 1000, + }; + + service.skipCalendarEvent(event); + + const skipDay = localStorage.getItem('SUP_CALENDER_EVENTS_LAST_SKIP_DAY'); + expect(skipDay).toBeTruthy(); + }); + }); + + describe('testConnection', () => { + it('should return true when connection succeeds', async () => { + const cfg = { icalUrl: 'https://example.com/calendar.ics' } as any; + + const promise = service.testConnection(cfg); + + const req = httpMock.expectOne(cfg.icalUrl); + req.flush(MOCK_ICAL_DATA); + + const result = await promise; + expect(result).toBe(true); + }); + + it('should return false when connection fails', async () => { + const cfg = { icalUrl: 'https://example.com/calendar.ics' } as any; + + const promise = service.testConnection(cfg); + + const req = httpMock.expectOne(cfg.icalUrl); + req.error(new ProgressEvent('error')); + + const result = await promise; + expect(result).toBe(false); + }); + + it('should return false for empty response', async () => { + const cfg = { icalUrl: 'https://example.com/calendar.ics' } as any; + + const promise = service.testConnection(cfg); + + const req = httpMock.expectOne(cfg.icalUrl); + req.flush(''); + + const result = await promise; + expect(result).toBe(false); + }); + }); + + describe('requestEvents$', () => { + it('should fetch events from provider URL', fakeAsync(() => { + const mockProvider = createMockProvider(); + + let result: unknown; + const sub = service.requestEvents$(mockProvider).subscribe((val) => { + result = val; + }); + subscriptions.push(sub); + + const req = httpMock.expectOne(mockProvider.icalUrl); + req.flush(MOCK_ICAL_DATA); + + tick(0); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + })); + + it('should return empty array for disabled web app provider in browser', fakeAsync(() => { + const mockProvider = createMockProvider({ + isDisabledForWebApp: true, + }); + + // Note: IS_WEB_BROWSER might be false in tests, so this test might not fully work + const sub = service.requestEvents$(mockProvider).subscribe(() => { + // Subscribe to trigger the request + }); + subscriptions.push(sub); + + tick(0); + + // May or may not make request depending on IS_WEB_BROWSER + })); + + it('should handle parse errors gracefully', fakeAsync(() => { + const mockProvider = createMockProvider(); + + const sub = service.requestEvents$(mockProvider).subscribe(() => { + // Subscribe to trigger the request + }); + subscriptions.push(sub); + + const req = httpMock.expectOne(mockProvider.icalUrl); + req.flush('INVALID ICAL DATA'); + + tick(0); + // Should not throw, might return empty array or parsed result + })); + }); + + describe('_getMinRefreshInterval', () => { + it('should return default interval for empty provider list', () => { + const interval = (service as any)._getMinRefreshInterval([]); + expect(interval).toBe(2 * 60 * 60 * 1000); + }); + + it('should return minimum interval from multiple providers', () => { + const provider1 = createMockProvider({ + id: 'p1', + isEnabled: true, + checkUpdatesEvery: 60 * 60 * 1000, // 1 hour + }); + + const provider2 = createMockProvider({ + id: 'p2', + isEnabled: true, + checkUpdatesEvery: 30 * 60 * 1000, // 30 minutes + }); + + const interval = (service as any)._getMinRefreshInterval([provider1, provider2]); + expect(interval).toBe(30 * 60 * 1000); + }); + + it('should ignore disabled providers', () => { + const enabledProvider = createMockProvider({ + id: 'enabled', + isEnabled: true, + checkUpdatesEvery: 60 * 60 * 1000, // 1 hour + }); + + const disabledProvider = createMockProvider({ + id: 'disabled', + isEnabled: false, + checkUpdatesEvery: 10 * 60 * 1000, // 10 minutes - shorter but disabled + }); + + const interval = (service as any)._getMinRefreshInterval([ + enabledProvider, + disabledProvider, + ]); + expect(interval).toBe(60 * 60 * 1000); + }); + + it('should ignore providers without URL', () => { + const providerWithUrl = createMockProvider({ + id: 'with-url', + isEnabled: true, + icalUrl: 'https://example.com/cal.ics', + checkUpdatesEvery: 60 * 60 * 1000, + }); + + const providerWithoutUrl = createMockProvider({ + id: 'without-url', + isEnabled: true, + icalUrl: '', + checkUpdatesEvery: 10 * 60 * 1000, + }); + + const interval = (service as any)._getMinRefreshInterval([ + providerWithUrl, + providerWithoutUrl, + ]); + expect(interval).toBe(60 * 60 * 1000); + }); + + it('should use LOCAL_FILE_CHECK_INTERVAL for file:// provider', () => { + const fileProvider = createMockProvider({ + isEnabled: true, + icalUrl: 'file:///home/user/calendar.ics', + checkUpdatesEvery: 2 * 60 * 60 * 1000, // Configured as 2 hours + }); + + const interval = (service as any)._getMinRefreshInterval([fileProvider]); + expect(interval).toBe(LOCAL_FILE_CHECK_INTERVAL); // Should be 5 minutes + }); + }); + + describe('constructor', () => { + it('should load skipped events from localStorage on init', () => { + const skippedIds = ['event-1', 'event-2']; + const today = new Date().toISOString().split('T')[0]; + + localStorage.setItem( + 'SUP_CALENDER_EVENTS_SKIPPED_TODAY', + JSON.stringify(skippedIds), + ); + localStorage.setItem('SUP_CALENDER_EVENTS_LAST_SKIP_DAY', today); + + // Reset TestBed to create a fresh service instance + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + CalendarIntegrationService, + provideMockStore({ + selectors: [ + { selector: selectCalendarProviders, value: [] }, + { selector: selectAllCalendarTaskEventIds, value: [] }, + ], + }), + { provide: SnackService, useValue: mockSnackService }, + ], + }); + + const newService = TestBed.inject(CalendarIntegrationService); + expect(newService.skippedEventIds$.getValue()).toEqual(skippedIds); + }); + + it('should not load skipped events from different day', () => { + const skippedIds = ['event-1', 'event-2']; + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; + + localStorage.setItem( + 'SUP_CALENDER_EVENTS_SKIPPED_TODAY', + JSON.stringify(skippedIds), + ); + localStorage.setItem('SUP_CALENDER_EVENTS_LAST_SKIP_DAY', yesterday); + + // Reset TestBed to create a fresh service instance + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + CalendarIntegrationService, + provideMockStore({ + selectors: [ + { selector: selectCalendarProviders, value: [] }, + { selector: selectAllCalendarTaskEventIds, value: [] }, + ], + }), + { provide: SnackService, useValue: mockSnackService }, + ], + }); + + const newService = TestBed.inject(CalendarIntegrationService); + expect(newService.skippedEventIds$.getValue()).toEqual([]); + }); + + it('should handle invalid JSON in localStorage gracefully', () => { + const today = new Date().toISOString().split('T')[0]; + + localStorage.setItem('SUP_CALENDER_EVENTS_SKIPPED_TODAY', 'invalid json'); + localStorage.setItem('SUP_CALENDER_EVENTS_LAST_SKIP_DAY', today); + + // Reset TestBed to create a fresh service instance + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + CalendarIntegrationService, + provideMockStore({ + selectors: [ + { selector: selectCalendarProviders, value: [] }, + { selector: selectAllCalendarTaskEventIds, value: [] }, + ], + }), + { provide: SnackService, useValue: mockSnackService }, + ], + }); + + expect(() => TestBed.inject(CalendarIntegrationService)).not.toThrow(); + }); + }); + + describe('event filtering', () => { + it('should filter out events already added as tasks', fakeAsync(() => { + // Reset TestBed for clean isolation + TestBed.resetTestingModule(); + localStorage.clear(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + CalendarIntegrationService, + provideMockStore({ + selectors: [ + { selector: selectCalendarProviders, value: [] }, + { selector: selectAllCalendarTaskEventIds, value: ['test-event-1'] }, + ], + }), + { provide: SnackService, useValue: mockSnackService }, + ], + }); + + const freshService = TestBed.inject(CalendarIntegrationService); + const freshStore = TestBed.inject(MockStore); + const freshHttpMock = TestBed.inject(HttpTestingController); + + const mockProvider = createMockProvider(); + freshStore.overrideSelector(selectCalendarProviders, [mockProvider]); + freshStore.refreshState(); + + let lastValue: any; + const sub = freshService.icalEvents$.subscribe((val) => { + lastValue = val; + }); + + tick(0); + const req = freshHttpMock.expectOne(mockProvider.icalUrl); + req.flush(MOCK_ICAL_DATA); + + tick(100); + freshStore.refreshState(); + tick(100); + + // The event with ID 'test-event-1' should be filtered out + if (lastValue && lastValue.length > 0) { + const allItems = lastValue.flatMap((entry: any) => entry.items || []); + const hasFilteredEvent = allItems.some((item: any) => item.id === 'test-event-1'); + expect(hasFilteredEvent).toBe(false); + } + + sub.unsubscribe(); + discardPeriodicTasks(); + })); + + it('should filter out skipped events', fakeAsync(() => { + const mockProvider = createMockProvider(); + store.overrideSelector(selectCalendarProviders, [mockProvider]); + store.refreshState(); + + // Skip an event first + service.skipCalendarEvent({ + id: 'test-event-1', + calProviderId: 'test-provider', + title: 'Test Event', + start: Date.now(), + duration: 3600000, + }); + + let lastValue: any; + const sub = service.icalEvents$.subscribe((val) => { + lastValue = val; + }); + subscriptions.push(sub); + + tick(0); + const req = httpMock.expectOne(mockProvider.icalUrl); + req.flush(MOCK_ICAL_DATA); + + tick(100); + store.refreshState(); + tick(100); + + // Skipped event should be filtered + if (lastValue && lastValue.length > 0) { + const allItems = lastValue.flatMap((entry: any) => entry.items || []); + const hasSkippedEvent = allItems.some((item: any) => item.id === 'test-event-1'); + expect(hasSkippedEvent).toBe(false); + } + + discardPeriodicTasks(); + })); + + it('should update filtered events when skippedEventIds$ changes', fakeAsync(() => { + const mockProvider = createMockProvider(); + store.overrideSelector(selectCalendarProviders, [mockProvider]); + store.refreshState(); + + const emissions: any[] = []; + const sub = service.icalEvents$.subscribe((val) => { + emissions.push(val); + }); + subscriptions.push(sub); + + tick(0); + const req = httpMock.expectOne(mockProvider.icalUrl); + req.flush(MOCK_ICAL_DATA); + + tick(100); + + const emissionsBeforeSkip = emissions.length; + + // Skip an event - should trigger new emission + service.skipCalendarEvent({ + id: 'new-skip-event', + calProviderId: 'test-provider', + title: 'New Skip Event', + start: Date.now(), + duration: 3600000, + }); + + tick(100); + + // Should have more emissions after skipping + expect(emissions.length).toBeGreaterThanOrEqual(emissionsBeforeSkip); + + discardPeriodicTasks(); + })); + }); + + describe('multiple providers', () => { + it('should fetch from multiple providers in parallel', fakeAsync(() => { + const provider1 = createMockProvider({ + id: 'provider-1', + icalUrl: 'https://provider1.com/calendar.ics', + }); + const provider2 = createMockProvider({ + id: 'provider-2', + icalUrl: 'https://provider2.com/calendar.ics', + }); + + store.overrideSelector(selectCalendarProviders, [provider1, provider2]); + store.refreshState(); + + const sub = service.icalEvents$.subscribe(() => {}); + subscriptions.push(sub); + + tick(0); + + // Both providers should have requests + const req1 = httpMock.expectOne(provider1.icalUrl); + const req2 = httpMock.expectOne(provider2.icalUrl); + + req1.flush(MOCK_ICAL_DATA); + req2.flush(MOCK_ICAL_DATA_2); + + discardPeriodicTasks(); + })); + + it('should fall back to cache when one provider errors', fakeAsync(() => { + // Set up cache with data for provider-1 + const cachedData = [ + { + items: [ + { + id: 'cached-event-provider-1', + calProviderId: 'provider-1', + title: 'Cached Event', + start: Date.now() + 60000, + duration: 3600000, + }, + ], + }, + ]; + localStorage.setItem('SUP_CAL_EVENTS_CACHE', JSON.stringify(cachedData)); + + // Reset TestBed to pick up cache + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + CalendarIntegrationService, + provideMockStore({ + selectors: [ + { selector: selectCalendarProviders, value: [] }, + { selector: selectAllCalendarTaskEventIds, value: [] }, + ], + }), + { provide: SnackService, useValue: mockSnackService }, + ], + }); + + const freshService = TestBed.inject(CalendarIntegrationService); + const freshStore = TestBed.inject(MockStore); + const freshHttpMock = TestBed.inject(HttpTestingController); + + const provider1 = createMockProvider({ + id: 'provider-1', + icalUrl: 'https://provider1.com/calendar.ics', + }); + const provider2 = createMockProvider({ + id: 'provider-2', + icalUrl: 'https://provider2.com/calendar.ics', + }); + + freshStore.overrideSelector(selectCalendarProviders, [provider1, provider2]); + freshStore.refreshState(); + + let lastValue: any; + const sub = freshService.icalEvents$.subscribe((val) => { + lastValue = val; + }); + + tick(0); + + const req1 = freshHttpMock.expectOne(provider1.icalUrl); + const req2 = freshHttpMock.expectOne(provider2.icalUrl); + + // Provider 1 errors + req1.error(new ProgressEvent('error')); + // Provider 2 succeeds + req2.flush(MOCK_ICAL_DATA_2); + + tick(100); + freshStore.refreshState(); + tick(100); + + // Should have received data (either from cache fallback or provider 2) + expect(lastValue).toBeDefined(); + + sub.unsubscribe(); + discardPeriodicTasks(); + })); + + it('should handle all providers failing gracefully', fakeAsync(() => { + const provider1 = createMockProvider({ + id: 'provider-1', + icalUrl: 'https://provider1.com/calendar.ics', + }); + const provider2 = createMockProvider({ + id: 'provider-2', + icalUrl: 'https://provider2.com/calendar.ics', + }); + + store.overrideSelector(selectCalendarProviders, [provider1, provider2]); + store.refreshState(); + + let lastValue: any; + let errorOccurred = false; + const sub = service.icalEvents$.subscribe({ + next: (val) => { + lastValue = val; + }, + error: () => { + errorOccurred = true; + }, + }); + subscriptions.push(sub); + + tick(0); + + const req1 = httpMock.expectOne(provider1.icalUrl); + const req2 = httpMock.expectOne(provider2.icalUrl); + + req1.error(new ProgressEvent('error')); + req2.error(new ProgressEvent('error')); + + tick(100); + + // Should not error, should emit empty or cached data + expect(errorOccurred).toBe(false); + expect(lastValue).toBeDefined(); + + discardPeriodicTasks(); + })); + }); + + describe('timer behavior', () => { + it('should refresh data at configured interval', fakeAsync(() => { + const interval = 60000; // 1 minute + const mockProvider = createMockProvider({ + checkUpdatesEvery: interval, + }); + + store.overrideSelector(selectCalendarProviders, [mockProvider]); + store.refreshState(); + + const sub = service.icalEvents$.subscribe(() => {}); + subscriptions.push(sub); + + // Initial request + tick(0); + const req1 = httpMock.expectOne(mockProvider.icalUrl); + req1.flush(MOCK_ICAL_DATA); + + // Wait for interval + tick(interval); + const req2 = httpMock.expectOne(mockProvider.icalUrl); + req2.flush(MOCK_ICAL_DATA); + + // Wait for another interval + tick(interval); + const req3 = httpMock.expectOne(mockProvider.icalUrl); + req3.flush(MOCK_ICAL_DATA); + + discardPeriodicTasks(); + })); + + it('should use shortest interval among all providers', fakeAsync(() => { + const provider1 = createMockProvider({ + id: 'slow-provider', + icalUrl: 'https://slow.com/calendar.ics', + checkUpdatesEvery: 120000, // 2 minutes + }); + const provider2 = createMockProvider({ + id: 'fast-provider', + icalUrl: 'https://fast.com/calendar.ics', + checkUpdatesEvery: 60000, // 1 minute + }); + + store.overrideSelector(selectCalendarProviders, [provider1, provider2]); + store.refreshState(); + + const sub = service.icalEvents$.subscribe(() => {}); + subscriptions.push(sub); + + // Initial request + tick(0); + httpMock.expectOne(provider1.icalUrl).flush(MOCK_ICAL_DATA); + httpMock.expectOne(provider2.icalUrl).flush(MOCK_ICAL_DATA_2); + + // Wait for shortest interval (1 minute) + tick(60000); + + // Both should refresh at the shortest interval + httpMock.expectOne(provider1.icalUrl).flush(MOCK_ICAL_DATA); + httpMock.expectOne(provider2.icalUrl).flush(MOCK_ICAL_DATA_2); + + discardPeriodicTasks(); + })); + + it('should not make requests before interval elapses', fakeAsync(() => { + const interval = 60000; + const mockProvider = createMockProvider({ + checkUpdatesEvery: interval, + }); + + store.overrideSelector(selectCalendarProviders, [mockProvider]); + store.refreshState(); + + const sub = service.icalEvents$.subscribe(() => {}); + subscriptions.push(sub); + + tick(0); + const req = httpMock.expectOne(mockProvider.icalUrl); + req.flush(MOCK_ICAL_DATA); + + // Wait for less than interval + tick(30000); + + // Should not have any pending requests + httpMock.expectNone(mockProvider.icalUrl); + + discardPeriodicTasks(); + })); + }); + + describe('cache validation', () => { + it('should handle corrupted cache data gracefully', fakeAsync(() => { + localStorage.setItem('SUP_CAL_EVENTS_CACHE', 'not valid json'); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + CalendarIntegrationService, + provideMockStore({ + selectors: [ + { selector: selectCalendarProviders, value: [] }, + { selector: selectAllCalendarTaskEventIds, value: [] }, + ], + }), + { provide: SnackService, useValue: mockSnackService }, + ], + }); + + expect(() => TestBed.inject(CalendarIntegrationService)).not.toThrow(); + discardPeriodicTasks(); + })); + + it('should handle null cache gracefully', fakeAsync(() => { + localStorage.setItem('SUP_CAL_EVENTS_CACHE', 'null'); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + CalendarIntegrationService, + provideMockStore({ + selectors: [ + { selector: selectCalendarProviders, value: [] }, + { selector: selectAllCalendarTaskEventIds, value: [] }, + ], + }), + { provide: SnackService, useValue: mockSnackService }, + ], + }); + + const freshService = TestBed.inject(CalendarIntegrationService); + let emittedValue: unknown; + const sub = freshService.icalEvents$.pipe(take(1)).subscribe((val) => { + emittedValue = val; + }); + + tick(0); + expect(emittedValue).toEqual([]); + sub.unsubscribe(); + discardPeriodicTasks(); + })); + + it('should handle cache with missing items property', fakeAsync(() => { + const malformedCache = [{ notItems: [] }]; + localStorage.setItem('SUP_CAL_EVENTS_CACHE', JSON.stringify(malformedCache)); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + CalendarIntegrationService, + provideMockStore({ + selectors: [ + { selector: selectCalendarProviders, value: [] }, + { selector: selectAllCalendarTaskEventIds, value: [] }, + ], + }), + { provide: SnackService, useValue: mockSnackService }, + ], + }); + + // Should not throw when accessing cache + expect(() => TestBed.inject(CalendarIntegrationService)).not.toThrow(); + discardPeriodicTasks(); + })); + }); + + describe('requestEventsForSchedule$', () => { + it('should request events from now to one month ahead', fakeAsync(() => { + const mockProvider = createMockProvider(); + + const sub = service.requestEventsForSchedule$(mockProvider).subscribe(() => {}); + subscriptions.push(sub); + + const req = httpMock.expectOne(mockProvider.icalUrl); + req.flush(MOCK_ICAL_DATA); + + tick(0); + })); + + it('should forward errors when isForwardError is true', fakeAsync(() => { + const mockProvider = createMockProvider(); + + let errorThrown = false; + const sub = service.requestEventsForSchedule$(mockProvider, true).subscribe({ + error: () => { + errorThrown = true; + }, + }); + subscriptions.push(sub); + + const req = httpMock.expectOne(mockProvider.icalUrl); + req.error(new ProgressEvent('error')); + + tick(0); + expect(errorThrown).toBe(true); + })); + + it('should not forward errors when isForwardError is false', fakeAsync(() => { + const mockProvider = createMockProvider(); + + let errorThrown = false; + let result: unknown; + const sub = service.requestEventsForSchedule$(mockProvider, false).subscribe({ + next: (val) => { + result = val; + }, + error: () => { + errorThrown = true; + }, + }); + subscriptions.push(sub); + + const req = httpMock.expectOne(mockProvider.icalUrl); + req.error(new ProgressEvent('error')); + + tick(0); + expect(errorThrown).toBe(false); + expect(result).toEqual([]); + })); + }); + + describe('edge cases', () => { + it('should handle provider with undefined icalUrl', () => { + const provider = createMockProvider({ + icalUrl: undefined as unknown as string, + }); + + const interval = (service as any)._getMinRefreshInterval([provider]); + expect(interval).toBe(2 * 60 * 60 * 1000); // Default interval + }); + + it('should handle provider with null icalUrl', () => { + const provider = createMockProvider({ + icalUrl: null as unknown as string, + }); + + const interval = (service as any)._getMinRefreshInterval([provider]); + expect(interval).toBe(2 * 60 * 60 * 1000); // Default interval + }); + + it('should handle very short check interval', fakeAsync(() => { + const mockProvider = createMockProvider({ + checkUpdatesEvery: 1000, // 1 second + }); + + store.overrideSelector(selectCalendarProviders, [mockProvider]); + store.refreshState(); + + const sub = service.icalEvents$.subscribe(() => {}); + subscriptions.push(sub); + + tick(0); + httpMock.expectOne(mockProvider.icalUrl).flush(MOCK_ICAL_DATA); + + // Multiple rapid refreshes + for (let i = 0; i < 3; i++) { + tick(1000); + httpMock.expectOne(mockProvider.icalUrl).flush(MOCK_ICAL_DATA); + } + + discardPeriodicTasks(); + })); + + it('should handle skipCalendarEvent with empty string id', () => { + const event = { + id: '', + calProviderId: 'test-provider', + title: 'Test Event', + start: Date.now(), + duration: 3600000, + }; + + const beforeLength = service.skippedEventIds$.getValue().length; + service.skipCalendarEvent(event); + const afterLength = service.skippedEventIds$.getValue().length; + + // Should not add empty string + expect(afterLength).toBe(beforeLength); + }); + + it('should handle events at exactly current time', fakeAsync(() => { + const now = Date.now(); + const cachedData = [ + { + items: [ + { + id: 'current-event', + calProviderId: 'provider-1', + title: 'Current Event', + start: now, + duration: 3600000, + }, + ], + }, + ]; + localStorage.setItem('SUP_CAL_EVENTS_CACHE', JSON.stringify(cachedData)); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + CalendarIntegrationService, + provideMockStore({ + selectors: [ + { selector: selectCalendarProviders, value: [] }, + { selector: selectAllCalendarTaskEventIds, value: [] }, + ], + }), + { provide: SnackService, useValue: mockSnackService }, + ], + }); + + const freshService = TestBed.inject(CalendarIntegrationService); + + let emittedValue: any; + const sub = freshService.icalEvents$.pipe(take(1)).subscribe((val) => { + emittedValue = val; + }); + + tick(0); + // Current event should be included (start + duration >= now) + expect(emittedValue[0].items.length).toBe(1); + sub.unsubscribe(); + discardPeriodicTasks(); + })); + }); + + describe('performance', () => { + it('should not make duplicate requests for same provider', fakeAsync(() => { + const mockProvider = createMockProvider(); + store.overrideSelector(selectCalendarProviders, [mockProvider]); + store.refreshState(); + + // Multiple rapid subscriptions + const sub1 = service.icalEvents$.subscribe(() => {}); + const sub2 = service.icalEvents$.subscribe(() => {}); + const sub3 = service.icalEvents$.subscribe(() => {}); + subscriptions.push(sub1, sub2, sub3); + + tick(0); + + // Should only be ONE request thanks to shareReplay + const reqs = httpMock.match(mockProvider.icalUrl); + expect(reqs.length).toBe(1); + reqs[0].flush(MOCK_ICAL_DATA); + + discardPeriodicTasks(); + })); + + it('should handle rapid provider changes efficiently', fakeAsync(() => { + const provider1 = createMockProvider({ + id: 'p1', + icalUrl: 'https://p1.com/cal.ics', + }); + const provider2 = createMockProvider({ + id: 'p2', + icalUrl: 'https://p2.com/cal.ics', + }); + const provider3 = createMockProvider({ + id: 'p3', + icalUrl: 'https://p3.com/cal.ics', + }); + + const sub = service.icalEvents$.subscribe(() => {}); + subscriptions.push(sub); + + // Rapid provider changes + store.overrideSelector(selectCalendarProviders, [provider1]); + store.refreshState(); + tick(0); + + store.overrideSelector(selectCalendarProviders, [provider2]); + store.refreshState(); + tick(0); + + store.overrideSelector(selectCalendarProviders, [provider3]); + store.refreshState(); + tick(0); + + // Only the last provider should have a pending request (switchMap cancels previous) + // Note: Due to timing, we might see requests for earlier providers + const req = httpMock.expectOne(provider3.icalUrl); + req.flush(MOCK_ICAL_DATA); + + discardPeriodicTasks(); + })); + }); +}); diff --git a/src/app/features/calendar-integration/calendar-integration.service.ts b/src/app/features/calendar-integration/calendar-integration.service.ts index 4e5674310..547e2bc77 100644 --- a/src/app/features/calendar-integration/calendar-integration.service.ts +++ b/src/app/features/calendar-integration/calendar-integration.service.ts @@ -18,6 +18,7 @@ import { merge, Observable, of, + timer, } from 'rxjs'; import { T } from '../../t.const'; import { SnackService } from '../../core/snack/snack.service'; @@ -43,6 +44,7 @@ import { getCalendarEventIdCandidates, matchesAnyCalendarEventId, } from './get-calendar-event-id-candidates'; +import { getEffectiveCheckInterval } from '../issue/providers/calendar/calendar.const'; const ONE_MONTHS = 60 * 60 * 1000 * 24 * 31; @@ -60,77 +62,96 @@ export class CalendarIntegrationService { this._store.select(selectCalendarProviders).pipe( distinctUntilChanged(fastArrayCompare), switchMap((calendarProviders) => { - return calendarProviders && calendarProviders.length - ? forkJoin( - calendarProviders.map((calProvider) => { - if (!calProvider.isEnabled) { - return of({ - itemsForProvider: [] as CalendarIntegrationEvent[], - calProvider, - didError: false, - }); - } - - return this.requestEventsForSchedule$(calProvider, true).pipe( - first(), - map((itemsForProvider: CalendarIntegrationEvent[]) => ({ - itemsForProvider, - calProvider, - didError: false, - })), - catchError(() => - of({ - itemsForProvider: [] as CalendarIntegrationEvent[], - calProvider, - didError: true, - }), - ), - ); - }), - ).pipe( - switchMap((resultForProviders) => - combineLatest([ - this._store - .select(selectAllCalendarTaskEventIds) - .pipe(distinctUntilChanged(fastArrayCompare)), - this.skippedEventIds$.pipe(distinctUntilChanged(fastArrayCompare)), - ]).pipe( - // tap((val) => Log.log('selectAllCalendarTaskEventIds', val)), - map(([allCalendarTaskEventIds, skippedEventIds]) => { - const cachedByProviderId = this._groupCachedEventsByProvider( - this._getCalProviderFromCache(), - ); - return resultForProviders.map( - ({ itemsForProvider, calProvider, didError }) => { - // Fall back to cached data when the live fetch errored so offline mode keeps showing events. - const sourceItems: ScheduleFromCalendarEvent[] = didError - ? (cachedByProviderId.get(calProvider.id) ?? []) - : (itemsForProvider as ScheduleFromCalendarEvent[]); - return { - // filter out items already added as tasks - items: sourceItems.filter( - (calEv) => - !matchesAnyCalendarEventId( - calEv, - allCalendarTaskEventIds, - ) && !matchesAnyCalendarEventId(calEv, skippedEventIds), - ), - } as ScheduleCalendarMapEntry; - }, - ); - }), - ), - ), - // tap((v) => Log.log('icalEvents$ final', v)), - tap((val) => { - saveToRealLs(LS.CAL_EVENTS_CACHE, val); - }), - ) - : (of([]) as Observable); + if (!calendarProviders?.length) { + return of([]) as Observable; + } + // Calculate the minimum refresh interval from all enabled providers + const minInterval = this._getMinRefreshInterval(calendarProviders); + // Use timer to periodically refresh calendar data + return timer(0, minInterval).pipe( + switchMap(() => this._fetchAllProviders(calendarProviders)), + ); }), - shareReplay({ bufferSize: 1, refCount: true }), ), - ); + ).pipe(shareReplay({ bufferSize: 1, refCount: true })); + + private _fetchAllProviders( + calendarProviders: IssueProviderCalendar[], + ): Observable { + return forkJoin( + calendarProviders.map((calProvider) => { + if (!calProvider.isEnabled) { + return of({ + itemsForProvider: [] as CalendarIntegrationEvent[], + calProvider, + didError: false, + }); + } + + return this.requestEventsForSchedule$(calProvider, true).pipe( + first(), + map((itemsForProvider: CalendarIntegrationEvent[]) => ({ + itemsForProvider, + calProvider, + didError: false, + })), + catchError(() => + of({ + itemsForProvider: [] as CalendarIntegrationEvent[], + calProvider, + didError: true, + }), + ), + ); + }), + ).pipe( + switchMap((resultForProviders) => + combineLatest([ + this._store + .select(selectAllCalendarTaskEventIds) + .pipe(distinctUntilChanged(fastArrayCompare)), + this.skippedEventIds$.pipe(distinctUntilChanged(fastArrayCompare)), + ]).pipe( + map(([allCalendarTaskEventIds, skippedEventIds]) => { + const cachedByProviderId = this._groupCachedEventsByProvider( + this._getCalProviderFromCache(), + ); + return resultForProviders.map( + ({ itemsForProvider, calProvider, didError }) => { + // Fall back to cached data when the live fetch errored so offline mode keeps showing events. + const sourceItems: ScheduleFromCalendarEvent[] = didError + ? (cachedByProviderId.get(calProvider.id) ?? []) + : (itemsForProvider as ScheduleFromCalendarEvent[]); + return { + // filter out items already added as tasks + items: sourceItems.filter( + (calEv) => + !matchesAnyCalendarEventId(calEv, allCalendarTaskEventIds) && + !matchesAnyCalendarEventId(calEv, skippedEventIds), + ), + } as ScheduleCalendarMapEntry; + }, + ); + }), + ), + ), + tap((val) => { + saveToRealLs(LS.CAL_EVENTS_CACHE, val); + }), + ); + } + + /** + * Calculate the minimum refresh interval from all enabled providers. + * Uses getEffectiveCheckInterval which returns 5 min for file:// URLs. + */ + private _getMinRefreshInterval(calendarProviders: IssueProviderCalendar[]): number { + const enabledProviders = calendarProviders.filter((p) => p.isEnabled && p.icalUrl); + if (!enabledProviders.length) { + return 2 * 60 * 60 * 1000; // Default 2 hours + } + return Math.min(...enabledProviders.map((p) => getEffectiveCheckInterval(p))); + } public readonly skippedEventIds$ = new BehaviorSubject([]); diff --git a/src/app/features/calendar-integration/store/calendar-integration.effects.ts b/src/app/features/calendar-integration/store/calendar-integration.effects.ts index e232578f6..b175f17e3 100644 --- a/src/app/features/calendar-integration/store/calendar-integration.effects.ts +++ b/src/app/features/calendar-integration/store/calendar-integration.effects.ts @@ -26,6 +26,7 @@ import { matchesAnyCalendarEventId, shareCalendarEventId, } from '../get-calendar-event-id-candidates'; +import { getEffectiveCheckInterval } from '../../issue/providers/calendar/calendar.const'; const CHECK_TO_SHOW_INTERVAL = 60 * 1000; @@ -62,8 +63,7 @@ export class CalendarIntegrationEffects { return forkJoin( activatedProviders.map((calProvider) => - timer(0, calProvider.checkUpdatesEvery).pipe( - // timer(0, 10000).pipe( + timer(0, getEffectiveCheckInterval(calProvider)).pipe( // tap(() => Log.log('REQUEST CALENDAR', calProvider)), switchMap(() => this._calendarIntegrationService.requestEvents$(calProvider), diff --git a/src/app/features/issue/providers/calendar/calendar.const.spec.ts b/src/app/features/issue/providers/calendar/calendar.const.spec.ts new file mode 100644 index 000000000..bde21ee58 --- /dev/null +++ b/src/app/features/issue/providers/calendar/calendar.const.spec.ts @@ -0,0 +1,109 @@ +import { + getEffectiveCheckInterval, + LOCAL_FILE_CHECK_INTERVAL, + DEFAULT_CALENDAR_CFG, +} from './calendar.const'; +import { IssueProviderCalendar } from '../../issue.model'; + +describe('calendar.const', () => { + describe('LOCAL_FILE_CHECK_INTERVAL', () => { + it('should be 5 minutes in milliseconds', () => { + expect(LOCAL_FILE_CHECK_INTERVAL).toBe(5 * 60 * 1000); + }); + }); + + describe('getEffectiveCheckInterval', () => { + const createMockProvider = ( + overrides: Partial = {}, + ): IssueProviderCalendar => + ({ + id: 'test-provider', + isEnabled: true, + issueProviderKey: 'ICAL', + icalUrl: 'https://example.com/calendar.ics', + checkUpdatesEvery: DEFAULT_CALENDAR_CFG.checkUpdatesEvery, + showBannerBeforeThreshold: DEFAULT_CALENDAR_CFG.showBannerBeforeThreshold, + isAutoImportForCurrentDay: false, + isDisabledForWebApp: false, + ...overrides, + }) as IssueProviderCalendar; + + it('should return LOCAL_FILE_CHECK_INTERVAL for file:// URLs', () => { + const provider = createMockProvider({ + icalUrl: 'file:///home/user/calendar.ics', + }); + + expect(getEffectiveCheckInterval(provider)).toBe(LOCAL_FILE_CHECK_INTERVAL); + }); + + it('should return LOCAL_FILE_CHECK_INTERVAL for file:// URLs with different paths', () => { + const provider = createMockProvider({ + icalUrl: 'file:///C:/Users/test/calendar.ics', + }); + + expect(getEffectiveCheckInterval(provider)).toBe(LOCAL_FILE_CHECK_INTERVAL); + }); + + it('should return checkUpdatesEvery for http:// URLs', () => { + const customInterval = 30 * 60 * 1000; // 30 minutes + const provider = createMockProvider({ + icalUrl: 'http://example.com/calendar.ics', + checkUpdatesEvery: customInterval, + }); + + expect(getEffectiveCheckInterval(provider)).toBe(customInterval); + }); + + it('should return checkUpdatesEvery for https:// URLs', () => { + const customInterval = 60 * 60 * 1000; // 1 hour + const provider = createMockProvider({ + icalUrl: 'https://calendar.google.com/calendar.ics', + checkUpdatesEvery: customInterval, + }); + + expect(getEffectiveCheckInterval(provider)).toBe(customInterval); + }); + + it('should return default checkUpdatesEvery when no custom interval set', () => { + const provider = createMockProvider({ + icalUrl: 'https://example.com/calendar.ics', + }); + + expect(getEffectiveCheckInterval(provider)).toBe( + DEFAULT_CALENDAR_CFG.checkUpdatesEvery, + ); + }); + + it('should handle undefined icalUrl gracefully', () => { + const provider = createMockProvider({ + icalUrl: undefined as unknown as string, + }); + + expect(getEffectiveCheckInterval(provider)).toBe( + DEFAULT_CALENDAR_CFG.checkUpdatesEvery, + ); + }); + + it('should handle empty string icalUrl', () => { + const provider = createMockProvider({ + icalUrl: '', + }); + + expect(getEffectiveCheckInterval(provider)).toBe( + DEFAULT_CALENDAR_CFG.checkUpdatesEvery, + ); + }); + + it('should be case-sensitive for file:// protocol', () => { + // file:// should be lowercase per URI spec + const provider = createMockProvider({ + icalUrl: 'FILE:///home/user/calendar.ics', + }); + + // FILE:// doesn't match file://, so should use default interval + expect(getEffectiveCheckInterval(provider)).toBe( + DEFAULT_CALENDAR_CFG.checkUpdatesEvery, + ); + }); + }); +}); diff --git a/src/app/features/issue/providers/calendar/calendar.const.ts b/src/app/features/issue/providers/calendar/calendar.const.ts index 4dc285aeb..2f7bdab0f 100644 --- a/src/app/features/issue/providers/calendar/calendar.const.ts +++ b/src/app/features/issue/providers/calendar/calendar.const.ts @@ -6,6 +6,16 @@ import { ISSUE_PROVIDER_FF_DEFAULT_PROJECT } from '../../common-issue-form-stuff import { IS_ELECTRON } from '../../../../app.constants'; import { IssueLog } from '../../../../core/log'; +// 5 minutes for local file:// URLs (faster polling for local calendars) +export const LOCAL_FILE_CHECK_INTERVAL = 5 * 60 * 1000; + +export const getEffectiveCheckInterval = (calProvider: IssueProviderCalendar): number => { + if (calProvider.icalUrl?.startsWith('file://')) { + return LOCAL_FILE_CHECK_INTERVAL; + } + return calProvider.checkUpdatesEvery; +}; + export const DEFAULT_CALENDAR_CFG: CalendarProviderCfg = { isEnabled: false, icalUrl: '',