diff --git a/apps/calendar/apps/web/src/lib/api/sync.test.ts b/apps/calendar/apps/web/src/lib/api/sync.test.ts new file mode 100644 index 000000000..fd4be5d95 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/api/sync.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('./client', () => ({ + fetchApi: vi.fn(), +})); + +import { fetchApi } from './client'; +import { + getExternalCalendars, + connectExternalCalendar, + updateExternalCalendar, + disconnectExternalCalendar, + triggerSync, + discoverCalDav, + getGoogleAuthUrl, + getICalExportUrl, +} from './sync'; + +const mockFetchApi = vi.mocked(fetchApi); + +describe('sync API client', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getExternalCalendars', () => { + it('should fetch external calendars', async () => { + mockFetchApi.mockResolvedValue({ + data: { calendars: [{ id: 'ext-1', name: 'Test' }] }, + error: null, + }); + const result = await getExternalCalendars(); + expect(mockFetchApi).toHaveBeenCalledWith('/sync/external'); + expect(result.data).toHaveLength(1); + expect(result.data![0].name).toBe('Test'); + }); + + it('should return error on failure', async () => { + mockFetchApi.mockResolvedValue({ + data: null, + error: { message: 'Not found', code: 'NOT_FOUND', status: 404 }, + }); + const result = await getExternalCalendars(); + expect(result.data).toBeNull(); + expect(result.error).toBeTruthy(); + }); + }); + + describe('connectExternalCalendar', () => { + it('should POST to /sync/external', async () => { + mockFetchApi.mockResolvedValue({ + data: { calendar: { id: 'ext-new', name: 'New Cal' } }, + error: null, + }); + const result = await connectExternalCalendar({ + name: 'New Cal', + provider: 'ical_url', + calendarUrl: 'https://example.com/cal.ics', + }); + expect(mockFetchApi).toHaveBeenCalledWith('/sync/external', { + method: 'POST', + body: { name: 'New Cal', provider: 'ical_url', calendarUrl: 'https://example.com/cal.ics' }, + }); + expect(result.data!.name).toBe('New Cal'); + }); + }); + + describe('disconnectExternalCalendar', () => { + it('should DELETE /sync/external/:id', async () => { + mockFetchApi.mockResolvedValue({ data: { success: true }, error: null }); + await disconnectExternalCalendar('ext-1'); + expect(mockFetchApi).toHaveBeenCalledWith('/sync/external/ext-1', { method: 'DELETE' }); + }); + }); + + describe('triggerSync', () => { + it('should POST to /sync/external/:id/sync', async () => { + mockFetchApi.mockResolvedValue({ data: { success: true, eventsImported: 10 }, error: null }); + const result = await triggerSync('ext-1'); + expect(mockFetchApi).toHaveBeenCalledWith('/sync/external/ext-1/sync', { method: 'POST' }); + expect(result.data!.eventsImported).toBe(10); + }); + }); + + describe('discoverCalDav', () => { + it('should POST credentials to /sync/caldav/discover', async () => { + mockFetchApi.mockResolvedValue({ + data: { calendars: [{ url: 'https://cal.example.com/personal', name: 'Personal' }] }, + error: null, + }); + const result = await discoverCalDav('https://cal.example.com', 'user@example.com', 'pass'); + expect(mockFetchApi).toHaveBeenCalledWith('/sync/caldav/discover', { + method: 'POST', + body: { + serverUrl: 'https://cal.example.com', + username: 'user@example.com', + password: 'pass', + }, + }); + expect(result.data).toHaveLength(1); + }); + }); + + describe('getGoogleAuthUrl', () => { + it('should GET /sync/google/auth-url', async () => { + mockFetchApi.mockResolvedValue({ + data: { url: 'https://accounts.google.com/auth' }, + error: null, + }); + const result = await getGoogleAuthUrl(); + expect(mockFetchApi).toHaveBeenCalledWith('/sync/google/auth-url'); + expect(result.data).toContain('google'); + }); + }); + + describe('getICalExportUrl', () => { + it('should return the correct export URL', () => { + expect(getICalExportUrl('cal-123')).toBe('/api/v1/calendars/cal-123/export.ics'); + }); + }); +}); diff --git a/apps/calendar/apps/web/src/lib/stores/events-recurrence.test.ts b/apps/calendar/apps/web/src/lib/stores/events-recurrence.test.ts new file mode 100644 index 000000000..a147dcb47 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/events-recurrence.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { CalendarEvent } from '@calendar/shared'; +import type { PaginationMeta } from '$lib/api/events'; + +const defaultPagination: PaginationMeta = { offset: 0, count: 0 }; + +vi.mock('$lib/api/events', () => ({ + getEvents: vi.fn(), + createEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), +})); + +vi.mock('@manacore/shared-ui', () => ({ + toastStore: { error: vi.fn(), success: vi.fn() }, +})); + +import * as api from '$lib/api/events'; +import { eventsStore } from './events.svelte'; + +const mockGetEvents = vi.mocked(api.getEvents); +const mockUpdateEvent = vi.mocked(api.updateEvent); +const mockDeleteEvent = vi.mocked(api.deleteEvent); + +function makeEvent(overrides: Partial = {}): CalendarEvent { + return { + id: 'evt-1', + calendarId: 'cal-1', + userId: 'user-1', + title: 'Test', + description: null, + location: null, + startTime: '2026-03-15T10:00:00', + endTime: '2026-03-15T11:00:00', + isAllDay: false, + timezone: 'Europe/Berlin', + recurrenceRule: null, + recurrenceEndDate: null, + recurrenceExceptions: null, + parentEventId: null, + color: null, + status: 'confirmed', + externalId: null, + metadata: null, + createdAt: '2026-03-01T00:00:00', + updatedAt: '2026-03-01T00:00:00', + ...overrides, + }; +} + +describe('eventsStore - recurrence', () => { + beforeEach(() => { + vi.clearAllMocks(); + eventsStore.clear(); + eventsStore.clearDraftEvent(); + }); + + describe('expansion', () => { + it('should expand daily recurring event', async () => { + mockGetEvents.mockResolvedValue({ + data: [ + makeEvent({ + id: 'r1', + startTime: '2026-03-01T09:00:00', + endTime: '2026-03-01T09:30:00', + recurrenceRule: 'FREQ=DAILY', + }), + ], + pagination: defaultPagination, + error: null, + }); + await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-07')); + const events = eventsStore.events; + expect(events.length).toBeGreaterThanOrEqual(6); + for (const e of events) { + expect(e.id).toContain('r1__recurrence__'); + expect(e.parentEventId).toBe('r1'); + } + }); + + it('should preserve event duration in occurrences', async () => { + mockGetEvents.mockResolvedValue({ + data: [ + makeEvent({ + id: 'w1', + startTime: '2026-03-02T14:00:00', + endTime: '2026-03-02T15:00:00', + recurrenceRule: 'FREQ=WEEKLY', + }), + ], + pagination: defaultPagination, + error: null, + }); + await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31')); + for (const e of eventsStore.events) { + const dur = new Date(e.endTime).getTime() - new Date(e.startTime).getTime(); + expect(dur).toBe(3600000); + } + }); + + it('should respect exceptions', async () => { + mockGetEvents.mockResolvedValue({ + data: [ + makeEvent({ + id: 'exc', + startTime: '2026-03-01T09:00:00', + endTime: '2026-03-01T09:30:00', + recurrenceRule: 'FREQ=DAILY', + recurrenceExceptions: ['2026-03-03', '2026-03-05'], + }), + ], + pagination: defaultPagination, + error: null, + }); + await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-07')); + const dates = eventsStore.events.map((e) => e.id.split('__recurrence__')[1]); + expect(dates).not.toContain('2026-03-03'); + expect(dates).not.toContain('2026-03-05'); + expect(dates).toContain('2026-03-01'); + }); + + it('should not expand non-recurring events', async () => { + mockGetEvents.mockResolvedValue({ + data: [makeEvent({ id: 'normal' })], + pagination: defaultPagination, + error: null, + }); + await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31')); + expect(eventsStore.events).toHaveLength(1); + expect(eventsStore.events[0].id).toBe('normal'); + }); + }); + + describe('helpers', () => { + it('isRecurrenceOccurrence', () => { + expect(eventsStore.isRecurrenceOccurrence('evt__recurrence__2026-03-15')).toBe(true); + expect(eventsStore.isRecurrenceOccurrence('evt-1')).toBe(false); + }); + + it('getParentEventId', () => { + expect(eventsStore.getParentEventId('evt-1__recurrence__2026-03-15')).toBe('evt-1'); + expect(eventsStore.getParentEventId('evt-1')).toBe('evt-1'); + }); + }); + + describe('deleteRecurrenceOccurrence', () => { + it('should add exception and remove from local state', async () => { + mockGetEvents.mockResolvedValue({ + data: [ + makeEvent({ + id: 'r1', + startTime: '2026-03-01T09:00:00', + endTime: '2026-03-01T09:30:00', + recurrenceRule: 'FREQ=DAILY', + }), + ], + pagination: defaultPagination, + error: null, + }); + await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-07')); + mockUpdateEvent.mockResolvedValue({ data: makeEvent({ id: 'r1' }), error: null }); + await eventsStore.deleteRecurrenceOccurrence('r1__recurrence__2026-03-03'); + expect(mockUpdateEvent).toHaveBeenCalledWith('r1', { recurrenceExceptions: ['2026-03-03'] }); + expect(eventsStore.events.map((e) => e.id)).not.toContain('r1__recurrence__2026-03-03'); + }); + }); + + describe('deleteRecurrenceSeries', () => { + it('should delete parent event', async () => { + mockGetEvents.mockResolvedValue({ + data: [ + makeEvent({ + id: 'r1', + startTime: '2026-03-01T09:00:00', + endTime: '2026-03-01T09:30:00', + recurrenceRule: 'FREQ=DAILY', + }), + ], + pagination: defaultPagination, + error: null, + }); + await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-07')); + mockDeleteEvent.mockResolvedValue({ data: null, error: null }); + await eventsStore.deleteRecurrenceSeries('r1__recurrence__2026-03-03'); + expect(mockDeleteEvent).toHaveBeenCalledWith('r1'); + }); + }); +}); diff --git a/apps/calendar/apps/web/src/lib/stores/external-calendars.test.ts b/apps/calendar/apps/web/src/lib/stores/external-calendars.test.ts new file mode 100644 index 000000000..680f254f5 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/external-calendars.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/api/sync', () => ({ + getExternalCalendars: vi.fn(), + connectExternalCalendar: vi.fn(), + updateExternalCalendar: vi.fn(), + disconnectExternalCalendar: vi.fn(), + triggerSync: vi.fn(), + discoverCalDav: vi.fn(), + getGoogleAuthUrl: vi.fn(), +})); + +vi.mock('@manacore/shared-ui', () => ({ + toastStore: { error: vi.fn(), success: vi.fn() }, +})); + +import * as api from '$lib/api/sync'; +import { externalCalendarsStore } from './external-calendars.svelte'; +import type { ExternalCalendar } from '@calendar/shared'; + +const mockFetch = vi.mocked(api.getExternalCalendars); +const mockConnect = vi.mocked(api.connectExternalCalendar); +const mockUpdate = vi.mocked(api.updateExternalCalendar); +const mockDisconnect = vi.mocked(api.disconnectExternalCalendar); +const mockSync = vi.mocked(api.triggerSync); + +function makeCal(overrides: Partial = {}): ExternalCalendar { + return { + id: 'ext-1', + userId: 'user-1', + name: 'Google', + provider: 'google', + calendarUrl: 'https://google.com/cal', + syncEnabled: true, + syncDirection: 'both', + syncInterval: 15, + lastSyncAt: null, + lastSyncError: null, + color: '#4285f4', + isVisible: true, + providerData: null, + createdAt: '2026-03-01', + updatedAt: '2026-03-01', + ...overrides, + }; +} + +describe('externalCalendarsStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + externalCalendarsStore.clear(); + }); + + it('should load calendars', async () => { + mockFetch.mockResolvedValue({ + data: [makeCal({ id: 'ext-1' }), makeCal({ id: 'ext-2' })], + error: null, + }); + await externalCalendarsStore.fetchCalendars(); + expect(externalCalendarsStore.calendars).toHaveLength(2); + expect(externalCalendarsStore.loading).toBe(false); + }); + + it('should set error on fetch failure', async () => { + mockFetch.mockResolvedValue({ + data: null, + error: { message: 'fail', code: 'ERR', status: 500 }, + }); + await externalCalendarsStore.fetchCalendars(); + expect(externalCalendarsStore.error).toBe('fail'); + }); + + it('should add calendar on connect', async () => { + mockConnect.mockResolvedValue({ data: makeCal({ id: 'new' }), error: null }); + await externalCalendarsStore.connect({ name: 'X', provider: 'caldav', calendarUrl: 'url' }); + expect(externalCalendarsStore.calendars).toHaveLength(1); + }); + + it('should remove calendar on disconnect', async () => { + mockFetch.mockResolvedValue({ data: [makeCal()], error: null }); + await externalCalendarsStore.fetchCalendars(); + mockDisconnect.mockResolvedValue({ data: { success: true }, error: null }); + await externalCalendarsStore.disconnect('ext-1'); + expect(externalCalendarsStore.calendars).toHaveLength(0); + }); + + it('should update calendar', async () => { + mockFetch.mockResolvedValue({ data: [makeCal({ syncEnabled: true })], error: null }); + await externalCalendarsStore.fetchCalendars(); + mockUpdate.mockResolvedValue({ data: makeCal({ syncEnabled: false }), error: null }); + await externalCalendarsStore.update('ext-1', { syncEnabled: false }); + expect(externalCalendarsStore.calendars[0].syncEnabled).toBe(false); + }); + + it('should update lastSyncAt on sync success', async () => { + mockFetch.mockResolvedValue({ data: [makeCal({ lastSyncAt: null })], error: null }); + await externalCalendarsStore.fetchCalendars(); + mockSync.mockResolvedValue({ data: { success: true }, error: null }); + await externalCalendarsStore.triggerSync('ext-1'); + expect(externalCalendarsStore.calendars[0].lastSyncAt).not.toBeNull(); + expect(externalCalendarsStore.isSyncing('ext-1')).toBe(false); + }); + + it('should set error on sync failure', async () => { + mockFetch.mockResolvedValue({ data: [makeCal()], error: null }); + await externalCalendarsStore.fetchCalendars(); + mockSync.mockResolvedValue({ + data: null, + error: { message: 'Timeout', code: 'T', status: 504 }, + }); + await externalCalendarsStore.triggerSync('ext-1'); + expect(externalCalendarsStore.calendars[0].lastSyncError).toBe('Timeout'); + }); + + it('should find by ID', async () => { + mockFetch.mockResolvedValue({ data: [makeCal({ name: 'Found' })], error: null }); + await externalCalendarsStore.fetchCalendars(); + expect(externalCalendarsStore.getById('ext-1')?.name).toBe('Found'); + expect(externalCalendarsStore.getById('nope')).toBeUndefined(); + }); +});