From 30b8c9643a6d1fd03a512761be0745155811dd19 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sun, 14 Jun 2026 08:21:44 -0400 Subject: [PATCH] =?UTF-8?q?test(11-05):=20RED=20=E2=80=94=20WR-02=20remind?= =?UTF-8?q?erLeadMinutes=20max(10080)=20in=20both=20Zod=20schemas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - outboxPayloadSchema: 10081 must hard-fail the row (currently dispatches) - eventFieldsSchema: POST /create with 10081 must 400 (currently 202) - boundary 10080 and null pass (already correct, no test fails expected) --- apps/api/tests/broker/outboxWorker.test.ts | 76 ++++++++++++++++++++++ apps/api/tests/routes/events.test.ts | 74 +++++++++++++++++++++ 2 files changed, 150 insertions(+) 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); + }); +});