diff --git a/apps/api/tests/broker/outboxWorker.test.ts b/apps/api/tests/broker/outboxWorker.test.ts index e3755c2..34293c3 100644 --- a/apps/api/tests/broker/outboxWorker.test.ts +++ b/apps/api/tests/broker/outboxWorker.test.ts @@ -1018,3 +1018,79 @@ describe('runOutboxDrain — reminderLeadMinutes VALARM wiring (CAL-13/CAL-14, P expect(capturedIcsString as string).toContain('VALUE=DATE-TIME'); }); }); + +// ─── Phase 11 Plan 05 WR-02: .max(10080) on reminderLeadMinutes in outboxPayloadSchema ── +// A payload with reminderLeadMinutes=10081 exceeds the 1-week UI cap (10080 min). +// The outboxPayloadSchema must reject it so the row is hard-failed rather than +// letting an out-of-range value silently flow into the VALARM trigger. + +describe('runOutboxDrain — WR-02: reminderLeadMinutes max(10080) in outboxPayloadSchema', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockPendingRows = []; + wireMockChain(); + }); + + it('WR-02: payload with reminderLeadMinutes=10081 is hard-failed (validation error)', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js'); + vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201)); + + const payload = JSON.stringify({ + title: 'Over-cap reminder', + allDay: false, + start: '2026-12-15T10:00:00Z', + end: '2026-12-15T11:00:00Z', + reminderLeadMinutes: 10081, // 1 min over the 1-week cap + }); + mockPendingRows = [makeRow({ payload })]; + + await runOutboxDrain(); + + // Must NOT dispatch to CalDAV — validation must fire before ICS assembly + 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); + }); + + it('WR-02: payload with reminderLeadMinutes=10080 (boundary) passes validation and dispatches', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js'); + vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201)); + + const payload = JSON.stringify({ + title: 'Max-cap reminder', + allDay: false, + start: '2026-12-15T10:00:00Z', + end: '2026-12-15T11:00:00Z', + reminderLeadMinutes: 10080, // exactly 1 week — must be allowed + }); + mockPendingRows = [makeRow({ payload })]; + + await runOutboxDrain(); + + // Row is valid — CalDAV write must have been dispatched + expect(createCalendarEvent).toHaveBeenCalledTimes(1); + const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }; + expect(setArg?.status).toBe('done'); + }); + + it('WR-02: payload with reminderLeadMinutes=null (explicit clear) passes validation', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js'); + vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201)); + + const payload = JSON.stringify({ + title: 'Clear reminder', + allDay: false, + start: '2026-12-15T10:00:00Z', + end: '2026-12-15T11:00:00Z', + reminderLeadMinutes: null, + }); + mockPendingRows = [makeRow({ payload })]; + + await runOutboxDrain(); + + // null (explicit clear) must pass .nullable() + const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }; + expect(setArg?.status).not.toBe('failed'); + }); +}); diff --git a/apps/api/tests/routes/events.test.ts b/apps/api/tests/routes/events.test.ts index f257899..18e727b 100644 --- a/apps/api/tests/routes/events.test.ts +++ b/apps/api/tests/routes/events.test.ts @@ -930,3 +930,77 @@ describe('GET /api/events/writable-calendars', () => { expect(body.calendars.length).toBe(1); }); }); + +// --------------------------------------------------------------------------- +// WR-02: eventFieldsSchema must reject reminderLeadMinutes > 10080 (Phase 11 Plan 05) +// 10080 = 1 week in minutes; the UI cap prevents accidental over-wide lead values. +// --------------------------------------------------------------------------- +describe('WR-02: reminderLeadMinutes .max(10080) in eventFieldsSchema (Phase 11 Plan 05)', () => { + beforeEach(() => { + // Wire a calendar row so POST /create reaches schema validation (not 403) + mockDbRows = [ + { + id: 1, + url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + displayName: 'Default', + color: '#4A90D9', + userId: 1, + isShared: false, + }, + ]; + const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)); + mockFromFn.mockReturnValue({ where: mockSimpleWhere }); + mockSelectFn.mockReturnValue({ from: mockFromFn }); + }); + + it('returns 400 for reminderLeadMinutes=10081 on POST /create', async () => { + const { app } = await import('../../src/index.js'); + const res = await app.request('/api/events/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Over-cap', + allDay: false, + start: '2026-12-15T10:00:00Z', + end: '2026-12-15T11:00:00Z', + calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + reminderLeadMinutes: 10081, + }), + }); + expect(res.status).toBe(400); + }); + + it('returns 202 for reminderLeadMinutes=10080 (boundary) on POST /create', async () => { + const { app } = await import('../../src/index.js'); + const res = await app.request('/api/events/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Max-cap', + allDay: false, + start: '2026-12-15T10:00:00Z', + end: '2026-12-15T11:00:00Z', + calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + reminderLeadMinutes: 10080, + }), + }); + expect(res.status).toBe(202); + }); + + it('returns 202 for reminderLeadMinutes=null (explicit clear) on POST /create', async () => { + const { app } = await import('../../src/index.js'); + const res = await app.request('/api/events/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Clear reminder', + allDay: false, + start: '2026-12-15T10:00:00Z', + end: '2026-12-15T11:00:00Z', + calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + reminderLeadMinutes: null, + }), + }); + expect(res.status).toBe(202); + }); +});