mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
223 lines
8.8 KiB
TypeScript
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']);
|
|
});
|
|
});
|