import { describe, it, expect } from 'vitest'; import { mergeSyncedSubscriptions, mergeStoredSubscriptions, parseSyncedSubscriptions, parseSubscriptionTombstones, SUBSCRIPTION_TOMBSTONE_TTL_MS, } from '../calendar-subscription-sync'; import type { ICalSubscription } from '@/stores/calendar-store'; function makeSubscription(overrides: Partial = {}): ICalSubscription { return { id: 'sub-test-id', url: 'https://example.com/calendar.ics', calendarId: 'cal-123', name: 'Test Subscription', color: '#3b82f6', refreshInterval: 60, lastRefreshed: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', ...overrides, }; } const NOW = new Date('2026-03-01T00:00:00Z'); describe('parseSyncedSubscriptions', () => { it('returns null for non-array values (pre-sync build)', () => { expect(parseSyncedSubscriptions(undefined)).toBeNull(); expect(parseSyncedSubscriptions({ not: 'an array' })).toBeNull(); }); it('preserves valid subscriptions and normalizes webcal URLs', () => { const result = parseSyncedSubscriptions([ makeSubscription({ id: 'sub-1', url: 'webcal://example.com/feed.ics' }), ]); expect(result).toHaveLength(1); expect(result![0].id).toBe('sub-1'); expect(result![0].url).toBe('https://example.com/feed.ics'); expect(result![0].updatedAt).toBe('2026-01-01T00:00:00Z'); }); it('keeps the owning login so another device only acts through it', () => { const owner = 'https://mail.example.com|alice@example.com'; const [parsed] = parseSyncedSubscriptions([makeSubscription({ owner })])!; expect(parsed.owner).toBe(owner); const [unowned] = parseSyncedSubscriptions([{ ...makeSubscription(), owner: 42 }])!; expect(unowned.owner).toBeUndefined(); }); it('drops entries without required fields or duplicate ids', () => { const result = parseSyncedSubscriptions([ makeSubscription({ id: 'a' }), makeSubscription({ id: 'a', name: 'Duplicate' }), { ...makeSubscription(), id: undefined }, { ...makeSubscription(), url: '' }, { ...makeSubscription(), url: 'ftp://example.com/unsupported.ics' }, { ...makeSubscription(), calendarId: '' }, { ...makeSubscription(), name: ' ' }, 'invalid string entry', ]); expect(result!.map((s) => s.id)).toEqual(['a']); }); it('normalizes non-finite or negative refresh intervals to default (60m)', () => { const result = parseSyncedSubscriptions([ makeSubscription({ id: 's1', refreshInterval: -10 }), makeSubscription({ id: 's2', refreshInterval: NaN as unknown as number }), makeSubscription({ id: 's3', refreshInterval: Infinity as unknown as number }), makeSubscription({ id: 's4', refreshInterval: 0 }), ]); expect(result?.every((s) => s.refreshInterval === 60)).toBe(true); }); }); describe('parseSubscriptionTombstones', () => { it('returns empty record for non-record values', () => { expect(parseSubscriptionTombstones(undefined)).toEqual({}); expect(parseSubscriptionTombstones(['a'])).toEqual({}); }); it('drops invalid date values', () => { expect( parseSubscriptionTombstones({ a: '2026-01-02T00:00:00Z', b: 'invalid', c: 123 }) ).toEqual({ a: '2026-01-02T00:00:00Z' }); }); }); describe('mergeSyncedSubscriptions', () => { it('adds subscriptions present in incoming state', () => { const merged = mergeSyncedSubscriptions( { icalSubscriptions: [makeSubscription({ id: 'local' })], deletedSubscriptionIds: {} }, { icalSubscriptions: [makeSubscription({ id: 'remote' })], deletedSubscriptionIds: {} }, NOW ); expect(merged.icalSubscriptions.map((s) => s.id).sort()).toEqual(['local', 'remote']); }); it('preserves subscriptions from multiple distinct accounts during merge', () => { const merged = mergeSyncedSubscriptions( { icalSubscriptions: [makeSubscription({ id: 'sub-1', accountId: 'acc-1' })], deletedSubscriptionIds: {} }, { icalSubscriptions: [makeSubscription({ id: 'sub-2', accountId: 'acc-2' })], deletedSubscriptionIds: {} }, NOW ); expect(merged.icalSubscriptions).toHaveLength(2); expect(merged.icalSubscriptions.map((s) => s.accountId)).toEqual(['acc-1', 'acc-2']); }); it('keeps newer copy per id, preferring local on tie', () => { const merged = mergeSyncedSubscriptions( { icalSubscriptions: [ makeSubscription({ id: 'a', name: 'Local newer', updatedAt: '2026-02-01T00:00:00Z' }), makeSubscription({ id: 'b', name: 'Local tie' }), ], deletedSubscriptionIds: {}, }, { icalSubscriptions: [ makeSubscription({ id: 'a', name: 'Remote older', updatedAt: '2026-01-15T00:00:00Z' }), makeSubscription({ id: 'b', name: 'Remote tie' }), ], deletedSubscriptionIds: {}, }, NOW ); const byId = Object.fromEntries(merged.icalSubscriptions.map((s) => [s.id, s.name])); expect(byId).toEqual({ a: 'Local newer', b: 'Local tie' }); }); it('applies incoming tombstone to local subscription', () => { const merged = mergeSyncedSubscriptions( { icalSubscriptions: [makeSubscription({ id: 'a' })], deletedSubscriptionIds: {} }, { icalSubscriptions: [], deletedSubscriptionIds: { a: '2026-02-01T00:00:00Z' } }, NOW ); expect(merged.icalSubscriptions).toEqual([]); expect(merged.deletedSubscriptionIds).toEqual({ a: '2026-02-01T00:00:00Z' }); }); it('tombstone wins over subscription on exact timestamp tie (defensive deletion)', () => { const timestamp = '2026-02-01T00:00:00.000Z'; const merged = mergeSyncedSubscriptions( { icalSubscriptions: [makeSubscription({ id: 'a', updatedAt: timestamp })], deletedSubscriptionIds: {} }, { icalSubscriptions: [], deletedSubscriptionIds: { a: timestamp } }, NOW ); expect(merged.icalSubscriptions).toEqual([]); expect(merged.deletedSubscriptionIds).toEqual({ a: timestamp }); }); it('resurrects subscription edited after its deletion and clears tombstone', () => { const merged = mergeSyncedSubscriptions( { icalSubscriptions: [makeSubscription({ id: 'a', updatedAt: '2026-03-01T00:00:00Z' })], deletedSubscriptionIds: {}, }, { icalSubscriptions: [], deletedSubscriptionIds: { a: '2026-02-01T00:00:00Z' } }, NOW ); expect(merged.icalSubscriptions.map((s) => s.id)).toEqual(['a']); expect(merged.deletedSubscriptionIds).toEqual({}); }); it('prunes tombstones older than TTL', () => { const expired = new Date(NOW.getTime() - SUBSCRIPTION_TOMBSTONE_TTL_MS - 1000).toISOString(); const merged = mergeSyncedSubscriptions( { icalSubscriptions: [], deletedSubscriptionIds: { old: expired } }, { icalSubscriptions: [], deletedSubscriptionIds: {} }, NOW ); expect(merged.deletedSubscriptionIds).toEqual({}); }); }); describe('legacy subscriptions without updatedAt', () => { it('stay deleted when another device removed them', () => { const legacy = { ...makeSubscription({ id: 'old', lastRefreshed: '2026-02-20T00:00:00Z' }), updatedAt: undefined }; const parsed = parseSyncedSubscriptions([legacy])!; const merged = mergeSyncedSubscriptions( { icalSubscriptions: [], deletedSubscriptionIds: { old: '2026-02-10T00:00:00Z' } }, { icalSubscriptions: parsed, deletedSubscriptionIds: {} }, NOW, ); expect(merged.icalSubscriptions).toEqual([]); expect(merged.deletedSubscriptionIds).toEqual({ old: '2026-02-10T00:00:00Z' }); }); it('drops URLs that only look like http(s)', () => { expect(parseSyncedSubscriptions([makeSubscription({ url: 'https://' })])).toEqual([]); }); }); describe('mergeStoredSubscriptions', () => { const stored = { icalSubscriptions: [makeSubscription({ id: 'a' }), makeSubscription({ id: 'gone' })], deletedSubscriptionIds: {}, }; it('keeps a subscription another device added when a stale client pushes', () => { const saved = mergeStoredSubscriptions(stored, { fontSize: 'large', icalSubscriptions: [makeSubscription({ id: 'gone' })], deletedSubscriptionIds: {}, }, NOW); expect((saved.icalSubscriptions as ICalSubscription[]).map((s) => s.id).sort()).toEqual(['a', 'gone']); expect(saved.fontSize).toBe('large'); }); it('applies a deletion the push carries', () => { const saved = mergeStoredSubscriptions(stored, { icalSubscriptions: [makeSubscription({ id: 'a' })], deletedSubscriptionIds: { gone: '2026-02-01T00:00:00Z' }, }, NOW); expect((saved.icalSubscriptions as ICalSubscription[]).map((s) => s.id)).toEqual(['a']); }); it('keeps stored subscriptions when the push has none', () => { const saved = mergeStoredSubscriptions(stored, { fontSize: 'large' }, NOW); expect((saved.icalSubscriptions as ICalSubscription[]).map((s) => s.id).sort()).toEqual(['a', 'gone']); }); });