diff --git a/apps/api/tests/broker/outboxWorker.test.ts b/apps/api/tests/broker/outboxWorker.test.ts index 6d39ef5..e8babf1 100644 --- a/apps/api/tests/broker/outboxWorker.test.ts +++ b/apps/api/tests/broker/outboxWorker.test.ts @@ -18,7 +18,7 @@ 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 } 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. @@ -647,3 +647,69 @@ describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff in expect(setArg?.nextAttemptAt?.getTime()).toBeLessThanOrEqual(expectedMaxMs) }) }) + +// ─── D-06: assembleRruleString unit tests ───────────────────────────────────── +// These tests import the NOT-YET-EXPORTED `assembleRruleString` helper. +// RED: will fail because assembleRruleString is not exported yet. + +describe('assembleRruleString (D-06)', () => { + it('returns base preset unchanged when no bound given', () => { + 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') + }) + + 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') + }) + + 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') + }) + + 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') + }) + + it('COUNT=5 appended to FREQ=DAILY (matches plan behavior assertion)', () => { + 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 +// and the FREQ-persistence assertion catches the D-07 regression scenario. + +describe('FREQ persistence (D-07 regression)', () => { + beforeEach(() => { + 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 + vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { + 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 })] + + await runOutboxDrain() + + 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') + }) +}) diff --git a/apps/api/tests/broker/vevent.test.ts b/apps/api/tests/broker/vevent.test.ts index 52d608b..407e1fa 100644 --- a/apps/api/tests/broker/vevent.test.ts +++ b/apps/api/tests/broker/vevent.test.ts @@ -90,6 +90,43 @@ describe('buildVeventString', () => { 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)', () => { + const result = buildVeventString({ + summary: 'Weekly', + allDay: false, + 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') + }) + + // 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)', () => { + const result = buildVeventString({ + summary: 'Daily standup', + allDay: true, + 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') + }) + + // D-06: RRULE UNTIL DATETIME UTC form — timed event + it('serializes UNTIL as DATETIME UTC form for timed events (D-06)', () => { + const result = buildVeventString({ + summary: 'Weekly', + allDay: false, + 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') + }) + it('uses the provided uid when given', () => { const uid = 'custom-uid-001@familysync' const result = buildVeventString({