Files
webmail/lib/__tests__/calendar-subscription-sync.test.ts
T

223 lines
8.8 KiB
TypeScript

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> = {}): 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']);
});
});