mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
test(calendar): add tests for sync API, recurrence store, and external calendars
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7d2eb335b0
commit
8537d7c691
3 changed files with 430 additions and 0 deletions
121
apps/calendar/apps/web/src/lib/api/sync.test.ts
Normal file
121
apps/calendar/apps/web/src/lib/api/sync.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
188
apps/calendar/apps/web/src/lib/stores/events-recurrence.test.ts
Normal file
188
apps/calendar/apps/web/src/lib/stores/events-recurrence.test.ts
Normal file
|
|
@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
121
apps/calendar/apps/web/src/lib/stores/external-calendars.test.ts
Normal file
121
apps/calendar/apps/web/src/lib/stores/external-calendars.test.ts
Normal file
|
|
@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue