style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
+45 -45
View File
@@ -9,72 +9,72 @@
* - Stored payload shape
*/
import { describe, it, expect, beforeAll } from 'vitest'
import { describe, it, expect, beforeAll } from 'vitest';
// Set a fixed 32-byte (64-char hex) key before importing the module
const TEST_KEY = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2'
const TEST_KEY = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2';
beforeAll(() => {
process.env.APP_PASSWORD_ENCRYPTION_KEY = TEST_KEY
})
process.env.APP_PASSWORD_ENCRYPTION_KEY = TEST_KEY;
});
// Dynamic import so env is set before module-level KEY evaluation
async function getCrypto() {
return import('../../src/broker/crypto.js')
return import('../../src/broker/crypto.js');
}
describe('encryptPassword / decryptPassword', () => {
it('roundtrip: decrypt(encrypt(plaintext)) === plaintext', async () => {
const { encryptPassword, decryptPassword } = await getCrypto()
const plaintext = 'my-fastmail-app-password-abc123'
const encrypted = encryptPassword(plaintext)
expect(decryptPassword(encrypted)).toBe(plaintext)
})
const { encryptPassword, decryptPassword } = await getCrypto();
const plaintext = 'my-fastmail-app-password-abc123';
const encrypted = encryptPassword(plaintext);
expect(decryptPassword(encrypted)).toBe(plaintext);
});
it('different IVs produce different ciphertext for the same plaintext', async () => {
const { encryptPassword } = await getCrypto()
const plaintext = 'same-password'
const enc1 = encryptPassword(plaintext)
const enc2 = encryptPassword(plaintext)
const { encryptPassword } = await getCrypto();
const plaintext = 'same-password';
const enc1 = encryptPassword(plaintext);
const enc2 = encryptPassword(plaintext);
// The JSON payloads must differ (different IVs → different ciphertext)
expect(enc1).not.toBe(enc2)
expect(enc1).not.toBe(enc2);
// And the IVs themselves must differ
const p1 = JSON.parse(enc1)
const p2 = JSON.parse(enc2)
expect(p1.iv).not.toBe(p2.iv)
})
const p1 = JSON.parse(enc1);
const p2 = JSON.parse(enc2);
expect(p1.iv).not.toBe(p2.iv);
});
it('decrypting with a tampered authTag throws', async () => {
const { encryptPassword, decryptPassword } = await getCrypto()
const encrypted = encryptPassword('secret')
const payload = JSON.parse(encrypted)
const { encryptPassword, decryptPassword } = await getCrypto();
const encrypted = encryptPassword('secret');
const payload = JSON.parse(encrypted);
// Flip first byte of authTag
payload.authTag = ('ff' + payload.authTag.slice(2))
expect(() => decryptPassword(JSON.stringify(payload))).toThrow()
})
payload.authTag = 'ff' + payload.authTag.slice(2);
expect(() => decryptPassword(JSON.stringify(payload))).toThrow();
});
it('decrypting with a tampered ciphertext throws', async () => {
const { encryptPassword, decryptPassword } = await getCrypto()
const encrypted = encryptPassword('secret')
const payload = JSON.parse(encrypted)
const { encryptPassword, decryptPassword } = await getCrypto();
const encrypted = encryptPassword('secret');
const payload = JSON.parse(encrypted);
// Flip first byte of ciphertext
payload.ciphertext = ('ff' + payload.ciphertext.slice(2))
expect(() => decryptPassword(JSON.stringify(payload))).toThrow()
})
payload.ciphertext = 'ff' + payload.ciphertext.slice(2);
expect(() => decryptPassword(JSON.stringify(payload))).toThrow();
});
it('stored payload is valid JSON with iv, authTag, ciphertext fields', async () => {
const { encryptPassword } = await getCrypto()
const encrypted = encryptPassword('test-password')
const payload = JSON.parse(encrypted)
expect(payload).toHaveProperty('iv')
expect(payload).toHaveProperty('authTag')
expect(payload).toHaveProperty('ciphertext')
const { encryptPassword } = await getCrypto();
const encrypted = encryptPassword('test-password');
const payload = JSON.parse(encrypted);
expect(payload).toHaveProperty('iv');
expect(payload).toHaveProperty('authTag');
expect(payload).toHaveProperty('ciphertext');
// All values are non-empty hex strings
expect(typeof payload.iv).toBe('string')
expect(payload.iv.length).toBeGreaterThan(0)
expect(typeof payload.authTag).toBe('string')
expect(payload.authTag.length).toBeGreaterThan(0)
expect(typeof payload.ciphertext).toBe('string')
expect(payload.ciphertext.length).toBeGreaterThan(0)
})
})
expect(typeof payload.iv).toBe('string');
expect(payload.iv.length).toBeGreaterThan(0);
expect(typeof payload.authTag).toBe('string');
expect(payload.authTag.length).toBeGreaterThan(0);
expect(typeof payload.ciphertext).toBe('string');
expect(payload.ciphertext.length).toBeGreaterThan(0);
});
});
+123 -124
View File
@@ -13,23 +13,22 @@
* duration derives from DTSTART→DTEND (not the recurrence span) (D-06 invariant).
*/
import 'temporal-polyfill/global'
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import 'temporal-polyfill/global';
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { expandOccurrences } from '../../src/broker/expand.js'
import { expandOccurrences } from '../../src/broker/expand.js';
const __dirname = dirname(fileURLToPath(import.meta.url))
const FIXTURES = join(__dirname, '../fixtures')
const __dirname = dirname(fileURLToPath(import.meta.url));
const FIXTURES = join(__dirname, '../fixtures');
function loadFixture(name: string): string {
return readFileSync(join(FIXTURES, name), 'utf8')
return readFileSync(join(FIXTURES, name), 'utf8');
}
describe('expandOccurrences', () => {
describe('DST correctness — weekly-dst.ics', () => {
it('returns 10:00 America/New_York wall-clock time on BOTH sides of March 2026 DST boundary', () => {
// The fixture has DTSTART;TZID=America/New_York:20260301T100000 RRULE:FREQ=WEEKLY.
@@ -38,62 +37,62 @@ describe('expandOccurrences', () => {
// 2026-03-15 (EDT, UTC-4) must ALL show hour === 10 in America/New_York.
// A broken implementation that falls back to UTC would show hour === 10 UTC before
// transition and hour === 11 local after transition — off by one DST hour.
const rawVevent = loadFixture('weekly-dst.ics')
const windowStart = new Date('2026-03-01T00:00:00Z')
const windowEnd = new Date('2026-04-01T00:00:00Z')
const rawVevent = loadFixture('weekly-dst.ics');
const windowStart = new Date('2026-03-01T00:00:00Z');
const windowEnd = new Date('2026-04-01T00:00:00Z');
const occurrences = expandOccurrences(
rawVevent,
windowStart,
windowEnd,
1, // calendarId
1, // calendarId
'My Calendar', // calendarName
1, // ownerUserId
'Alice', // ownerName
'#4A90D9', // color
false, // isShared
)
1, // ownerUserId
'Alice', // ownerName
'#4A90D9', // color
false, // isShared
);
// Should return several weekly occurrences in March
expect(occurrences.length).toBeGreaterThan(0)
expect(occurrences.length).toBeGreaterThan(0);
// The key contract: every occurrence must have local hour === 10 in America/New_York.
// We verify this by checking the ISO string — before DST: '...T10:00:00-05:00[America/New_York]'
// after DST: '...T10:00:00-04:00[America/New_York]'. Both include the IANA bracket.
for (const occ of occurrences) {
expect(occ.allDay).toBe(false)
expect(occ.allDay).toBe(false);
// ownerName must be threaded through to every occurrence
expect(occ.ownerName).toBe('Alice')
expect(occ.ownerName).toBe('Alice');
// start must be IANA-annotated: '2026-03-01T10:00:00-05:00[America/New_York]'
expect(occ.start).toMatch(/T10:00:00/)
expect(occ.start).toMatch(/T10:00:00/);
// Must include IANA bracket — offset-only strings fail Temporal.ZonedDateTime.from()
expect(occ.start).toContain('[America/New_York]')
expect(occ.start).toContain('[America/New_York]');
}
// Explicitly check one pre-transition occurrence (EST) and one post-transition (EDT)
const preTransition = occurrences.find(o => o.start.includes('2026-03-01'))
const postTransition = occurrences.find(o => o.start.includes('2026-03-15'))
const preTransition = occurrences.find((o) => o.start.includes('2026-03-01'));
const postTransition = occurrences.find((o) => o.start.includes('2026-03-15'));
expect(preTransition).toBeDefined()
expect(postTransition).toBeDefined()
expect(preTransition).toBeDefined();
expect(postTransition).toBeDefined();
// Pre-transition occurrence: EST — '2026-03-01T10:00:00-05:00[America/New_York]'
expect(preTransition!.start).toContain('T10:00:00')
expect(preTransition!.start).toContain('-05:00[America/New_York]')
expect(preTransition!.start).toContain('T10:00:00');
expect(preTransition!.start).toContain('-05:00[America/New_York]');
// Post-transition occurrence: EDT — '2026-03-15T10:00:00-04:00[America/New_York]'
expect(postTransition!.start).toContain('T10:00:00')
expect(postTransition!.start).toContain('-04:00[America/New_York]')
})
})
expect(postTransition!.start).toContain('T10:00:00');
expect(postTransition!.start).toContain('-04:00[America/New_York]');
});
});
describe('All-day events — allday-birthday.ics', () => {
it('returns allDay:true with start as YYYY-MM-DD and no time component', () => {
// The fixture has DTSTART;VALUE=DATE:20260615 with RRULE:FREQ=YEARLY.
// The all-day occurrence should have allDay:true and start === '2026-06-15' (DATE format).
// A broken implementation returning '2026-06-15T00:00:00Z' would fail on date-shift.
const rawVevent = loadFixture('allday-birthday.ics')
const windowStart = new Date('2026-06-01T00:00:00Z')
const windowEnd = new Date('2026-07-01T00:00:00Z')
const rawVevent = loadFixture('allday-birthday.ics');
const windowStart = new Date('2026-06-01T00:00:00Z');
const windowEnd = new Date('2026-07-01T00:00:00Z');
const occurrences = expandOccurrences(
rawVevent,
@@ -102,20 +101,20 @@ describe('expandOccurrences', () => {
1,
'My Calendar',
1,
null, // ownerName
null, // ownerName
'#4A90D9',
false,
)
);
expect(occurrences.length).toBe(1)
expect(occurrences.length).toBe(1);
const occ = occurrences[0]
expect(occ.allDay).toBe(true)
const occ = occurrences[0];
expect(occ.allDay).toBe(true);
// start must be plain date string 'YYYY-MM-DD' — no 'T' time component
expect(occ.start).toBe('2026-06-15')
expect(occ.start).not.toContain('T')
})
})
expect(occ.start).toBe('2026-06-15');
expect(occ.start).not.toContain('T');
});
});
describe('EXDATE exclusions — exdate-series.ics', () => {
it('omits the EXDATE-excluded occurrence (array length is one fewer than un-excluded)', () => {
@@ -123,9 +122,9 @@ describe('expandOccurrences', () => {
// Without EXDATE: 5 occurrences (Jun 1, Jun 8, Jun 15, Jun 22, Jun 29).
// With EXDATE on Jun 15: 4 occurrences returned.
// ICAL.RecurExpansion handles EXDATE internally — no manual filtering needed.
const rawVevent = loadFixture('exdate-series.ics')
const windowStart = new Date('2026-06-01T00:00:00Z')
const windowEnd = new Date('2026-07-01T00:00:00Z')
const rawVevent = loadFixture('exdate-series.ics');
const windowStart = new Date('2026-06-01T00:00:00Z');
const windowEnd = new Date('2026-07-01T00:00:00Z');
const occurrences = expandOccurrences(
rawVevent,
@@ -134,28 +133,28 @@ describe('expandOccurrences', () => {
1,
'My Calendar',
1,
null, // ownerName
null, // ownerName
'#4A90D9',
false,
)
);
// 5 total occurrences minus 1 EXDATE = 4
expect(occurrences.length).toBe(4)
expect(occurrences.length).toBe(4);
// The June 15 occurrence must be absent
const june15 = occurrences.find(o => o.start.includes('2026-06-15'))
expect(june15).toBeUndefined()
})
})
const june15 = occurrences.find((o) => o.start.includes('2026-06-15'));
expect(june15).toBeUndefined();
});
});
describe('Non-recurring DURATION-only event — single-duration.ics', () => {
it('BUG-1 regression: non-recurring event with DURATION but no DTEND has end strictly after start', () => {
// Real Fastmail events use DURATION (not DTEND). Before the fix, the NON-RECURRING branch
// used getFirstPropertyValue('dtend') ?? dtstart, which returned dtstart when DTEND was absent,
// producing zero-duration occurrences (invisible in week/day views).
const rawVevent = loadFixture('single-duration.ics')
const windowStart = new Date('2026-06-01T00:00:00Z')
const windowEnd = new Date('2026-07-01T00:00:00Z')
const rawVevent = loadFixture('single-duration.ics');
const windowStart = new Date('2026-06-01T00:00:00Z');
const windowEnd = new Date('2026-07-01T00:00:00Z');
const occurrences = expandOccurrences(
rawVevent,
@@ -164,33 +163,33 @@ describe('expandOccurrences', () => {
1,
'My Calendar',
1,
null, // ownerName
null, // ownerName
'#4A90D9',
false,
)
);
expect(occurrences.length).toBe(1)
expect(occurrences.length).toBe(1);
const occ = occurrences[0]
expect(occ.allDay).toBe(false)
const occ = occurrences[0];
expect(occ.allDay).toBe(false);
// Parse both via Temporal.ZonedDateTime and assert end > start
const startZdt = Temporal.ZonedDateTime.from(occ.start)
const endZdt = Temporal.ZonedDateTime.from(occ.end)
expect(Temporal.ZonedDateTime.compare(endZdt, startZdt)).toBeGreaterThan(0)
const startZdt = Temporal.ZonedDateTime.from(occ.start);
const endZdt = Temporal.ZonedDateTime.from(occ.end);
expect(Temporal.ZonedDateTime.compare(endZdt, startZdt)).toBeGreaterThan(0);
// Verify the actual duration is correct: DURATION:PT1H → end is 1 hour after start
expect(endZdt.hour - startZdt.hour).toBe(1)
expect(endZdt.minute).toBe(startZdt.minute)
})
})
expect(endZdt.hour - startZdt.hour).toBe(1);
expect(endZdt.minute).toBe(startZdt.minute);
});
});
describe('hasRrule field — D-08 (recurring series detection)', () => {
it('recurring event occurrence has hasRrule === true', () => {
// weekly-dst.ics has RRULE:FREQ=WEEKLY — all occurrences must have hasRrule === true
const rawVevent = loadFixture('weekly-dst.ics')
const windowStart = new Date('2026-03-01T00:00:00Z')
const windowEnd = new Date('2026-04-01T00:00:00Z')
const rawVevent = loadFixture('weekly-dst.ics');
const windowStart = new Date('2026-03-01T00:00:00Z');
const windowEnd = new Date('2026-04-01T00:00:00Z');
const occurrences = expandOccurrences(
rawVevent,
@@ -202,19 +201,19 @@ describe('expandOccurrences', () => {
null,
'#4A90D9',
false,
)
);
expect(occurrences.length).toBeGreaterThan(0)
expect(occurrences.length).toBeGreaterThan(0);
for (const occ of occurrences) {
expect(occ.hasRrule).toBe(true)
expect(occ.hasRrule).toBe(true);
}
})
});
it('non-recurring event occurrence has hasRrule === false', () => {
// single-duration.ics has no RRULE — the single occurrence must have hasRrule === false
const rawVevent = loadFixture('single-duration.ics')
const windowStart = new Date('2026-06-01T00:00:00Z')
const windowEnd = new Date('2026-07-01T00:00:00Z')
const rawVevent = loadFixture('single-duration.ics');
const windowStart = new Date('2026-06-01T00:00:00Z');
const windowEnd = new Date('2026-07-01T00:00:00Z');
const occurrences = expandOccurrences(
rawVevent,
@@ -226,20 +225,20 @@ describe('expandOccurrences', () => {
null,
'#4A90D9',
false,
)
);
expect(occurrences.length).toBe(1)
expect(occurrences[0].hasRrule).toBe(false)
})
})
expect(occurrences.length).toBe(1);
expect(occurrences[0].hasRrule).toBe(false);
});
});
describe('Bounded RRULE (COUNT=3) — D-06 invariant', () => {
it('expands to exactly 3 occurrences within a wide window (COUNT terminates expansion)', () => {
// weekly-count3.ics has RRULE:FREQ=WEEKLY;COUNT=3 starting 2026-06-01.
// A 6-month window must return exactly 3 occurrences — not more.
const rawVevent = loadFixture('weekly-count3.ics')
const windowStart = new Date('2026-06-01T00:00:00Z')
const windowEnd = new Date('2026-12-01T00:00:00Z')
const rawVevent = loadFixture('weekly-count3.ics');
const windowStart = new Date('2026-06-01T00:00:00Z');
const windowEnd = new Date('2026-12-01T00:00:00Z');
const occurrences = expandOccurrences(
rawVevent,
@@ -251,17 +250,17 @@ describe('expandOccurrences', () => {
null,
'#4A90D9',
false,
)
);
expect(occurrences.length).toBe(3)
})
expect(occurrences.length).toBe(3);
});
it('each bounded occurrence duration derives from DTSTART→DTEND (1 hour), not recurrence span', () => {
// Each occurrence must have end exactly 1 hour after start.
// The recurrence span is many weeks; duration must be per-event, not recurrence-level.
const rawVevent = loadFixture('weekly-count3.ics')
const windowStart = new Date('2026-06-01T00:00:00Z')
const windowEnd = new Date('2026-12-01T00:00:00Z')
const rawVevent = loadFixture('weekly-count3.ics');
const windowStart = new Date('2026-06-01T00:00:00Z');
const windowEnd = new Date('2026-12-01T00:00:00Z');
const occurrences = expandOccurrences(
rawVevent,
@@ -273,23 +272,23 @@ describe('expandOccurrences', () => {
null,
'#4A90D9',
false,
)
);
expect(occurrences.length).toBe(3)
expect(occurrences.length).toBe(3);
for (const occ of occurrences) {
const startZdt = Temporal.ZonedDateTime.from(occ.start)
const endZdt = Temporal.ZonedDateTime.from(occ.end)
const startZdt = Temporal.ZonedDateTime.from(occ.start);
const endZdt = Temporal.ZonedDateTime.from(occ.end);
// Each occurrence must be exactly 1 hour (3 600 000 ms).
// Use epochMilliseconds which is a regular number in the polyfill.
const durationMs = endZdt.epochMilliseconds - startZdt.epochMilliseconds
expect(durationMs).toBe(3_600_000)
const durationMs = endZdt.epochMilliseconds - startZdt.epochMilliseconds;
expect(durationMs).toBe(3_600_000);
}
})
});
it('bounded occurrences have hasRrule === true', () => {
const rawVevent = loadFixture('weekly-count3.ics')
const windowStart = new Date('2026-06-01T00:00:00Z')
const windowEnd = new Date('2026-12-01T00:00:00Z')
const rawVevent = loadFixture('weekly-count3.ics');
const windowStart = new Date('2026-06-01T00:00:00Z');
const windowEnd = new Date('2026-12-01T00:00:00Z');
const occurrences = expandOccurrences(
rawVevent,
@@ -301,14 +300,14 @@ describe('expandOccurrences', () => {
null,
'#4A90D9',
false,
)
);
expect(occurrences.length).toBe(3)
expect(occurrences.length).toBe(3);
for (const occ of occurrences) {
expect(occ.hasRrule).toBe(true)
expect(occ.hasRrule).toBe(true);
}
})
})
});
});
describe('Cross-contract: expand output → Temporal.ZonedDateTime.from (regression guard)', () => {
it('timed event start/end strings from weekly-dst.ics parse via Temporal.ZonedDateTime.from without throwing', () => {
@@ -319,9 +318,9 @@ describe('expandOccurrences', () => {
// Previously, serializeTime emitted offset-only strings like '2026-03-01T10:00:00-05:00'
// which caused Temporal.ZonedDateTime.from() to throw RangeError: Cannot parse.
// Now it emits IANA-annotated strings like '2026-03-01T10:00:00-05:00[America/New_York]'.
const rawVevent = loadFixture('weekly-dst.ics')
const windowStart = new Date('2026-03-01T00:00:00Z')
const windowEnd = new Date('2026-04-01T00:00:00Z')
const rawVevent = loadFixture('weekly-dst.ics');
const windowStart = new Date('2026-03-01T00:00:00Z');
const windowEnd = new Date('2026-04-01T00:00:00Z');
const occurrences = expandOccurrences(
rawVevent,
@@ -330,23 +329,23 @@ describe('expandOccurrences', () => {
1,
'My Calendar',
1,
null, // ownerName
null, // ownerName
'#4A90D9',
false,
)
);
expect(occurrences.length).toBeGreaterThan(0)
expect(occurrences.length).toBeGreaterThan(0);
for (const occ of occurrences) {
// These must not throw — this is the cross-service contract
expect(() => Temporal.ZonedDateTime.from(occ.start)).not.toThrow()
expect(() => Temporal.ZonedDateTime.from(occ.end)).not.toThrow()
expect(() => Temporal.ZonedDateTime.from(occ.start)).not.toThrow();
expect(() => Temporal.ZonedDateTime.from(occ.end)).not.toThrow();
// Parsed ZonedDateTime must round-trip the wall-clock hour
const startZdt = Temporal.ZonedDateTime.from(occ.start)
expect(startZdt.hour).toBe(10)
expect(startZdt.timeZoneId).toBe('America/New_York')
const startZdt = Temporal.ZonedDateTime.from(occ.start);
expect(startZdt.hour).toBe(10);
expect(startZdt.timeZoneId).toBe('America/New_York');
}
})
})
})
});
});
});
+324 -303
View File
@@ -13,12 +13,12 @@
* They will turn GREEN in Plan 03-03 when the implementation is added.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest';
// This import fails (RED) — broker/outboxWorker.ts does not exist yet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore intentional RED import
import { runOutboxDrain, assembleRruleString } from '../../src/broker/outboxWorker.js'
import { runOutboxDrain, assembleRruleString } from '../../src/broker/outboxWorker.js';
// ── Drizzle DB mock ────────────────────────────────────────────────────────
// Follows the pattern from PATTERNS.md §Drizzle DB mock in tests.
@@ -37,60 +37,68 @@ const {
mockSelectFn,
mockDecryptPassword,
} = vi.hoisted(() => {
const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet })
const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) });
const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet });
// mockWherePending: terminal node for calendarOutbox selects (pending-rows + sibling-status)
// db.select().from(calendarOutbox).where(...) — resolves to the row array
const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]))
const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]));
// mockWhereCalEvents: terminal node for calendarEvents selects (etag re-read for WR-02)
// db.select({etag}).from(calendarEvents).where(...) — resolves to the etag array
const mockWhereCalEvents = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]))
const mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending })
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn })
const mockWhereCalEvents = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]));
const mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending });
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn });
// By default returns a dummy password so loadClientForUser succeeds
const mockDecryptPassword = vi.fn().mockReturnValue('app-password')
return { mockUpdateSet, mockUpdate, mockWherePending, mockWhereCalEvents, mockFromFn, mockSelectFn, mockDecryptPassword }
})
const mockDecryptPassword = vi.fn().mockReturnValue('app-password');
return {
mockUpdateSet,
mockUpdate,
mockWherePending,
mockWhereCalEvents,
mockFromFn,
mockSelectFn,
mockDecryptPassword,
};
});
let mockPendingRows: unknown[] = []
let mockPendingRows: unknown[] = [];
// Fake credential row returned by loadClientForUser's db.select().from(memberCredentials).where()
const FAKE_CRED_ROW = {
userId: 42,
fastmailEmail: 'test@fastmail.com',
encryptedPassword: '{"iv":"aa","authTag":"bb","ciphertext":"cc"}',
}
};
vi.mock('../../src/db/client.js', () => ({
db: {
select: mockSelectFn,
update: mockUpdate,
},
}))
}));
// Mock write functions — these are called by outboxWorker for the actual CalDAV ops
vi.mock('../../src/broker/write.js', () => ({
createCalendarEvent: vi.fn(),
updateCalendarEvent: vi.fn(),
deleteCalendarEvent: vi.fn(),
}))
}));
// Mock sync — called after successful write (D-06)
vi.mock('../../src/broker/sync.js', () => ({
syncCalendar: vi.fn().mockResolvedValue(undefined),
}))
}));
// Mock client creation — the worker needs a DAVClient to call sync
vi.mock('../../src/broker/client.js', () => ({
createFastmailClient: vi.fn().mockResolvedValue({
fetchCalendars: vi.fn().mockResolvedValue([]),
}),
}))
}));
// Mock crypto — controls whether loadClientForUser succeeds or throws (CR-03 tests)
vi.mock('../../src/broker/crypto.js', () => ({
decryptPassword: mockDecryptPassword,
}))
}));
// Default payload is form JSON (the worker must build ICS from this — not pass raw JSON to CalDAV)
const DEFAULT_FORM_PAYLOAD = JSON.stringify({
@@ -99,7 +107,7 @@ const DEFAULT_FORM_PAYLOAD = JSON.stringify({
start: '2026-06-10T12:00:00',
end: '2026-06-10T13:00:00',
recurrence: 'none',
})
});
const makeRow = (overrides: Record<string, unknown> = {}) => ({
id: 1,
@@ -118,25 +126,25 @@ const makeRow = (overrides: Record<string, unknown> = {}) => ({
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
})
});
const makeResponse = (status: number): Response =>
({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as Response
({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as Response;
// Helper: wire db mock so outbox queries return mockPendingRows and credential queries return FAKE_CRED_ROW
// This is called in each beforeEach after vi.clearAllMocks() to restore the mock chain.
function wireMockChain() {
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
mockUpdate.mockReturnValue({ set: mockUpdateSet })
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) });
mockUpdate.mockReturnValue({ set: mockUpdateSet });
// mockFromFn differentiates by table argument using Symbol.for('drizzle:Name'):
// - memberCredentials → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default)
// - calendarEvents → returns mockWhereCalEvents (etag re-read for WR-02)
// - calendarOutbox (and anything else) → returns mockWherePending (pending-rows + sibling-status)
// JSON.stringify throws on circular Drizzle table structures; use Symbol identity instead.
mockFromFn.mockImplementation((table: unknown) => {
const tableName = (table as Record<symbol, string>)[Symbol.for('drizzle:Name')] ?? ''
const tableName = (table as Record<symbol, string>)[Symbol.for('drizzle:Name')] ?? '';
if (tableName === 'member_credentials') {
return { where: vi.fn().mockResolvedValue([FAKE_CRED_ROW]) }
return { where: vi.fn().mockResolvedValue([FAKE_CRED_ROW]) };
}
if (tableName === 'calendar_events') {
// CR-02: the freshest-etag re-read now scopes to the writing member's calendar:
@@ -147,174 +155,176 @@ function wireMockChain() {
innerJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: mockWhereCalEvents }),
}),
}
};
}
return { where: mockWherePending }
})
mockWhereCalEvents.mockImplementation(() => Promise.resolve([]))
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows))
mockSelectFn.mockReturnValue({ from: mockFromFn })
return { where: mockWherePending };
});
mockWhereCalEvents.mockImplementation(() => Promise.resolve([]));
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows));
mockSelectFn.mockReturnValue({ from: mockFromFn });
// Default: decryptPassword succeeds
mockDecryptPassword.mockReturnValue('app-password')
mockDecryptPassword.mockReturnValue('app-password');
}
describe('runOutboxDrain — state transitions', () => {
beforeEach(() => {
vi.resetAllMocks()
mockPendingRows = []
wireMockChain()
})
vi.resetAllMocks();
mockPendingRows = [];
wireMockChain();
});
it('transitions pending→done on 204 response and triggers re-sync (D-06)', async () => {
const { createCalendarEvent } = await import('../../src/broker/write.js')
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(204))
mockPendingRows = [makeRow()]
const { createCalendarEvent } = await import('../../src/broker/write.js');
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(204));
mockPendingRows = [makeRow()];
await runOutboxDrain()
await runOutboxDrain();
// Must update status to 'done'
expect(mockUpdate).toHaveBeenCalled()
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }
expect(setArg?.status).toBe('done')
})
expect(mockUpdate).toHaveBeenCalled();
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string };
expect(setArg?.status).toBe('done');
});
it('transitions pending→failed on 412 (conflict — no retry), marks failed (D-08)', async () => {
const { createCalendarEvent } = await import('../../src/broker/write.js')
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(412))
mockPendingRows = [makeRow()]
const { createCalendarEvent } = await import('../../src/broker/write.js');
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(412));
mockPendingRows = [makeRow()];
await runOutboxDrain()
await runOutboxDrain();
// 412 = hard fail (conflict) — must NOT retry, must mark failed
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string }
expect(setArg?.status).toBe('failed')
expect(setArg?.lastError).toBeTruthy()
})
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string };
expect(setArg?.status).toBe('failed');
expect(setArg?.lastError).toBeTruthy();
});
it('transitions pending→backoff (attemptCount++, nextAttemptAt advanced) on 500 (transient)', async () => {
const { createCalendarEvent } = await import('../../src/broker/write.js')
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500))
const row = makeRow({ attemptCount: 0 })
mockPendingRows = [row]
const { createCalendarEvent } = await import('../../src/broker/write.js');
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500));
const row = makeRow({ attemptCount: 0 });
mockPendingRows = [row];
const beforeDrain = Date.now()
await runOutboxDrain()
const beforeDrain = Date.now();
await runOutboxDrain();
// Must NOT transition to done or failed — backoff
const setArg = mockUpdateSet.mock.calls[0]?.[0] as {
status?: string
attemptCount?: number
nextAttemptAt?: Date
}
expect(setArg?.status).not.toBe('done')
expect(setArg?.status).not.toBe('failed')
expect(setArg?.attemptCount).toBe(1)
status?: string;
attemptCount?: number;
nextAttemptAt?: Date;
};
expect(setArg?.status).not.toBe('done');
expect(setArg?.status).not.toBe('failed');
expect(setArg?.attemptCount).toBe(1);
// nextAttemptAt must be in the future
expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThan(beforeDrain)
})
expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThan(beforeDrain);
});
it('transitions pending→dead when attemptCount reaches MAX_ATTEMPTS on transient error', async () => {
const { createCalendarEvent } = await import('../../src/broker/write.js')
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500))
const { createCalendarEvent } = await import('../../src/broker/write.js');
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500));
// MAX_ATTEMPTS is 5 per RESEARCH.md Pattern 4 — at attempt 4 (0-indexed) → dead
const row = makeRow({ attemptCount: 4 })
mockPendingRows = [row]
const row = makeRow({ attemptCount: 4 });
mockPendingRows = [row];
await runOutboxDrain()
await runOutboxDrain();
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }
expect(setArg?.status).toBe('dead')
})
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string };
expect(setArg?.status).toBe('dead');
});
it('does not crash when pending rows list is empty', async () => {
mockPendingRows = []
await expect(runOutboxDrain()).resolves.not.toThrow()
})
})
mockPendingRows = [];
await expect(runOutboxDrain()).resolves.not.toThrow();
});
});
describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
beforeEach(() => {
vi.resetAllMocks()
mockPendingRows = []
wireMockChain()
})
vi.resetAllMocks();
mockPendingRows = [];
wireMockChain();
});
it('create row: icsString passed to createCalendarEvent starts with BEGIN:VCALENDAR and contains SUMMARY', async () => {
const { createCalendarEvent } = await import('../../src/broker/write.js')
let capturedIcsString: unknown = null
const { createCalendarEvent } = await import('../../src/broker/write.js');
let capturedIcsString: unknown = null;
vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
capturedIcsString = icsString
return makeResponse(201)
})
mockPendingRows = [makeRow()]
capturedIcsString = icsString;
return makeResponse(201);
});
mockPendingRows = [makeRow()];
await runOutboxDrain()
await runOutboxDrain();
expect(typeof capturedIcsString).toBe('string')
expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true)
expect(capturedIcsString).toContain('SUMMARY:Lunch')
})
expect(typeof capturedIcsString).toBe('string');
expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true);
expect(capturedIcsString).toContain('SUMMARY:Lunch');
});
it('update row: icsString passed to updateCalendarEvent starts with BEGIN:VCALENDAR', async () => {
const { updateCalendarEvent } = await import('../../src/broker/write.js')
let capturedIcsString: unknown = null
const { updateCalendarEvent } = await import('../../src/broker/write.js');
let capturedIcsString: unknown = null;
vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => {
capturedIcsString = icsString
return makeResponse(204)
})
mockPendingRows = [makeRow({
operation: 'update',
calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
})]
capturedIcsString = icsString;
return makeResponse(204);
});
mockPendingRows = [
makeRow({
operation: 'update',
calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
}),
];
await runOutboxDrain()
await runOutboxDrain();
expect(typeof capturedIcsString).toBe('string')
expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true)
})
expect(typeof capturedIcsString).toBe('string');
expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true);
});
it('create row with unparseable payload marks the row failed (hard fail, no retry)', async () => {
mockPendingRows = [makeRow({ payload: 'NOT_VALID_JSON{{{' })]
mockPendingRows = [makeRow({ payload: 'NOT_VALID_JSON{{{' })];
await runOutboxDrain()
await runOutboxDrain();
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }
expect(setArg?.status).toBe('failed')
})
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string };
expect(setArg?.status).toBe('failed');
});
// IN-03 (iteration 2): a JSON-parseable but schema-INVALID payload (e.g. missing the
// required title) can never produce a valid VEVENT, so the row is hard-failed (no
// retry) rather than dispatched with SUMMARY:undefined.
it('IN-03: create row with schema-invalid payload (missing title) is hard-failed, never dispatched', async () => {
const { createCalendarEvent } = await import('../../src/broker/write.js')
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201))
const { createCalendarEvent } = await import('../../src/broker/write.js');
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201));
// Valid JSON, but title is missing → fails outboxPayloadSchema
const badPayload = JSON.stringify({
allDay: false,
start: '2026-06-10T12:00:00',
end: '2026-06-10T13:00:00',
})
mockPendingRows = [makeRow({ payload: badPayload })]
});
mockPendingRows = [makeRow({ payload: badPayload })];
await runOutboxDrain()
await runOutboxDrain();
// Must NOT have dispatched a CalDAV write with an invalid VEVENT
expect(createCalendarEvent).not.toHaveBeenCalled()
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string }
expect(setArg?.status).toBe('failed')
expect(setArg?.lastError).toMatch(/validation/i)
})
expect(createCalendarEvent).not.toHaveBeenCalled();
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string };
expect(setArg?.status).toBe('failed');
expect(setArg?.lastError).toMatch(/validation/i);
});
// CR-01 (iteration 2): the edit-as-move create branch must re-apply the RRULE the
// route stashed on the payload as `_preservedRrule`, so a moved recurring series keeps
// its RRULE instead of collapsing into a single occurrence.
it('CR-01: create row re-applies _preservedRrule → emitted ICS contains RRULE:', async () => {
const { createCalendarEvent } = await import('../../src/broker/write.js')
let capturedIcsString: unknown = null
const { createCalendarEvent } = await import('../../src/broker/write.js');
let capturedIcsString: unknown = null;
vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
capturedIcsString = icsString
return makeResponse(201)
})
capturedIcsString = icsString;
return makeResponse(201);
});
// Move payload: no explicit `recurrence`, but the route stashed the source RRULE.
const movePayload = JSON.stringify({
title: 'Moved weekly standup',
@@ -322,25 +332,27 @@ describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
start: '2026-06-10T12:00:00',
end: '2026-06-10T13:00:00',
_preservedRrule: 'FREQ=WEEKLY;BYDAY=MO',
})
mockPendingRows = [makeRow({ operation: 'create', payload: movePayload, groupId: 'move-grp-1' })]
});
mockPendingRows = [
makeRow({ operation: 'create', payload: movePayload, groupId: 'move-grp-1' }),
];
await runOutboxDrain()
await runOutboxDrain();
expect(typeof capturedIcsString).toBe('string')
expect(capturedIcsString as string).toContain('RRULE:')
expect(capturedIcsString as string).toContain('FREQ=WEEKLY')
})
expect(typeof capturedIcsString).toBe('string');
expect(capturedIcsString as string).toContain('RRULE:');
expect(capturedIcsString as string).toContain('FREQ=WEEKLY');
});
// CR-01 corollary: an explicit `recurrence` on a create still wins over any preserved
// RRULE (deliberate user choice); recurrence:'none' must emit no RRULE.
it("CR-01: explicit recurrence:'none' wins → emitted ICS has no RRULE even if _preservedRrule present", async () => {
const { createCalendarEvent } = await import('../../src/broker/write.js')
let capturedIcsString: unknown = null
const { createCalendarEvent } = await import('../../src/broker/write.js');
let capturedIcsString: unknown = null;
vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
capturedIcsString = icsString
return makeResponse(201)
})
capturedIcsString = icsString;
return makeResponse(201);
});
const payload = JSON.stringify({
title: 'One-off',
allDay: false,
@@ -348,31 +360,31 @@ describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
end: '2026-06-10T13:00:00',
recurrence: 'none',
_preservedRrule: 'FREQ=WEEKLY;BYDAY=MO',
})
mockPendingRows = [makeRow({ operation: 'create', payload })]
});
mockPendingRows = [makeRow({ operation: 'create', payload })];
await runOutboxDrain()
await runOutboxDrain();
expect(typeof capturedIcsString).toBe('string')
expect(capturedIcsString as string).not.toContain('RRULE:')
})
})
expect(typeof capturedIcsString).toBe('string');
expect(capturedIcsString as string).not.toContain('RRULE:');
});
});
describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
beforeEach(() => {
vi.resetAllMocks()
mockPendingRows = []
wireMockChain()
})
vi.resetAllMocks();
mockPendingRows = [];
wireMockChain();
});
it('processes the create row BEFORE the delete row when both share a groupId', async () => {
const { createCalendarEvent, deleteCalendarEvent } = await import('../../src/broker/write.js')
const createResponse = makeResponse(201)
const deleteResponse = makeResponse(204)
vi.mocked(createCalendarEvent).mockResolvedValue(createResponse)
vi.mocked(deleteCalendarEvent).mockResolvedValue(deleteResponse)
const { createCalendarEvent, deleteCalendarEvent } = await import('../../src/broker/write.js');
const createResponse = makeResponse(201);
const deleteResponse = makeResponse(204);
vi.mocked(createCalendarEvent).mockResolvedValue(createResponse);
vi.mocked(deleteCalendarEvent).mockResolvedValue(deleteResponse);
const groupId = 'edit-move-group-001'
const groupId = 'edit-move-group-001';
// delete row listed first (to verify ordering is enforced regardless of order in the array)
const deleteRow = makeRow({
id: 2,
@@ -381,16 +393,16 @@ describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
etag: '"etag-old"',
payload: null,
groupId,
})
});
const createRow = makeRow({
id: 3,
operation: 'create',
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/New/',
groupId,
})
});
// Both rows in the pending list
mockPendingRows = [deleteRow, createRow]
mockPendingRows = [deleteRow, createRow];
// The durable sibling-status check (CR-04) runs for the delete row with groupId.
// It queries calendarOutbox for the sibling create's status. By the time the delete
@@ -400,32 +412,32 @@ describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
// pending-rows select call.
mockWherePending
.mockImplementationOnce(() => Promise.resolve([deleteRow, createRow])) // pending-rows select
.mockImplementationOnce(() => Promise.resolve([{ status: 'done' }])) // sibling-status select
.mockImplementationOnce(() => Promise.resolve([{ status: 'done' }])); // sibling-status select
await runOutboxDrain()
await runOutboxDrain();
// CREATE must be called before DELETE
const createCall = vi.mocked(createCalendarEvent).mock.invocationCallOrder[0]
const deleteCall = vi.mocked(deleteCalendarEvent).mock.invocationCallOrder[0]
const createCall = vi.mocked(createCalendarEvent).mock.invocationCallOrder[0];
const deleteCall = vi.mocked(deleteCalendarEvent).mock.invocationCallOrder[0];
// If either was never called, the test will fail naturally.
// If create order index > delete order index, create ran AFTER delete — fail.
expect(createCall).toBeLessThan(deleteCall)
})
})
expect(createCall).toBeLessThan(deleteCall);
});
});
describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency guard (CR-05)', () => {
beforeEach(() => {
vi.resetAllMocks()
mockPendingRows = []
wireMockChain()
})
vi.resetAllMocks();
mockPendingRows = [];
wireMockChain();
});
it('CR-04 cross-batch: drain 1 (sibling create still pending) leaves the delete pending and never calls deleteCalendarEvent', async () => {
const { deleteCalendarEvent } = await import('../../src/broker/write.js')
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204))
const { deleteCalendarEvent } = await import('../../src/broker/write.js');
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204));
const groupId = 'edit-move-group-001'
const groupId = 'edit-move-group-001';
const deleteRow = makeRow({
id: 2,
operation: 'delete',
@@ -433,33 +445,33 @@ describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency
etag: '"etag-old"',
payload: null,
groupId,
})
});
// Drain 1: only the delete row is returned as pending (the create hasn't been fetched yet)
// First mockWherePending call → pending-rows select (only the delete row)
// Second mockWherePending call → sibling-status select (create is still 'pending')
mockWherePending
.mockImplementationOnce(() => Promise.resolve([deleteRow]))
.mockImplementationOnce(() => Promise.resolve([{ status: 'pending' }]))
.mockImplementationOnce(() => Promise.resolve([{ status: 'pending' }]));
await runOutboxDrain()
await runOutboxDrain();
// The delete must NOT have been dispatched — sibling create is not yet done
expect(deleteCalendarEvent).not.toHaveBeenCalled()
expect(deleteCalendarEvent).not.toHaveBeenCalled();
// The delete row's status must NOT have been updated to done or failed
const statusCalls = mockUpdateSet.mock.calls.filter((call) => {
const arg = call[0] as { status?: string }
return arg?.status === 'done' || arg?.status === 'failed'
})
expect(statusCalls.length).toBe(0)
})
const arg = call[0] as { status?: string };
return arg?.status === 'done' || arg?.status === 'failed';
});
expect(statusCalls.length).toBe(0);
});
it('CR-04 cross-batch: drain 2 (sibling create now done) dispatches the delete exactly once', async () => {
const { deleteCalendarEvent } = await import('../../src/broker/write.js')
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204))
const { deleteCalendarEvent } = await import('../../src/broker/write.js');
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204));
const groupId = 'edit-move-group-001'
const groupId = 'edit-move-group-001';
const deleteRow = makeRow({
id: 2,
operation: 'delete',
@@ -467,23 +479,23 @@ describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency
etag: '"etag-old"',
payload: null,
groupId,
})
});
// Drain 2: delete row is pending again, sibling create is now 'done'
mockWherePending
.mockImplementationOnce(() => Promise.resolve([deleteRow]))
.mockImplementationOnce(() => Promise.resolve([{ status: 'done' }]))
.mockImplementationOnce(() => Promise.resolve([{ status: 'done' }]));
await runOutboxDrain()
await runOutboxDrain();
expect(deleteCalendarEvent).toHaveBeenCalledTimes(1)
})
expect(deleteCalendarEvent).toHaveBeenCalledTimes(1);
});
it('CR-04 paired-create-failed: if sibling create is failed, delete is marked failed and never dispatched (D-04 preserved)', async () => {
const { deleteCalendarEvent } = await import('../../src/broker/write.js')
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204))
const { deleteCalendarEvent } = await import('../../src/broker/write.js');
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204));
const groupId = 'edit-move-group-001'
const groupId = 'edit-move-group-001';
const deleteRow = makeRow({
id: 2,
operation: 'delete',
@@ -491,162 +503,167 @@ describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency
etag: '"etag-old"',
payload: null,
groupId,
})
});
// Sibling create is 'failed' — the delete must be permanently skipped
mockWherePending
.mockImplementationOnce(() => Promise.resolve([deleteRow]))
.mockImplementationOnce(() => Promise.resolve([{ status: 'failed' }]))
.mockImplementationOnce(() => Promise.resolve([{ status: 'failed' }]));
await runOutboxDrain()
await runOutboxDrain();
// The original event must be preserved — delete must NOT be dispatched
expect(deleteCalendarEvent).not.toHaveBeenCalled()
expect(deleteCalendarEvent).not.toHaveBeenCalled();
// The delete row must be marked failed (permanently, not just skipped this cycle)
const failedCall = mockUpdateSet.mock.calls.find((call) => {
const arg = call[0] as { status?: string; lastError?: string }
return arg?.status === 'failed' && typeof arg?.lastError === 'string'
})
expect(failedCall).toBeDefined()
const failArg = failedCall![0] as { lastError: string }
expect(failArg.lastError).toMatch(/paired create/)
})
const arg = call[0] as { status?: string; lastError?: string };
return arg?.status === 'failed' && typeof arg?.lastError === 'string';
});
expect(failedCall).toBeDefined();
const failArg = failedCall![0] as { lastError: string };
expect(failArg.lastError).toMatch(/paired create/);
});
it('CR-05: two overlapping runOutboxDrain calls invoke createCalendarEvent exactly once', async () => {
const { createCalendarEvent } = await import('../../src/broker/write.js')
const { createCalendarEvent } = await import('../../src/broker/write.js');
// Simulate a slow create so the second drain starts while first is still running
vi.mocked(createCalendarEvent).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(makeResponse(201)), 20)),
)
);
mockPendingRows = [makeRow({ id: 1 })]
mockPendingRows = [makeRow({ id: 1 })];
// Start both drains concurrently WITHOUT awaiting the first
const drain1 = runOutboxDrain()
const drain2 = runOutboxDrain()
await Promise.all([drain1, drain2])
const drain1 = runOutboxDrain();
const drain2 = runOutboxDrain();
await Promise.all([drain1, drain2]);
// Only one dispatch must have happened — the second drain must have been a no-op
expect(createCalendarEvent).toHaveBeenCalledTimes(1)
})
})
expect(createCalendarEvent).toHaveBeenCalledTimes(1);
});
});
describe('runOutboxDrain — fresh etag re-read before PUT (WR-02)', () => {
beforeEach(() => {
// Use resetAllMocks here (not clearAllMocks) so that unconsumed mockImplementationOnce
// queues from prior tests do not bleed into subsequent tests via the shared mockWherePending.
vi.resetAllMocks()
mockPendingRows = []
wireMockChain()
})
vi.resetAllMocks();
mockPendingRows = [];
wireMockChain();
});
it('WR-02 fresh etag: update PUT uses freshest calendarEvents.etag, not stale enqueue-time etag', async () => {
const { updateCalendarEvent } = await import('../../src/broker/write.js')
let capturedEtag: string | null = null
const { updateCalendarEvent } = await import('../../src/broker/write.js');
let capturedEtag: string | null = null;
vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, _ics, etag) => {
capturedEtag = etag
return makeResponse(204)
})
capturedEtag = etag;
return makeResponse(204);
});
const updateRow = makeRow({
operation: 'update',
calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
etag: 'old-etag', // stale enqueue-time etag
})
mockPendingRows = [updateRow]
});
mockPendingRows = [updateRow];
// Mock the calendarEvents etag lookup to return a fresher etag.
// In RED (no fresh-etag code yet), mockWhereCalEvents is never called, so
// the PUT uses row.etag = 'old-etag'. The assertion expects 'new-etag' → fails RED.
mockWhereCalEvents.mockResolvedValue([{ etag: 'new-etag' }])
mockWhereCalEvents.mockResolvedValue([{ etag: 'new-etag' }]);
await runOutboxDrain()
await runOutboxDrain();
// The PUT must use the freshest etag from calendarEvents, not the stale row.etag
expect(capturedEtag).toBe('new-etag')
expect(capturedEtag).not.toBe('old-etag')
})
expect(capturedEtag).toBe('new-etag');
expect(capturedEtag).not.toBe('old-etag');
});
it('WR-02 etag fallback: update PUT falls back to row.etag when calendarEvents has no matching row', async () => {
const { updateCalendarEvent } = await import('../../src/broker/write.js')
let capturedEtag: string | null = null
const { updateCalendarEvent } = await import('../../src/broker/write.js');
let capturedEtag: string | null = null;
vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, _ics, etag) => {
capturedEtag = etag
return makeResponse(204)
})
capturedEtag = etag;
return makeResponse(204);
});
const updateRow = makeRow({
operation: 'update',
calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
etag: 'fallback-etag',
})
mockPendingRows = [updateRow]
});
mockPendingRows = [updateRow];
// mockWhereCalEvents is already configured to return [] by default in wireMockChain.
// No row for the uid → worker falls back to row.etag.
// In RED, mockWhereCalEvents is never called so the test passes (row.etag used directly).
// In GREEN, mockWhereCalEvents returns [] so the fallback is exercised.
await runOutboxDrain()
await runOutboxDrain();
// When calendarEvents has no row for the uid, fall back to row.etag
expect(capturedEtag).toBe('fallback-etag')
})
})
expect(capturedEtag).toBe('fallback-etag');
});
});
describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff index fix (WR-01)', () => {
beforeEach(() => {
vi.resetAllMocks()
mockPendingRows = []
wireMockChain()
})
vi.resetAllMocks();
mockPendingRows = [];
wireMockChain();
});
it('CR-03: credential-load failure leaves row pending and never calls createFastmailClient with empty credentials', async () => {
// Make decryptPassword throw so loadClientForUser throws
mockDecryptPassword.mockImplementation(() => { throw new Error('bad credentials') })
mockDecryptPassword.mockImplementation(() => {
throw new Error('bad credentials');
});
const { createFastmailClient } = await import('../../src/broker/client.js')
mockPendingRows = [makeRow()]
const { createFastmailClient } = await import('../../src/broker/client.js');
mockPendingRows = [makeRow()];
await runOutboxDrain()
await runOutboxDrain();
// The row must NOT be updated to done/failed/dead — it stays pending (outer catch handles it)
const updateCalls = mockUpdateSet.mock.calls
const updateCalls = mockUpdateSet.mock.calls;
const anyStatusChange = updateCalls.some((call) => {
const arg = call[0] as { status?: string }
return arg?.status !== undefined
})
expect(anyStatusChange).toBe(false)
const arg = call[0] as { status?: string };
return arg?.status !== undefined;
});
expect(anyStatusChange).toBe(false);
// createFastmailClient must NEVER be called with empty-string credentials
const emptyCalls = vi.mocked(createFastmailClient).mock.calls.filter(
([email, password]) => email === '' || password === ''
)
expect(emptyCalls.length).toBe(0)
})
const emptyCalls = vi
.mocked(createFastmailClient)
.mock.calls.filter(([email, password]) => email === '' || password === '');
expect(emptyCalls.length).toBe(0);
});
it('WR-01: first transient failure (attemptCount=0) sets backoff to ~15s (BACKOFF_SECONDS[0])', async () => {
const { createCalendarEvent } = await import('../../src/broker/write.js')
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500))
const row = makeRow({ attemptCount: 0 })
mockPendingRows = [row]
const { createCalendarEvent } = await import('../../src/broker/write.js');
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500));
const row = makeRow({ attemptCount: 0 });
mockPendingRows = [row];
const beforeDrain = Date.now()
await runOutboxDrain()
const afterDrain = Date.now()
const beforeDrain = Date.now();
await runOutboxDrain();
const afterDrain = Date.now();
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { nextAttemptAt?: Date; attemptCount?: number }
expect(setArg?.attemptCount).toBe(1)
const setArg = mockUpdateSet.mock.calls[0]?.[0] as {
nextAttemptAt?: Date;
attemptCount?: number;
};
expect(setArg?.attemptCount).toBe(1);
// WR-01: nextAttemptAt must be ~15s in the future (BACKOFF_SECONDS[0] = 15)
// Allow ±2s for execution overhead
const expectedMinMs = beforeDrain + 14_000
const expectedMaxMs = afterDrain + 16_000
expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThanOrEqual(expectedMinMs)
expect(setArg?.nextAttemptAt?.getTime()).toBeLessThanOrEqual(expectedMaxMs)
})
})
const expectedMinMs = beforeDrain + 14_000;
const expectedMaxMs = afterDrain + 16_000;
expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThanOrEqual(expectedMinMs);
expect(setArg?.nextAttemptAt?.getTime()).toBeLessThanOrEqual(expectedMaxMs);
});
});
// ─── D-06: assembleRruleString unit tests ─────────────────────────────────────
// These tests import the NOT-YET-EXPORTED `assembleRruleString` helper.
@@ -654,29 +671,33 @@ describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff in
describe('assembleRruleString (D-06)', () => {
it('returns base preset unchanged when no bound given', () => {
expect(assembleRruleString('FREQ=DAILY')).toBe('FREQ=DAILY')
})
expect(assembleRruleString('FREQ=DAILY')).toBe('FREQ=DAILY');
});
it('appends COUNT when count is given (count wins over until)', () => {
expect(assembleRruleString('FREQ=DAILY', undefined, 5, false)).toBe('FREQ=DAILY;COUNT=5')
})
expect(assembleRruleString('FREQ=DAILY', undefined, 5, false)).toBe('FREQ=DAILY;COUNT=5');
});
it('COUNT wins when both until and count are provided (mutual exclusion, RFC 5545 §3.3.10)', () => {
expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', 5, false)).toBe('FREQ=WEEKLY;COUNT=5')
})
expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', 5, false)).toBe('FREQ=WEEKLY;COUNT=5');
});
it('appends UNTIL as DATE form (YYYYMMDD) for all-day events', () => {
expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, true)).toBe('FREQ=WEEKLY;UNTIL=20260630')
})
expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, true)).toBe(
'FREQ=WEEKLY;UNTIL=20260630',
);
});
it('appends UNTIL as DATETIME UTC form (YYYYMMDDTHHMMSSZ) for timed events', () => {
expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, false)).toBe('FREQ=WEEKLY;UNTIL=20260630T235959Z')
})
expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, false)).toBe(
'FREQ=WEEKLY;UNTIL=20260630T235959Z',
);
});
it('COUNT=5 appended to FREQ=DAILY (matches plan behavior assertion)', () => {
expect(assembleRruleString('FREQ=DAILY', undefined, 5, false)).toBe('FREQ=DAILY;COUNT=5')
})
})
expect(assembleRruleString('FREQ=DAILY', undefined, 5, false)).toBe('FREQ=DAILY;COUNT=5');
});
});
// ─── D-07: FREQ-persistence regression lock ────────────────────────────────────
// RED: will fail because the outbox worker does not yet wire recurrenceUntil/recurrenceCount
@@ -684,32 +705,32 @@ describe('assembleRruleString (D-06)', () => {
describe('FREQ persistence (D-07 regression)', () => {
beforeEach(() => {
vi.resetAllMocks()
mockPendingRows = []
wireMockChain()
})
vi.resetAllMocks();
mockPendingRows = [];
wireMockChain();
});
it('D-07: daily-recurrence payload assembles to FREQ=DAILY (not weekly or none) in emitted ICS', async () => {
const { createCalendarEvent } = await import('../../src/broker/write.js')
let capturedIcsString: unknown = null
const { createCalendarEvent } = await import('../../src/broker/write.js');
let capturedIcsString: unknown = null;
vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
capturedIcsString = icsString
return makeResponse(201)
})
capturedIcsString = icsString;
return makeResponse(201);
});
const dailyPayload = JSON.stringify({
title: 'Daily standup',
allDay: false,
start: '2026-06-10T09:00:00',
end: '2026-06-10T09:30:00',
recurrence: 'daily',
})
mockPendingRows = [makeRow({ payload: dailyPayload })]
});
mockPendingRows = [makeRow({ payload: dailyPayload })];
await runOutboxDrain()
await runOutboxDrain();
expect(typeof capturedIcsString).toBe('string')
expect(typeof capturedIcsString).toBe('string');
// D-07: FREQ must be DAILY — not WEEKLY or absent
expect(capturedIcsString as string).toContain('RRULE:FREQ=DAILY')
expect(capturedIcsString as string).not.toContain('FREQ=WEEKLY')
})
})
expect(capturedIcsString as string).toContain('RRULE:FREQ=DAILY');
expect(capturedIcsString as string).not.toContain('FREQ=WEEKLY');
});
});
+101 -90
View File
@@ -6,93 +6,91 @@
* Credentials decrypted via decryptPassword before client creation (T-03-04).
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
// --- module-level mocks (Vitest hoisting) ---
const mockSyncCalendar = vi.fn().mockResolvedValue(undefined)
const mockSyncCalendar = vi.fn().mockResolvedValue(undefined);
vi.mock('../../src/broker/sync.js', () => ({
syncCalendar: mockSyncCalendar,
}))
}));
// decryptPassword returns a predictable plaintext for any input
vi.mock('../../src/broker/crypto.js', () => ({
decryptPassword: vi.fn().mockReturnValue('decrypted-app-password'),
}))
}));
// db mock: select() chain returns configurable results
const mockCalendarsSelectResult: Array<{ id: number; url: string; ctag: string | null }> = []
const mockCalendarsSelectResult: Array<{ id: number; url: string; ctag: string | null }> = [];
const mockCredentialsSelectResult: Array<{
id: number
userId: number
fastmailEmail: string
encryptedPassword: string
}> = []
id: number;
userId: number;
fastmailEmail: string;
encryptedPassword: string;
}> = [];
// Each call to db.select() needs to return different chains
// We use a call counter to decide which data to return
let _callCount = 0
let _callCount = 0;
const mockSelectLimit = vi.fn()
const mockSelectWhere = vi.fn().mockReturnValue({ limit: mockSelectLimit })
const mockSelectFrom = vi.fn()
const mockSelect = vi.fn().mockImplementation(() => ({ from: mockSelectFrom }))
const mockSelectLimit = vi.fn();
const mockSelectWhere = vi.fn().mockReturnValue({ limit: mockSelectLimit });
const mockSelectFrom = vi.fn();
const mockSelect = vi.fn().mockImplementation(() => ({ from: mockSelectFrom }));
mockSelectFrom.mockImplementation(() => ({
// For memberCredentials selects (no .where), resolve directly
where: mockSelectWhere,
// Support both: direct await (no where) and .where().limit()
then: (resolve: (v: typeof mockCredentialsSelectResult) => void) => {
_callCount++
resolve(mockCredentialsSelectResult)
return Promise.resolve(mockCredentialsSelectResult)
_callCount++;
resolve(mockCredentialsSelectResult);
return Promise.resolve(mockCredentialsSelectResult);
},
}))
}));
mockSelectLimit.mockImplementation(() =>
Promise.resolve(mockCalendarsSelectResult),
)
mockSelectLimit.mockImplementation(() => Promise.resolve(mockCalendarsSelectResult));
vi.mock('../../src/db/client.js', () => ({
db: { select: mockSelect },
}))
}));
// mockFetchCalendars: controlled per test
const mockFetchCalendars = vi.fn()
const mockFetchCalendars = vi.fn();
const mockCreateFastmailClient = vi.fn().mockResolvedValue({
fetchCalendars: mockFetchCalendars,
})
});
vi.mock('../../src/broker/client.js', () => ({
createFastmailClient: mockCreateFastmailClient,
}))
}));
describe('broker poller — runPoll', () => {
beforeEach(() => {
vi.clearAllMocks()
_callCount = 0
vi.clearAllMocks();
_callCount = 0;
// Reset implementations
mockSyncCalendar.mockResolvedValue(undefined)
mockCreateFastmailClient.mockResolvedValue({ fetchCalendars: mockFetchCalendars })
mockSelect.mockImplementation(() => ({ from: mockSelectFrom }))
mockSyncCalendar.mockResolvedValue(undefined);
mockCreateFastmailClient.mockResolvedValue({ fetchCalendars: mockFetchCalendars });
mockSelect.mockImplementation(() => ({ from: mockSelectFrom }));
mockSelectFrom.mockImplementation(() => ({
where: mockSelectWhere,
then: (resolve: (v: typeof mockCredentialsSelectResult) => void) => {
resolve(mockCredentialsSelectResult)
return Promise.resolve(mockCredentialsSelectResult)
resolve(mockCredentialsSelectResult);
return Promise.resolve(mockCredentialsSelectResult);
},
}))
mockSelectLimit.mockResolvedValue(mockCalendarsSelectResult)
}));
mockSelectLimit.mockResolvedValue(mockCalendarsSelectResult);
// Clear arrays
mockCredentialsSelectResult.length = 0
mockCalendarsSelectResult.length = 0
})
mockCredentialsSelectResult.length = 0;
mockCalendarsSelectResult.length = 0;
});
it('skips syncCalendar when ctag is unchanged', async () => {
const { runPoll } = await import('../../src/broker/poller.js')
const { runPoll } = await import('../../src/broker/poller.js');
// Set up one credential
mockCredentialsSelectResult.push({
@@ -100,10 +98,14 @@ describe('broker poller — runPoll', () => {
userId: 10,
fastmailEmail: 'lucas@fastmail.com',
encryptedPassword: 'encrypted-blob',
})
});
// Set up the stored calendar row with ctag 'ctag-v1'
mockCalendarsSelectResult.push({ id: 100, url: 'https://caldav.fastmail.com/cal/', ctag: 'ctag-v1' })
mockCalendarsSelectResult.push({
id: 100,
url: 'https://caldav.fastmail.com/cal/',
ctag: 'ctag-v1',
});
// fetchCalendars returns a calendar with the SAME ctag
mockFetchCalendars.mockResolvedValue([
@@ -113,24 +115,28 @@ describe('broker poller — runPoll', () => {
ctag: 'ctag-v1', // UNCHANGED
syncToken: null,
},
])
]);
await runPoll()
await runPoll();
expect(mockSyncCalendar).not.toHaveBeenCalled()
})
expect(mockSyncCalendar).not.toHaveBeenCalled();
});
it('calls syncCalendar when ctag changes', async () => {
const { runPoll } = await import('../../src/broker/poller.js')
const { runPoll } = await import('../../src/broker/poller.js');
mockCredentialsSelectResult.push({
id: 1,
userId: 10,
fastmailEmail: 'lucas@fastmail.com',
encryptedPassword: 'encrypted-blob',
})
});
mockCalendarsSelectResult.push({ id: 100, url: 'https://caldav.fastmail.com/cal/', ctag: 'ctag-v1' })
mockCalendarsSelectResult.push({
id: 100,
url: 'https://caldav.fastmail.com/cal/',
ctag: 'ctag-v1',
});
mockFetchCalendars.mockResolvedValue([
{
@@ -139,22 +145,22 @@ describe('broker poller — runPoll', () => {
ctag: 'ctag-v2', // CHANGED
syncToken: null,
},
])
]);
await runPoll()
await runPoll();
expect(mockSyncCalendar).toHaveBeenCalledOnce()
})
expect(mockSyncCalendar).toHaveBeenCalledOnce();
});
it('calls syncCalendar when ctag was null (first sync)', async () => {
const { runPoll } = await import('../../src/broker/poller.js')
const { runPoll } = await import('../../src/broker/poller.js');
mockCredentialsSelectResult.push({
id: 1,
userId: 10,
fastmailEmail: 'lucas@fastmail.com',
encryptedPassword: 'encrypted-blob',
})
});
// No stored calendar row yet (empty array → first sync)
// mockCalendarsSelectResult is empty
@@ -166,80 +172,85 @@ describe('broker poller — runPoll', () => {
ctag: 'ctag-v1',
syncToken: null,
},
])
]);
await runPoll()
await runPoll();
expect(mockSyncCalendar).toHaveBeenCalledOnce()
})
expect(mockSyncCalendar).toHaveBeenCalledOnce();
});
it('handles decryptPassword failure gracefully without crashing the poller', async () => {
const { runPoll } = await import('../../src/broker/poller.js')
const { decryptPassword } = await import('../../src/broker/crypto.js')
const { runPoll } = await import('../../src/broker/poller.js');
const { decryptPassword } = await import('../../src/broker/crypto.js');
mockCredentialsSelectResult.push({
id: 1,
userId: 10,
fastmailEmail: 'lucas@fastmail.com',
encryptedPassword: 'corrupted',
})
});
// Make decryptPassword throw for this test
;(decryptPassword as Mock).mockImplementationOnce(() => {
throw new Error('Decryption failed')
})
(decryptPassword as Mock).mockImplementationOnce(() => {
throw new Error('Decryption failed');
});
// runPoll should not throw — it should catch and skip the credential
await expect(runPoll()).resolves.not.toThrow()
expect(mockSyncCalendar).not.toHaveBeenCalled()
})
await expect(runPoll()).resolves.not.toThrow();
expect(mockSyncCalendar).not.toHaveBeenCalled();
});
it('BUG B: scopes the stored-calendar lookup to (userId, url), not url alone', async () => {
// Capture the predicate passed to db.select().from(calendars).where(...).
// The buggy code passed eq(url) only; the fix passes and(eq(userId), eq(url)).
// We serialize the predicate and assert it references the member's user_id column.
const capturedWhere: unknown[] = []
const capturedWhere: unknown[] = [];
mockSelectWhere.mockImplementation((pred: unknown) => {
capturedWhere.push(pred)
return { limit: mockSelectLimit }
})
capturedWhere.push(pred);
return { limit: mockSelectLimit };
});
const { runPoll } = await import('../../src/broker/poller.js')
const { runPoll } = await import('../../src/broker/poller.js');
mockCredentialsSelectResult.push({
id: 7,
userId: 42,
fastmailEmail: 'lucas@fastmail.com',
encryptedPassword: 'enc',
})
});
mockFetchCalendars.mockResolvedValue([
{ url: 'https://caldav.fastmail.com/cal/', displayName: 'Calendar', ctag: 'c', syncToken: null },
])
{
url: 'https://caldav.fastmail.com/cal/',
displayName: 'Calendar',
ctag: 'c',
syncToken: null,
},
]);
await runPoll()
await runPoll();
expect(capturedWhere.length).toBeGreaterThan(0)
expect(capturedWhere.length).toBeGreaterThan(0);
// A composite and(...) predicate exposes multiple queryChunks; a single eq does not
// contain a nested SQL referencing the user_id column. Serialize and inspect.
const pred = capturedWhere[0] as { queryChunks?: unknown[] }
const pred = capturedWhere[0] as { queryChunks?: unknown[] };
const serialized = JSON.stringify(pred, (_k, v) =>
typeof v === 'object' && v !== null && 'name' in (v as Record<string, unknown>)
? (v as { name?: unknown }).name
: v,
)
expect(serialized).toContain('user_id')
expect(serialized).toContain('url')
})
);
expect(serialized).toContain('user_id');
expect(serialized).toContain('url');
});
it('processes all member credentials in a poll cycle', async () => {
const { runPoll } = await import('../../src/broker/poller.js')
const { runPoll } = await import('../../src/broker/poller.js');
// Two credentials
mockCredentialsSelectResult.push(
{ id: 1, userId: 10, fastmailEmail: 'lucas@fastmail.com', encryptedPassword: 'enc1' },
{ id: 2, userId: 20, fastmailEmail: 'wife@icloud.com', encryptedPassword: 'enc2' },
)
);
// Each member's calendar has a different (new) ctag → both trigger sync
mockFetchCalendars.mockResolvedValue([
@@ -249,13 +260,13 @@ describe('broker poller — runPoll', () => {
ctag: 'new-ctag',
syncToken: null,
},
])
]);
await runPoll()
await runPoll();
// createFastmailClient called once per credential
expect(mockCreateFastmailClient).toHaveBeenCalledTimes(2)
expect(mockCreateFastmailClient).toHaveBeenCalledTimes(2);
// syncCalendar called once per credential (one calendar each, ctag changed)
expect(mockSyncCalendar).toHaveBeenCalledTimes(2)
})
})
expect(mockSyncCalendar).toHaveBeenCalledTimes(2);
});
});
+251 -186
View File
@@ -19,19 +19,19 @@
* Run: pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock the DB so we can control what events are returned
vi.mock('../../src/db/client.js', () => ({
db: {
select: vi.fn(),
},
}))
}));
// Mock pushDispatcher so no real push occurs
vi.mock('../../src/lib/pushDispatcher.js', () => ({
dispatchPush: vi.fn().mockResolvedValue(undefined),
}))
}));
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -44,18 +44,18 @@ function makeSelectMock(rows: unknown[]) {
}),
}),
}),
} as never
} as never;
}
function makeEventRow(overrides: {
uid?: string
title?: string
dtstartUtc: Date
subId?: number | null
subUserId?: number | null
subEndpoint?: string
subP256dh?: string
subAuth?: string
uid?: string;
title?: string;
dtstartUtc: Date;
subId?: number | null;
subUserId?: number | null;
subEndpoint?: string;
subP256dh?: string;
subAuth?: string;
}) {
return {
uid: overrides.uid ?? 'test-uid-1',
@@ -68,162 +68,183 @@ function makeEventRow(overrides: {
subEndpoint: overrides.subEndpoint ?? 'https://push.example.com/1',
subP256dh: overrides.subP256dh ?? 'p256dh-key',
subAuth: overrides.subAuth ?? 'auth-secret',
}
};
}
// ── Filtering tests (D-05 / D-07) ────────────────────────────────────────────
describe('reminderScheduler — shared+timed event filtering (D-05/D-07)', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.resetModules()
})
vi.useFakeTimers();
vi.resetModules();
});
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
vi.useRealTimers();
vi.clearAllMocks();
});
it('does not dispatch reminders for all-day events (D-07)', async () => {
const now = new Date('2026-06-15T10:00:00Z')
vi.setSystemTime(now)
const now = new Date('2026-06-15T10:00:00Z');
vi.setSystemTime(now);
const { db } = await import('../../src/db/client.js')
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
const { db } = await import('../../src/db/client.js');
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
// allDay=true events are excluded by the SQL WHERE; simulate by returning empty rows
vi.mocked(db.select).mockReturnValue(makeSelectMock([]))
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
await runReminderCheck()
await runReminderCheck();
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled()
})
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
});
it('does not dispatch reminders for non-shared (personal) calendar events (D-05)', async () => {
const now = new Date('2026-06-15T10:00:00Z')
vi.setSystemTime(now)
const now = new Date('2026-06-15T10:00:00Z');
vi.setSystemTime(now);
const { db } = await import('../../src/db/client.js')
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
const { db } = await import('../../src/db/client.js');
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
// isShared=false events are excluded by the SQL WHERE; simulate by returning empty rows
vi.mocked(db.select).mockReturnValue(makeSelectMock([]))
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
await runReminderCheck()
await runReminderCheck();
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled()
})
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
});
it('does not dispatch for an event whose start has already passed (dtstart <= now)', async () => {
const now = new Date('2026-06-15T10:00:00Z')
vi.setSystemTime(now)
const now = new Date('2026-06-15T10:00:00Z');
vi.setSystemTime(now);
const { db } = await import('../../src/db/client.js')
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
const { db } = await import('../../src/db/client.js');
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
// gt(dtstartUtc, now) excludes already-started events; simulate by returning empty rows
vi.mocked(db.select).mockReturnValue(makeSelectMock([]))
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
await runReminderCheck(now)
await runReminderCheck(now);
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled()
})
})
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
});
});
// ── D-16: empty subscriptions — zero sends / no crash ────────────────────────
describe('reminderScheduler — D-16: empty push_subscriptions', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.resetModules()
})
vi.useFakeTimers();
vi.resetModules();
});
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
vi.useRealTimers();
vi.clearAllMocks();
});
it('produces zero sends and does not crash when push_subscriptions is empty (D-16)', async () => {
const now = new Date('2026-06-15T10:00:00Z')
vi.setSystemTime(now)
const now = new Date('2026-06-15T10:00:00Z');
vi.setSystemTime(now);
const { db } = await import('../../src/db/client.js')
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
const { db } = await import('../../src/db/client.js');
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
// Cross-join with empty push_subscriptions returns no rows
vi.mocked(db.select).mockReturnValue(makeSelectMock([]))
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
await expect(runReminderCheck(now)).resolves.toBeUndefined()
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled()
})
})
await expect(runReminderCheck(now)).resolves.toBeUndefined();
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
});
});
// ── Catch-up / missed-tick recovery ──────────────────────────────────────────
describe('reminderScheduler — catch-up window and missed-tick recovery', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.resetModules()
})
vi.useFakeTimers();
vi.resetModules();
});
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
vi.useRealTimers();
vi.clearAllMocks();
});
it('SINGLE-FIRE: dispatches exactly once across three consecutive ticks while event is in window', async () => {
const { db } = await import('../../src/db/client.js')
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
const { db } = await import('../../src/db/client.js');
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
const t0 = new Date('2026-06-15T10:00:00Z')
const t0 = new Date('2026-06-15T10:00:00Z');
// Event is 15 min out from t0; still in (now, now+16min] at t0+1min (14 min out) and t0+2min (13 min out)
const eventDtstart = new Date('2026-06-15T10:15:00Z')
const eventDtstart = new Date('2026-06-15T10:15:00Z');
const sub = { id: 1, userId: 1, endpoint: 'https://push.example.com/1', p256dh: 'k', auth: 'a' }
const sub = {
id: 1,
userId: 1,
endpoint: 'https://push.example.com/1',
p256dh: 'k',
auth: 'a',
};
function rowForNow() {
return makeEventRow({ uid: 'single-fire-uid', dtstartUtc: eventDtstart, ...sub, subId: sub.id, subUserId: sub.userId, subEndpoint: sub.endpoint, subP256dh: sub.p256dh, subAuth: sub.auth })
return makeEventRow({
uid: 'single-fire-uid',
dtstartUtc: eventDtstart,
...sub,
subId: sub.id,
subUserId: sub.userId,
subEndpoint: sub.endpoint,
subP256dh: sub.p256dh,
subAuth: sub.auth,
});
}
// Tick at t0 (event 15 min out)
vi.setSystemTime(t0)
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]))
await runReminderCheck(t0)
vi.setSystemTime(t0);
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]));
await runReminderCheck(t0);
// Tick at t0+1min (event 14 min out — still in window, same uid)
const t1 = new Date(t0.getTime() + 60 * 1000)
vi.setSystemTime(t1)
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]))
await runReminderCheck(t1)
const t1 = new Date(t0.getTime() + 60 * 1000);
vi.setSystemTime(t1);
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]));
await runReminderCheck(t1);
// Tick at t0+2min (event 13 min out — still in window, same uid)
const t2 = new Date(t0.getTime() + 2 * 60 * 1000)
vi.setSystemTime(t2)
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]))
await runReminderCheck(t2)
const t2 = new Date(t0.getTime() + 2 * 60 * 1000);
vi.setSystemTime(t2);
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]));
await runReminderCheck(t2);
// Exactly one dispatch total: uid dedup prevents re-fire on ticks 2 and 3
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1)
})
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1);
});
it('MISSED-TICK-RECOVERY: fires when scan runs 8 min before event after ideal tick was skipped', async () => {
const { db } = await import('../../src/db/client.js')
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
const { db } = await import('../../src/db/client.js');
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
// Event at 10:15:00Z. The ideal 15-min scan (10:00:00Z) was missed.
// Call at 10:07:00Z — event is 8 min out, still > now and <= now+16min.
const eventDtstart = new Date('2026-06-15T10:15:00Z')
const recoveryNow = new Date('2026-06-15T10:07:00Z')
const eventDtstart = new Date('2026-06-15T10:15:00Z');
const recoveryNow = new Date('2026-06-15T10:07:00Z');
vi.setSystemTime(recoveryNow)
vi.setSystemTime(recoveryNow);
const sub = { id: 1, userId: 1, endpoint: 'https://push.example.com/missed', p256dh: 'k', auth: 'a' }
const sub = {
id: 1,
userId: 1,
endpoint: 'https://push.example.com/missed',
p256dh: 'k',
auth: 'a',
};
const row = makeEventRow({
uid: 'missed-tick-uid',
dtstartUtc: eventDtstart,
@@ -232,63 +253,85 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () =>
subEndpoint: sub.endpoint,
subP256dh: sub.p256dh,
subAuth: sub.auth,
})
});
vi.mocked(db.select).mockReturnValue(makeSelectMock([row]))
await runReminderCheck(recoveryNow)
vi.mocked(db.select).mockReturnValue(makeSelectMock([row]));
await runReminderCheck(recoveryNow);
// Reminder must fire even though the ideal-mark tick was skipped
expect(vi.mocked(dispatchPush).mock.calls.length).toBeGreaterThanOrEqual(1)
})
expect(vi.mocked(dispatchPush).mock.calls.length).toBeGreaterThanOrEqual(1);
});
it('dispatches to all subscribers for a single event (fan-out)', async () => {
const now = new Date('2026-06-15T10:00:00Z')
vi.setSystemTime(now)
const now = new Date('2026-06-15T10:00:00Z');
vi.setSystemTime(now);
const { db } = await import('../../src/db/client.js')
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
const { db } = await import('../../src/db/client.js');
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
const dtstartUtc = new Date('2026-06-15T10:15:00Z')
const dtstartUtc = new Date('2026-06-15T10:15:00Z');
const rows = [
makeEventRow({ uid: 'fanout-uid', dtstartUtc, subId: 1, subUserId: 1, subEndpoint: 'https://push.example.com/1', subP256dh: 'k1', subAuth: 'a1' }),
makeEventRow({ uid: 'fanout-uid', dtstartUtc, subId: 2, subUserId: 2, subEndpoint: 'https://push.example.com/2', subP256dh: 'k2', subAuth: 'a2' }),
]
makeEventRow({
uid: 'fanout-uid',
dtstartUtc,
subId: 1,
subUserId: 1,
subEndpoint: 'https://push.example.com/1',
subP256dh: 'k1',
subAuth: 'a1',
}),
makeEventRow({
uid: 'fanout-uid',
dtstartUtc,
subId: 2,
subUserId: 2,
subEndpoint: 'https://push.example.com/2',
subP256dh: 'k2',
subAuth: 'a2',
}),
];
vi.mocked(db.select).mockReturnValue(makeSelectMock(rows))
await runReminderCheck(now)
vi.mocked(db.select).mockReturnValue(makeSelectMock(rows));
await runReminderCheck(now);
// One dispatch per subscriber
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2)
})
})
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2);
});
});
// ── WR-01: mark-sent after dispatch ──────────────────────────────────────────
describe('reminderScheduler — WR-01: mark-sent after dispatch', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.resetModules()
})
vi.useFakeTimers();
vi.resetModules();
});
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
vi.useRealTimers();
vi.clearAllMocks();
});
it('does not re-dispatch the same uid after a successful dispatch in the same module instance', async () => {
// WR-01: sentReminders.set(uid) is called AFTER the fan-out loop completes.
// After a successful dispatch the uid is recorded; a second run with the same
// module instance must not re-fire for the same uid.
const { db } = await import('../../src/db/client.js')
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
const { db } = await import('../../src/db/client.js');
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
const now = new Date('2026-06-15T10:00:00Z')
vi.setSystemTime(now)
const now = new Date('2026-06-15T10:00:00Z');
vi.setSystemTime(now);
const sub = { id: 1, userId: 1, endpoint: 'https://push.example.com/1', p256dh: 'k', auth: 'a' }
const sub = {
id: 1,
userId: 1,
endpoint: 'https://push.example.com/1',
p256dh: 'k',
auth: 'a',
};
const eventRow = makeEventRow({
uid: 'wr01-dedup-uid',
title: 'WR-01 dedup event',
@@ -298,43 +341,49 @@ describe('reminderScheduler — WR-01: mark-sent after dispatch', () => {
subEndpoint: sub.endpoint,
subP256dh: sub.p256dh,
subAuth: sub.auth,
})
});
vi.mocked(dispatchPush).mockResolvedValue(undefined)
vi.mocked(dispatchPush).mockResolvedValue(undefined);
// First run — dispatch succeeds; uid is recorded after the loop
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]))
await runReminderCheck(now)
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1) // dispatched once
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
await runReminderCheck(now);
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // dispatched once
// Second run with same now — uid is in sentReminders; should NOT re-dispatch
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]))
await runReminderCheck(now)
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1) // still 1 — deduped
})
})
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
await runReminderCheck(now);
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // still 1 — deduped
});
});
// ── CR-01: started-event pruning ─────────────────────────────────────────────
describe('reminderScheduler — CR-01: sentReminders Map pruning', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.resetModules()
})
vi.useFakeTimers();
vi.resetModules();
});
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
vi.useRealTimers();
vi.clearAllMocks();
});
it('prunes started-event entries so a different future event with the same uid can fire again', async () => {
const { db } = await import('../../src/db/client.js')
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
const { db } = await import('../../src/db/client.js');
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
const sub = { id: 1, userId: 1, endpoint: 'https://push.example.com/1', p256dh: 'k', auth: 'a' }
const eventDtstart = new Date('2026-06-15T10:15:00Z')
const uid = 'prune-test-uid'
const sub = {
id: 1,
userId: 1,
endpoint: 'https://push.example.com/1',
p256dh: 'k',
auth: 'a',
};
const eventDtstart = new Date('2026-06-15T10:15:00Z');
const uid = 'prune-test-uid';
const eventRow = makeEventRow({
uid,
@@ -345,65 +394,81 @@ describe('reminderScheduler — CR-01: sentReminders Map pruning', () => {
subEndpoint: sub.endpoint,
subP256dh: sub.p256dh,
subAuth: sub.auth,
})
});
// Tick at t0 (now=10:00): event 15 min out — fires
const t0 = new Date('2026-06-15T10:00:00Z')
vi.setSystemTime(t0)
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]))
await runReminderCheck(t0)
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1) // fired
const t0 = new Date('2026-06-15T10:00:00Z');
vi.setSystemTime(t0);
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
await runReminderCheck(t0);
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // fired
// Same t0 — uid still in Map — must NOT re-fire
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]))
await runReminderCheck(t0)
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1) // still 1
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
await runReminderCheck(t0);
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // still 1
// Advance past dtstart (now=10:20): event has started; CR-01 prunes the uid entry.
// The SQL WHERE gt(dtstartUtc, now) would return no rows, so simulate empty.
const tPast = new Date('2026-06-15T10:20:00Z')
vi.setSystemTime(tPast)
vi.mocked(db.select).mockReturnValue(makeSelectMock([]))
await runReminderCheck(tPast)
const tPast = new Date('2026-06-15T10:20:00Z');
vi.setSystemTime(tPast);
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
await runReminderCheck(tPast);
// Pruning fires; dispatch count stays at 1
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1)
})
})
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1);
});
});
// ── T-05-19: per-subscription error isolation ────────────────────────────────
describe('reminderScheduler — T-05-19: per-subscription error isolation', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.resetModules()
})
vi.useFakeTimers();
vi.resetModules();
});
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
vi.useRealTimers();
vi.clearAllMocks();
});
it('continues dispatching to remaining subscribers when one subscription throws', async () => {
const now = new Date('2026-06-15T10:00:00Z')
vi.setSystemTime(now)
const now = new Date('2026-06-15T10:00:00Z');
vi.setSystemTime(now);
const { db } = await import('../../src/db/client.js')
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
const { db } = await import('../../src/db/client.js');
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
const dtstartUtc = new Date('2026-06-15T10:15:00Z')
const dtstartUtc = new Date('2026-06-15T10:15:00Z');
const rows = [
makeEventRow({ uid: 'iso-uid', dtstartUtc, subId: 1, subUserId: 1, subEndpoint: 'https://push.example.com/1', subP256dh: 'k1', subAuth: 'a1' }),
makeEventRow({ uid: 'iso-uid', dtstartUtc, subId: 2, subUserId: 2, subEndpoint: 'https://push.example.com/2', subP256dh: 'k2', subAuth: 'a2' }),
]
makeEventRow({
uid: 'iso-uid',
dtstartUtc,
subId: 1,
subUserId: 1,
subEndpoint: 'https://push.example.com/1',
subP256dh: 'k1',
subAuth: 'a1',
}),
makeEventRow({
uid: 'iso-uid',
dtstartUtc,
subId: 2,
subUserId: 2,
subEndpoint: 'https://push.example.com/2',
subP256dh: 'k2',
subAuth: 'a2',
}),
];
vi.mocked(db.select).mockReturnValue(makeSelectMock(rows))
vi.mocked(db.select).mockReturnValue(makeSelectMock(rows));
// First subscriber throws; second should still be attempted
vi.mocked(dispatchPush)
.mockRejectedValueOnce(new Error('network error'))
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
await expect(runReminderCheck(now)).resolves.toBeUndefined()
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2) // both attempted
})
})
await expect(runReminderCheck(now)).resolves.toBeUndefined();
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2); // both attempted
});
});
+197 -180
View File
@@ -10,25 +10,25 @@
* - Calendar ctag/syncToken updated after sync
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
SAMPLE_VEVENT_TIMED,
SAMPLE_VEVENT_ALLDAY,
SAMPLE_VEVENT_RECURRING_TIMED,
SAMPLE_VEVENT_RECURRING_ALLDAY,
} from '../helpers/db.js'
} from '../helpers/db.js';
// Track calls for assertions
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue([{ insertId: 1 }])
const mockValues = vi.fn().mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate })
const mockInsert = vi.fn().mockReturnValue({ values: mockValues })
const mockLimit = vi.fn().mockResolvedValue([{ id: 42 }])
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit })
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere })
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom })
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const mockValues = vi.fn().mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate });
const mockInsert = vi.fn().mockReturnValue({ values: mockValues });
const mockLimit = vi.fn().mockResolvedValue([{ id: 42 }]);
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit });
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere });
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom });
// Prune chain: db.delete(calendarEvents).where(...)
const mockDeleteWhere = vi.fn().mockResolvedValue([])
const mockDelete = vi.fn().mockReturnValue({ where: mockDeleteWhere })
const mockDeleteWhere = vi.fn().mockResolvedValue([]);
const mockDelete = vi.fn().mockReturnValue({ where: mockDeleteWhere });
// Mock the db singleton at module level (Vitest hoisting)
vi.mock('../../src/db/client.js', () => ({
@@ -37,334 +37,350 @@ vi.mock('../../src/db/client.js', () => ({
select: mockSelect,
delete: mockDelete,
},
}))
}));
describe('syncCalendar', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.clearAllMocks();
// Reset mock implementations after clearAllMocks
mockOnDuplicateKeyUpdate.mockResolvedValue([{ insertId: 1 }])
mockValues.mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate })
mockInsert.mockReturnValue({ values: mockValues })
mockLimit.mockResolvedValue([{ id: 42 }])
mockWhere.mockReturnValue({ limit: mockLimit })
mockFrom.mockReturnValue({ where: mockWhere })
mockSelect.mockReturnValue({ from: mockFrom })
mockDeleteWhere.mockResolvedValue([])
mockDelete.mockReturnValue({ where: mockDeleteWhere })
})
mockOnDuplicateKeyUpdate.mockResolvedValue([{ insertId: 1 }]);
mockValues.mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate });
mockInsert.mockReturnValue({ values: mockValues });
mockLimit.mockResolvedValue([{ id: 42 }]);
mockWhere.mockReturnValue({ limit: mockLimit });
mockFrom.mockReturnValue({ where: mockWhere });
mockSelect.mockReturnValue({ from: mockFrom });
mockDeleteWhere.mockResolvedValue([]);
mockDelete.mockReturnValue({ where: mockDeleteWhere });
});
it('stores all-day events with dtstart_date (DATE) and dtstart_utc=NULL', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const { syncCalendar } = await import('../../src/broker/sync.js');
const mockClient = {
fetchCalendarObjects: vi.fn().mockResolvedValue([
{ data: SAMPLE_VEVENT_ALLDAY, etag: '"etag-allday"', url: '/cal/allday.ics' },
]),
}
fetchCalendarObjects: vi
.fn()
.mockResolvedValue([
{ data: SAMPLE_VEVENT_ALLDAY, etag: '"etag-allday"', url: '/cal/allday.ics' },
]),
};
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test Calendar',
ctag: 'ctag-v1',
syncToken: null,
}
};
await syncCalendar(mockClient as never, mockDavCal as never, 1)
await syncCalendar(mockClient as never, mockDavCal as never, 1);
// insert called twice: calendars + calendarEvents
expect(mockInsert).toHaveBeenCalledTimes(2)
expect(mockInsert).toHaveBeenCalledTimes(2);
// Event insert: second call's values arg
const eventValuesArg = mockValues.mock.calls[1][0]
expect(eventValuesArg.allDay).toBe(true)
expect(eventValuesArg.dtstartDate).toBeTruthy()
expect(eventValuesArg.dtstartUtc).toBeNull()
})
const eventValuesArg = mockValues.mock.calls[1][0];
expect(eventValuesArg.allDay).toBe(true);
expect(eventValuesArg.dtstartDate).toBeTruthy();
expect(eventValuesArg.dtstartUtc).toBeNull();
});
it('stores timed events with dtstart_utc (TIMESTAMP) and dtstart_date=NULL', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const { syncCalendar } = await import('../../src/broker/sync.js');
const mockClient = {
fetchCalendarObjects: vi.fn().mockResolvedValue([
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag-timed"', url: '/cal/timed.ics' },
]),
}
fetchCalendarObjects: vi
.fn()
.mockResolvedValue([
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag-timed"', url: '/cal/timed.ics' },
]),
};
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test Calendar',
ctag: 'ctag-v1',
syncToken: null,
}
};
await syncCalendar(mockClient as never, mockDavCal as never, 1)
await syncCalendar(mockClient as never, mockDavCal as never, 1);
expect(mockInsert).toHaveBeenCalledTimes(2)
const eventValuesArg = mockValues.mock.calls[1][0]
expect(eventValuesArg.allDay).toBe(false)
expect(eventValuesArg.dtstartUtc).toBeInstanceOf(Date)
expect(eventValuesArg.dtstartDate).toBeNull()
})
expect(mockInsert).toHaveBeenCalledTimes(2);
const eventValuesArg = mockValues.mock.calls[1][0];
expect(eventValuesArg.allDay).toBe(false);
expect(eventValuesArg.dtstartUtc).toBeInstanceOf(Date);
expect(eventValuesArg.dtstartDate).toBeNull();
});
it('sets allDay=true for all-day events, allDay=false for timed', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const { syncCalendar } = await import('../../src/broker/sync.js');
const mockClient = {
fetchCalendarObjects: vi.fn().mockResolvedValue([
{ data: SAMPLE_VEVENT_ALLDAY, etag: '"allday"', url: '/allday.ics' },
]),
}
fetchCalendarObjects: vi
.fn()
.mockResolvedValue([{ data: SAMPLE_VEVENT_ALLDAY, etag: '"allday"', url: '/allday.ics' }]),
};
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Test/',
displayName: 'Test',
ctag: null,
syncToken: null,
}
};
await syncCalendar(mockClient as never, mockDavCal as never, 1)
const eventArg = mockValues.mock.calls[1][0]
expect(eventArg.allDay).toBe(true)
})
await syncCalendar(mockClient as never, mockDavCal as never, 1);
const eventArg = mockValues.mock.calls[1][0];
expect(eventArg.allDay).toBe(true);
});
it('upserts on duplicate UID within the same calendar (onDuplicateKeyUpdate called for events)', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const { syncCalendar } = await import('../../src/broker/sync.js');
const mockClient = {
fetchCalendarObjects: vi.fn().mockResolvedValue([
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag1"', url: '/timed.ics' },
]),
}
fetchCalendarObjects: vi
.fn()
.mockResolvedValue([{ data: SAMPLE_VEVENT_TIMED, etag: '"etag1"', url: '/timed.ics' }]),
};
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test',
ctag: 'v2',
syncToken: null,
}
};
await syncCalendar(mockClient as never, mockDavCal as never, 1)
await syncCalendar(mockClient as never, mockDavCal as never, 1);
// onDuplicateKeyUpdate must be called for both the calendar upsert and the event upsert
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(2)
})
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(2);
});
it('stores the raw VEVENT blob in rawVevent column', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const { syncCalendar } = await import('../../src/broker/sync.js');
const mockClient = {
fetchCalendarObjects: vi.fn().mockResolvedValue([
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag"', url: '/timed.ics' },
]),
}
fetchCalendarObjects: vi
.fn()
.mockResolvedValue([{ data: SAMPLE_VEVENT_TIMED, etag: '"etag"', url: '/timed.ics' }]),
};
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test',
ctag: 'v1',
syncToken: null,
}
};
await syncCalendar(mockClient as never, mockDavCal as never, 1)
const eventArg = mockValues.mock.calls[1][0]
expect(eventArg.rawVevent).toBe(SAMPLE_VEVENT_TIMED)
})
await syncCalendar(mockClient as never, mockDavCal as never, 1);
const eventArg = mockValues.mock.calls[1][0];
expect(eventArg.rawVevent).toBe(SAMPLE_VEVENT_TIMED);
});
it('sets hasRrule=true for a VEVENT with RRULE (timed recurring)', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const { syncCalendar } = await import('../../src/broker/sync.js');
const mockClient = {
fetchCalendarObjects: vi.fn().mockResolvedValue([
{ data: SAMPLE_VEVENT_RECURRING_TIMED, etag: '"etag-rrule"', url: '/rrule.ics' },
]),
}
fetchCalendarObjects: vi
.fn()
.mockResolvedValue([
{ data: SAMPLE_VEVENT_RECURRING_TIMED, etag: '"etag-rrule"', url: '/rrule.ics' },
]),
};
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test Calendar',
ctag: 'ctag-v1',
syncToken: null,
}
};
await syncCalendar(mockClient as never, mockDavCal as never, 1)
await syncCalendar(mockClient as never, mockDavCal as never, 1);
expect(mockInsert).toHaveBeenCalledTimes(2)
const eventValuesArg = mockValues.mock.calls[1][0]
expect(eventValuesArg.hasRrule).toBe(true)
})
expect(mockInsert).toHaveBeenCalledTimes(2);
const eventValuesArg = mockValues.mock.calls[1][0];
expect(eventValuesArg.hasRrule).toBe(true);
});
it('sets hasRrule=true for an all-day VEVENT with RRULE (all-day recurring)', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const { syncCalendar } = await import('../../src/broker/sync.js');
const mockClient = {
fetchCalendarObjects: vi.fn().mockResolvedValue([
{ data: SAMPLE_VEVENT_RECURRING_ALLDAY, etag: '"etag-rrule-allday"', url: '/rrule-allday.ics' },
{
data: SAMPLE_VEVENT_RECURRING_ALLDAY,
etag: '"etag-rrule-allday"',
url: '/rrule-allday.ics',
},
]),
}
};
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test Calendar',
ctag: 'ctag-v1',
syncToken: null,
}
};
await syncCalendar(mockClient as never, mockDavCal as never, 1)
await syncCalendar(mockClient as never, mockDavCal as never, 1);
expect(mockInsert).toHaveBeenCalledTimes(2)
const eventValuesArg = mockValues.mock.calls[1][0]
expect(eventValuesArg.hasRrule).toBe(true)
})
expect(mockInsert).toHaveBeenCalledTimes(2);
const eventValuesArg = mockValues.mock.calls[1][0];
expect(eventValuesArg.hasRrule).toBe(true);
});
it('sets hasRrule=false for a non-recurring timed VEVENT', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const { syncCalendar } = await import('../../src/broker/sync.js');
const mockClient = {
fetchCalendarObjects: vi.fn().mockResolvedValue([
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag-oneoff"', url: '/oneoff.ics' },
]),
}
fetchCalendarObjects: vi
.fn()
.mockResolvedValue([
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag-oneoff"', url: '/oneoff.ics' },
]),
};
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test Calendar',
ctag: 'ctag-v1',
syncToken: null,
}
};
await syncCalendar(mockClient as never, mockDavCal as never, 1)
await syncCalendar(mockClient as never, mockDavCal as never, 1);
expect(mockInsert).toHaveBeenCalledTimes(2)
const eventValuesArg = mockValues.mock.calls[1][0]
expect(eventValuesArg.hasRrule).toBe(false)
})
expect(mockInsert).toHaveBeenCalledTimes(2);
const eventValuesArg = mockValues.mock.calls[1][0];
expect(eventValuesArg.hasRrule).toBe(false);
});
it('includes hasRrule in onDuplicateKeyUpdate set so re-syncs self-heal the flag', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const { syncCalendar } = await import('../../src/broker/sync.js');
const mockClient = {
fetchCalendarObjects: vi.fn().mockResolvedValue([
{ data: SAMPLE_VEVENT_RECURRING_TIMED, etag: '"etag-v2"', url: '/rrule.ics' },
]),
}
fetchCalendarObjects: vi
.fn()
.mockResolvedValue([
{ data: SAMPLE_VEVENT_RECURRING_TIMED, etag: '"etag-v2"', url: '/rrule.ics' },
]),
};
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test Calendar',
ctag: 'ctag-v2',
syncToken: null,
}
};
await syncCalendar(mockClient as never, mockDavCal as never, 1)
await syncCalendar(mockClient as never, mockDavCal as never, 1);
// The second onDuplicateKeyUpdate call is for the event upsert
const eventUpdateArg = mockOnDuplicateKeyUpdate.mock.calls[1][0]
expect(eventUpdateArg.set).toHaveProperty('hasRrule', true)
})
const eventUpdateArg = mockOnDuplicateKeyUpdate.mock.calls[1][0];
expect(eventUpdateArg.set).toHaveProperty('hasRrule', true);
});
it('BUG B: scopes the calendar-row select to (userId, url), not url alone', async () => {
// Capture the predicate passed to db.select().from(calendars).where(...).limit(1).
const capturedWhere: unknown[] = []
const capturedWhere: unknown[] = [];
mockWhere.mockImplementation((pred: unknown) => {
capturedWhere.push(pred)
return { limit: mockLimit }
})
capturedWhere.push(pred);
return { limit: mockLimit };
});
const { syncCalendar } = await import('../../src/broker/sync.js')
const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]) }
const { syncCalendar } = await import('../../src/broker/sync.js');
const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]) };
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test',
ctag: 'v1',
syncToken: null,
}
};
await syncCalendar(mockClient as never, mockDavCal as never, 99)
await syncCalendar(mockClient as never, mockDavCal as never, 99);
expect(capturedWhere.length).toBeGreaterThan(0)
expect(capturedWhere.length).toBeGreaterThan(0);
const serialized = JSON.stringify(capturedWhere[0], (_k, v) =>
typeof v === 'object' && v !== null && 'name' in (v as Record<string, unknown>)
? (v as { name?: unknown }).name
: v,
)
expect(serialized).toContain('user_id')
expect(serialized).toContain('url')
})
);
expect(serialized).toContain('user_id');
expect(serialized).toContain('url');
});
it('BUG B: calendar upsert is idempotent — onDuplicateKeyUpdate fires for the calendar row', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]) }
const { syncCalendar } = await import('../../src/broker/sync.js');
const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]) };
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test',
ctag: 'v1',
syncToken: null,
}
await syncCalendar(mockClient as never, mockDavCal as never, 1)
};
await syncCalendar(mockClient as never, mockDavCal as never, 1);
// The calendar insert (call 0) must use onDuplicateKeyUpdate so the (userId,url)
// unique key makes re-polls update-in-place instead of inserting duplicate rows.
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalled()
const calUpdateArg = mockOnDuplicateKeyUpdate.mock.calls[0][0]
expect(calUpdateArg.set).toHaveProperty('ctag')
})
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalled();
const calUpdateArg = mockOnDuplicateKeyUpdate.mock.calls[0][0];
expect(calUpdateArg.set).toHaveProperty('ctag');
});
it('updates the calendar ctag/syncToken after a successful sync', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const { syncCalendar } = await import('../../src/broker/sync.js');
const mockClient = {
fetchCalendarObjects: vi.fn().mockResolvedValue([]),
}
};
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test Calendar',
ctag: 'new-ctag-123',
syncToken: 'sync-token-abc',
}
};
await syncCalendar(mockClient as never, mockDavCal as never, 1)
await syncCalendar(mockClient as never, mockDavCal as never, 1);
// Calendar insert values must include the new ctag and syncToken
const calValuesArg = mockValues.mock.calls[0][0]
expect(calValuesArg.ctag).toBe('new-ctag-123')
expect(calValuesArg.syncToken).toBe('sync-token-abc')
})
const calValuesArg = mockValues.mock.calls[0][0];
expect(calValuesArg.ctag).toBe('new-ctag-123');
expect(calValuesArg.syncToken).toBe('sync-token-abc');
});
// Regression: deletes must be reconciled out of the cache. Before this fix,
// syncCalendar only upserted present events, so a deleted event lingered in
// calendar_events forever and the UI showed a ghost that "wouldn't delete".
it('prunes cached events whose uid is absent from the server (delete reconciliation)', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const { syncCalendar } = await import('../../src/broker/sync.js');
// Server returns ONE timed event; any other cached uid for this calendar must be pruned.
const mockClient = {
fetchCalendarObjects: vi.fn().mockResolvedValue([
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag-timed"', url: '/cal/timed.ics' },
]),
}
fetchCalendarObjects: vi
.fn()
.mockResolvedValue([
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag-timed"', url: '/cal/timed.ics' },
]),
};
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test Calendar',
ctag: 'ctag-v2',
syncToken: null,
}
};
await syncCalendar(mockClient as never, mockDavCal as never, 1)
await syncCalendar(mockClient as never, mockDavCal as never, 1);
// A prune DELETE must run, scoped by calendarId AND excluding the seen uid(s).
expect(mockDelete).toHaveBeenCalledTimes(1)
expect(mockDeleteWhere).toHaveBeenCalledTimes(1)
})
expect(mockDelete).toHaveBeenCalledTimes(1);
expect(mockDeleteWhere).toHaveBeenCalledTimes(1);
});
it('prunes the entire calendar cache when the server returns zero events', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const { syncCalendar } = await import('../../src/broker/sync.js');
const mockClient = {
fetchCalendarObjects: vi.fn().mockResolvedValue([]),
}
};
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test Calendar',
ctag: 'ctag-empty',
syncToken: null,
}
};
await syncCalendar(mockClient as never, mockDavCal as never, 1)
await syncCalendar(mockClient as never, mockDavCal as never, 1);
// Empty server result → prune-all DELETE (scoped to this calendar id only).
expect(mockDelete).toHaveBeenCalledTimes(1)
expect(mockDeleteWhere).toHaveBeenCalledTimes(1)
})
expect(mockDelete).toHaveBeenCalledTimes(1);
expect(mockDeleteWhere).toHaveBeenCalledTimes(1);
});
// NEW-WR-01: When the server returns zero events (whole-cache clear), the
// onChanges callback must still receive a 'delete' change for each cached row
@@ -372,7 +388,7 @@ describe('syncCalendar', () => {
// ran inside the seenUids.length > 0 branch, so bulk/clear deletions silently
// dropped all delete change events.
it('NEW-WR-01: emits delete change events for each cached row when server returns zero events', async () => {
const { syncCalendar } = await import('../../src/broker/sync.js')
const { syncCalendar } = await import('../../src/broker/sync.js');
// First mockWhere call: calendar row lookup (ends in .limit(1)) → { limit: mockLimit }
// Second mockWhere call: pending-delete pre-capture (direct await, no .limit()) →
@@ -383,32 +399,33 @@ describe('syncCalendar', () => {
// 1. db.select().from(calendars).where(...).limit(1) — calendar row lookup
// 2. db.select({uid,title}).from(calendarEvents).where(...) — pending-delete capture (awaited directly)
mockWhere
.mockReturnValueOnce({ limit: mockLimit }) // call 1: calendar row lookup
.mockResolvedValueOnce([ // call 2: pending-delete capture
.mockReturnValueOnce({ limit: mockLimit }) // call 1: calendar row lookup
.mockResolvedValueOnce([
// call 2: pending-delete capture
{ uid: 'uid-to-delete-1', title: 'Event A' },
{ uid: 'uid-to-delete-2', title: 'Event B' },
])
]);
const mockClient = {
fetchCalendarObjects: vi.fn().mockResolvedValue([]),
}
};
const mockDavCal = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
displayName: 'Test Calendar',
ctag: 'ctag-empty',
syncToken: null,
}
};
const collectedChanges: import('../../src/lib/eventChangeDispatcher.js').EventChange[] = []
const collectedChanges: import('../../src/lib/eventChangeDispatcher.js').EventChange[] = [];
const onChanges = (changes: import('../../src/lib/eventChangeDispatcher.js').EventChange[]) => {
collectedChanges.push(...changes)
}
collectedChanges.push(...changes);
};
await syncCalendar(mockClient as never, mockDavCal as never, 1, onChanges)
await syncCalendar(mockClient as never, mockDavCal as never, 1, onChanges);
// The callback must have received one delete change per cached row.
expect(collectedChanges).toHaveLength(2)
expect(collectedChanges[0]).toMatchObject({ uid: 'uid-to-delete-1', operation: 'delete' })
expect(collectedChanges[1]).toMatchObject({ uid: 'uid-to-delete-2', operation: 'delete' })
})
})
expect(collectedChanges).toHaveLength(2);
expect(collectedChanges[0]).toMatchObject({ uid: 'uid-to-delete-1', operation: 'delete' });
expect(collectedChanges[1]).toMatchObject({ uid: 'uid-to-delete-2', operation: 'delete' });
});
});
+62 -62
View File
@@ -12,12 +12,12 @@
* They will turn GREEN in Plan 03-02 when the implementation is added.
*/
import { describe, it, expect } from 'vitest'
import { describe, it, expect } from 'vitest';
// This import fails (RED) — broker/vevent.ts does not exist yet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore intentional RED import
import { buildVeventString } from '../../src/broker/vevent.js'
import { buildVeventString } from '../../src/broker/vevent.js';
describe('buildVeventString', () => {
it('produces a VCALENDAR string containing a VEVENT for a timed event', () => {
@@ -26,16 +26,16 @@ describe('buildVeventString', () => {
allDay: false,
dtstart: new Date('2026-06-10T09:00:00Z'),
dtend: new Date('2026-06-10T09:30:00Z'),
})
});
expect(result).toHaveProperty('uid')
expect(result).toHaveProperty('icsString')
expect(result.icsString).toContain('BEGIN:VCALENDAR')
expect(result.icsString).toContain('BEGIN:VEVENT')
expect(result.icsString).toContain('SUMMARY:Team standup')
expect(result.icsString).toContain('END:VEVENT')
expect(result.icsString).toContain('END:VCALENDAR')
})
expect(result).toHaveProperty('uid');
expect(result).toHaveProperty('icsString');
expect(result.icsString).toContain('BEGIN:VCALENDAR');
expect(result.icsString).toContain('BEGIN:VEVENT');
expect(result.icsString).toContain('SUMMARY:Team standup');
expect(result.icsString).toContain('END:VEVENT');
expect(result.icsString).toContain('END:VCALENDAR');
});
it('produces DTSTART with Z suffix (UTC) for a timed event — not TZID', () => {
const result = buildVeventString({
@@ -43,12 +43,12 @@ describe('buildVeventString', () => {
allDay: false,
dtstart: new Date('2026-06-10T14:00:00Z'),
dtend: new Date('2026-06-10T15:00:00Z'),
})
});
// D-13 timed contract: DATETIME in UTC → 'Z' suffix, no TZID
expect(result.icsString).toMatch(/DTSTART:\d{8}T\d{6}Z/)
expect(result.icsString).not.toMatch(/DTSTART;TZID=/)
})
expect(result.icsString).toMatch(/DTSTART:\d{8}T\d{6}Z/);
expect(result.icsString).not.toMatch(/DTSTART;TZID=/);
});
it('produces DTSTART as DATE (no time, no TZID) for an all-day event', () => {
const result = buildVeventString({
@@ -56,16 +56,16 @@ describe('buildVeventString', () => {
allDay: true,
dtstart: '2026-06-15',
dtend: '2026-06-16',
})
});
// D-13 all-day contract: VALUE=DATE, no time component, no TZID
// ical.js represents DATE as DTSTART;VALUE=DATE:YYYYMMDD
expect(result.icsString).toMatch(/DTSTART[^:]*:20260615/)
expect(result.icsString).toMatch(/DTSTART[^:]*:20260615/);
// Must NOT contain a time component (no 'T' after the date)
expect(result.icsString).not.toMatch(/DTSTART[^:]*:20260615T/)
expect(result.icsString).not.toMatch(/DTSTART[^:]*:20260615T/);
// Must NOT contain TZID on the DTSTART property
expect(result.icsString).not.toMatch(/DTSTART;TZID=/)
})
expect(result.icsString).not.toMatch(/DTSTART;TZID=/);
});
it('includes an RRULE property when rruleString is provided (CAL-07)', () => {
const result = buildVeventString({
@@ -74,10 +74,10 @@ describe('buildVeventString', () => {
dtstart: new Date('2026-06-09T10:00:00Z'),
dtend: new Date('2026-06-09T11:00:00Z'),
rruleString: 'FREQ=WEEKLY;BYDAY=MO',
})
});
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;BYDAY=MO')
})
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;BYDAY=MO');
});
it('does NOT include RRULE when rruleString is omitted', () => {
const result = buildVeventString({
@@ -85,10 +85,10 @@ describe('buildVeventString', () => {
allDay: false,
dtstart: new Date('2026-06-10T12:00:00Z'),
dtend: new Date('2026-06-10T13:00:00Z'),
})
});
expect(result.icsString).not.toContain('RRULE:')
})
expect(result.icsString).not.toContain('RRULE:');
});
// D-06: RRULE COUNT — verified ical.js 2.2.1 output
it('serializes COUNT in RRULE for a timed event (D-06)', () => {
@@ -98,9 +98,9 @@ describe('buildVeventString', () => {
dtstart: new Date('2026-06-10T09:00:00Z'),
dtend: new Date('2026-06-10T10:00:00Z'),
rruleString: 'FREQ=WEEKLY;COUNT=5',
})
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;COUNT=5')
})
});
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;COUNT=5');
});
// D-06: RRULE UNTIL DATE form — all-day event must NOT contain T235959Z
it('serializes UNTIL as DATE form for all-day events (D-06)', () => {
@@ -110,10 +110,10 @@ describe('buildVeventString', () => {
dtstart: '2026-06-10',
dtend: '2026-06-11',
rruleString: 'FREQ=DAILY;UNTIL=20260630',
})
expect(result.icsString).toContain('RRULE:FREQ=DAILY;UNTIL=20260630')
expect(result.icsString).not.toContain('T235959Z')
})
});
expect(result.icsString).toContain('RRULE:FREQ=DAILY;UNTIL=20260630');
expect(result.icsString).not.toContain('T235959Z');
});
// D-06: RRULE UNTIL DATETIME UTC form — timed event
it('serializes UNTIL as DATETIME UTC form for timed events (D-06)', () => {
@@ -123,23 +123,23 @@ describe('buildVeventString', () => {
dtstart: new Date('2026-06-10T09:00:00Z'),
dtend: new Date('2026-06-10T10:00:00Z'),
rruleString: 'FREQ=WEEKLY;UNTIL=20260630T235959Z',
})
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z')
})
});
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z');
});
it('uses the provided uid when given', () => {
const uid = 'custom-uid-001@familysync'
const uid = 'custom-uid-001@familysync';
const result = buildVeventString({
uid,
summary: 'Test event',
allDay: false,
dtstart: new Date('2026-06-10T09:00:00Z'),
dtend: new Date('2026-06-10T10:00:00Z'),
})
});
expect(result.uid).toBe(uid)
expect(result.icsString).toContain(`UID:${uid}`)
})
expect(result.uid).toBe(uid);
expect(result.icsString).toContain(`UID:${uid}`);
});
it('generates a uid when none is provided', () => {
const result = buildVeventString({
@@ -147,12 +147,12 @@ describe('buildVeventString', () => {
allDay: false,
dtstart: new Date('2026-06-10T09:00:00Z'),
dtend: new Date('2026-06-10T10:00:00Z'),
})
});
expect(result.uid).toBeTruthy()
expect(result.uid.length).toBeGreaterThan(10)
})
})
expect(result.uid).toBeTruthy();
expect(result.uid.length).toBeGreaterThan(10);
});
});
describe('buildVeventString — D-13 form-parsed contract', () => {
it('timed event: produces BEGIN:VCALENDAR, SUMMARY, UID, timed DTSTART (Z suffix), and DTEND', () => {
@@ -163,16 +163,16 @@ describe('buildVeventString — D-13 form-parsed contract', () => {
allDay: false,
dtstart: new Date('2026-06-10T12:00:00Z'),
dtend: new Date('2026-06-10T13:00:00Z'),
})
});
expect(result.icsString).toContain('BEGIN:VCALENDAR')
expect(result.icsString).toContain('SUMMARY:Lunch')
expect(result.icsString).toContain('UID:u1@familysync')
expect(result.icsString).toContain('BEGIN:VCALENDAR');
expect(result.icsString).toContain('SUMMARY:Lunch');
expect(result.icsString).toContain('UID:u1@familysync');
// D-13 timed contract: DATETIME in UTC → 'Z' suffix, no TZID
expect(result.icsString).toMatch(/DTSTART:\d{8}T\d{6}Z/)
expect(result.icsString).toMatch(/DTSTART:\d{8}T\d{6}Z/);
// DTEND must be present
expect(result.icsString).toMatch(/DTEND:\d{8}T\d{6}Z/)
})
expect(result.icsString).toMatch(/DTEND:\d{8}T\d{6}Z/);
});
it('single-day all-day event: DTSTART is DATE format and DTEND = DTSTART + 1 day (RFC-5545 exclusive end, WR-04)', () => {
// Simulates a single-day all-day event where start and end are the same calendar day.
@@ -182,17 +182,17 @@ describe('buildVeventString — D-13 form-parsed contract', () => {
allDay: true,
dtstart: '2026-06-10',
dtend: '2026-06-10',
})
});
// DTSTART must be DATE format (no time component, no TZID) — D-13 all-day contract
expect(result.icsString).toMatch(/DTSTART[^:]*:20260610/)
expect(result.icsString).not.toMatch(/DTSTART[^:]*:20260610T/)
expect(result.icsString).toMatch(/DTSTART[^:]*:20260610/);
expect(result.icsString).not.toMatch(/DTSTART[^:]*:20260610T/);
// WR-04: DTEND must be DTSTART + 1 day (RFC-5545 exclusive end)
expect(result.icsString).toMatch(/DTEND[^:]*:20260611/)
expect(result.icsString).toMatch(/DTEND[^:]*:20260611/);
// DTEND date string must NOT equal DTSTART date string (owning-boundary assertion)
const dtendMatch = result.icsString.match(/DTEND[^:]*:(\d{8})/)
const dtstartMatch = result.icsString.match(/DTSTART[^:]*:(\d{8})/)
expect(dtendMatch?.[1]).not.toBe(dtstartMatch?.[1])
})
})
const dtendMatch = result.icsString.match(/DTEND[^:]*:(\d{8})/);
const dtstartMatch = result.icsString.match(/DTSTART[^:]*:(\d{8})/);
expect(dtendMatch?.[1]).not.toBe(dtstartMatch?.[1]);
});
});
+67 -45
View File
@@ -11,7 +11,7 @@
* They will turn GREEN in Plan 03-02 when the implementation is added.
*/
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect, vi } from 'vitest';
// This import fails (RED) — broker/write.ts does not exist yet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -20,51 +20,68 @@ import {
createCalendarEvent,
updateCalendarEvent,
deleteCalendarEvent,
} from '../../src/broker/write.js'
} from '../../src/broker/write.js';
const makeMockResponse = (status: number): Response =>
({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as Response
({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as Response;
describe('createCalendarEvent', () => {
it('calls client.createCalendarObject with uid.ics filename', async () => {
const mockClient = {
createCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(201)),
}
const mockCalendar = { url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/' }
const uid = 'abc-123@familysync'
const icsString = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR'
};
const mockCalendar = {
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
};
const uid = 'abc-123@familysync';
const icsString = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR';
const result = await createCalendarEvent(mockClient as never, mockCalendar as never, uid, icsString)
const result = await createCalendarEvent(
mockClient as never,
mockCalendar as never,
uid,
icsString,
);
expect(mockClient.createCalendarObject).toHaveBeenCalledWith({
calendar: mockCalendar,
filename: `${uid}.ics`,
iCalString: icsString,
})
expect(result.status).toBe(201)
})
});
expect(result.status).toBe(201);
});
it('returns the raw Response from client', async () => {
const mockClient = {
createCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)),
}
const mockCalendar = { url: 'https://caldav.fastmail.com/' }
const result = await createCalendarEvent(mockClient as never, mockCalendar as never, 'uid@fs', 'ICS')
expect(result).toBeDefined()
expect(result.status).toBe(204)
})
})
};
const mockCalendar = { url: 'https://caldav.fastmail.com/' };
const result = await createCalendarEvent(
mockClient as never,
mockCalendar as never,
'uid@fs',
'ICS',
);
expect(result).toBeDefined();
expect(result.status).toBe(204);
});
});
describe('updateCalendarEvent', () => {
it('calls client.updateCalendarObject with etag in calendarObject (If-Match)', async () => {
const mockClient = {
updateCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)),
}
const calendarObjectUrl = 'https://caldav.fastmail.com/dav/calendars/user/test/Default/uid.ics'
const icsString = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR'
const etag = '"etag-123"'
};
const calendarObjectUrl = 'https://caldav.fastmail.com/dav/calendars/user/test/Default/uid.ics';
const icsString = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR';
const etag = '"etag-123"';
const result = await updateCalendarEvent(mockClient as never, calendarObjectUrl, icsString, etag)
const result = await updateCalendarEvent(
mockClient as never,
calendarObjectUrl,
icsString,
etag,
);
expect(mockClient.updateCalendarObject).toHaveBeenCalledWith({
calendarObject: {
@@ -72,30 +89,35 @@ describe('updateCalendarEvent', () => {
data: icsString,
etag,
},
})
expect(result.status).toBe(204)
})
});
expect(result.status).toBe(204);
});
it('passes empty string as etag when etag is null (safe for If-Match)', async () => {
const mockClient = {
updateCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)),
}
await updateCalendarEvent(mockClient as never, 'https://caldav.fastmail.com/uid.ics', 'ICS', null)
};
await updateCalendarEvent(
mockClient as never,
'https://caldav.fastmail.com/uid.ics',
'ICS',
null,
);
const callArg = mockClient.updateCalendarObject.mock.calls[0][0]
expect(callArg.calendarObject.etag).toBe('')
})
})
const callArg = mockClient.updateCalendarObject.mock.calls[0][0];
expect(callArg.calendarObject.etag).toBe('');
});
});
describe('deleteCalendarEvent', () => {
it('calls client.deleteCalendarObject with etag in calendarObject (If-Match)', async () => {
const mockClient = {
deleteCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)),
}
const calendarObjectUrl = 'https://caldav.fastmail.com/dav/calendars/user/test/Default/uid.ics'
const etag = '"etag-456"'
};
const calendarObjectUrl = 'https://caldav.fastmail.com/dav/calendars/user/test/Default/uid.ics';
const etag = '"etag-456"';
const result = await deleteCalendarEvent(mockClient as never, calendarObjectUrl, etag)
const result = await deleteCalendarEvent(mockClient as never, calendarObjectUrl, etag);
expect(mockClient.deleteCalendarObject).toHaveBeenCalledWith({
calendarObject: {
@@ -103,17 +125,17 @@ describe('deleteCalendarEvent', () => {
data: '',
etag,
},
})
expect(result.status).toBe(204)
})
});
expect(result.status).toBe(204);
});
it('passes empty string as etag when etag is null', async () => {
const mockClient = {
deleteCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)),
}
await deleteCalendarEvent(mockClient as never, 'https://caldav.fastmail.com/uid.ics', null)
};
await deleteCalendarEvent(mockClient as never, 'https://caldav.fastmail.com/uid.ics', null);
const callArg = mockClient.deleteCalendarObject.mock.calls[0][0]
expect(callArg.calendarObject.etag).toBe('')
})
})
const callArg = mockClient.deleteCalendarObject.mock.calls[0][0];
expect(callArg.calendarObject.etag).toBe('');
});
});