Files
familysync/apps/pwa/src/components/EventDetailPopover.test.tsx
Lucas Berger f6b47ebf1e fix(11-05): CR-01 surface reminderIsCustom to preserve custom VALARMs on edit
- expand.ts: add reminderIsCustom:boolean to CalendarOccurrence interface;
  derived from classifyValarms kind==='custom'; propagated to both
  non-recurring and recurring occurrence branches
- client.ts: mirror reminderIsCustom on CalendarOccurrence (atomic mirror)
- EventForm.tsx: extend deriveReminderValue to accept isCustom flag;
  returns '__custom__' when true, making the existing D-08 preserve branch
  live — editing a custom-alarm event now omits reminderLeadMinutes from
  the payload so outboxWorker extractValarms keeps the original VALARM
- Fix existing test fixtures (EventForm.test.tsx, EventDetailPopover.test.tsx)
  to include reminderIsCustom:false on all CalendarOccurrence literals

Fixes CAL-14 Pitfall 1: Apple Calendar absolute DATE-TIME / multi-VALARM
alarms no longer silently stripped on any edit round-trip from the PWA.
2026-06-14 08:11:46 -04:00

293 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* EventDetailPopover tests — Task 1 (TDD RED → GREEN)
*
* Guards:
* - T-02e-01: Title containing HTML-looking strings renders as escaped text (XSS guard)
* - Escape key closes the popover and clears openEventId
* - All event fields (title, location, description, calendar name) render as text
* - Close button has aria-label="Close"
* - Backdrop click closes the popover
*/
import 'temporal-polyfill/global';
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// window.matchMedia polyfilled in test-setup.ts
// ── Module mocks ──────────────────────────────────────────────────────────────
const { mockSetOpenEventId, mockSetEventForm, mockSetDeleteDialog } = vi.hoisted(() => ({
mockSetOpenEventId: vi.fn(),
mockSetEventForm: vi.fn(),
mockSetDeleteDialog: vi.fn(),
}));
let mockOpenEventId: string | null = null;
vi.mock('../store/calendarStore.js', () => ({
useCalendarStore: vi.fn((selector?: (s: Record<string, unknown>) => unknown) => {
const state = {
openEventId: mockOpenEventId,
setOpenEventId: mockSetOpenEventId,
setEventForm: mockSetEventForm,
setDeleteDialog: mockSetDeleteDialog,
};
if (typeof selector === 'function') return selector(state);
return state;
}),
}));
// ── Fixtures ───────────────────────────────────────────────────────────────────
import type { CalendarOccurrence } from '../api/client.js';
const TIMED_OCCURRENCE: CalendarOccurrence = {
id: 'test-uid::2026-06-15T10:00:00',
uid: 'test-uid',
calendarId: 1,
calendarName: 'My Calendar',
ownerUserId: 1,
ownerName: 'Alice',
color: '#4A90D9',
isShared: false,
title: 'Team Standup',
start: '2026-06-15T10:00:00-04:00[America/New_York]',
end: '2026-06-15T10:30:00-04:00[America/New_York]',
allDay: false,
location: 'Conference Room B',
description: 'Daily team sync meeting',
hasRrule: false,
reminderLeadMinutes: null,
reminderIsCustom: false,
};
const OCCURRENCE_WITH_HTML: CalendarOccurrence = {
...TIMED_OCCURRENCE,
id: 'xss-uid::2026-06-15T10:00:00',
uid: 'xss-uid',
title: '<script>alert("xss")</script>Team Meeting',
description: '<b>Bold</b> description',
location: '<img src=x onerror=alert(1)>Room',
};
const ALLDAY_OCCURRENCE: CalendarOccurrence = {
id: 'allday-uid::2026-06-20',
uid: 'allday-uid',
calendarId: 2,
calendarName: 'Shared Calendar',
ownerUserId: 1,
ownerName: 'Alice',
color: '#F25C7A',
isShared: true,
title: 'Birthday Party',
start: '2026-06-20',
end: '2026-06-20',
allDay: true,
location: null,
description: null,
hasRrule: false,
reminderLeadMinutes: null,
reminderIsCustom: false,
};
// ── Import component (after mocks are declared) ───────────────────────────────
import { EventDetailPopover } from './EventDetailPopover.js';
import { useCalendarStore } from '../store/calendarStore.js';
// ── Helpers ───────────────────────────────────────────────────────────────────
function renderPopover(occurrence = TIMED_OCCURRENCE) {
mockOpenEventId = occurrence.id;
(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(selector?: (s: Record<string, unknown>) => unknown) => {
const state = {
openEventId: mockOpenEventId,
setOpenEventId: mockSetOpenEventId,
setEventForm: mockSetEventForm,
setDeleteDialog: mockSetDeleteDialog,
};
if (typeof selector === 'function') return selector(state);
return state;
},
);
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
// Pre-populate the events cache so EventDetailPopover can resolve by id
client.setQueryData(['events'], { occurrences: [occurrence] });
return render(
<QueryClientProvider client={client}>
<EventDetailPopover />
</QueryClientProvider>,
);
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('EventDetailPopover', () => {
beforeEach(() => {
vi.clearAllMocks();
mockOpenEventId = null;
});
it('renders the event title as a heading', () => {
renderPopover(TIMED_OCCURRENCE);
expect(screen.getByRole('heading')).toHaveTextContent('Team Standup');
});
it('renders location when present', () => {
renderPopover(TIMED_OCCURRENCE);
expect(screen.getByText(/Conference Room B/)).toBeDefined();
});
it('renders description when present', () => {
renderPopover(TIMED_OCCURRENCE);
expect(screen.getByText(/Daily team sync meeting/)).toBeDefined();
});
it('renders owner name in footer for personal events', () => {
renderPopover(TIMED_OCCURRENCE);
// TIMED_OCCURRENCE is personal (isShared:false) with ownerName:'Alice'
expect(screen.getByText(/Alice/)).toBeDefined();
});
it('renders "Family" in footer for shared calendar events', () => {
renderPopover(ALLDAY_OCCURRENCE);
// ALLDAY_OCCURRENCE has isShared:true — footer must show 'Family'
expect(screen.getByText('Family')).toBeDefined();
});
it('renders calendarName in footer when ownerName is null', () => {
const noOwnerName: CalendarOccurrence = {
...TIMED_OCCURRENCE,
id: 'no-owner-uid::2026-06-15T10:00:00',
ownerName: null,
};
renderPopover(noOwnerName);
expect(screen.getByText(/My Calendar/)).toBeDefined();
});
it('renders an all-day event without crashing', () => {
renderPopover(ALLDAY_OCCURRENCE);
expect(screen.getByRole('heading')).toHaveTextContent('Birthday Party');
});
it('close button has aria-label="Close"', () => {
renderPopover(TIMED_OCCURRENCE);
expect(screen.getByLabelText('Close')).toBeDefined();
});
it('pressing Escape calls setOpenEventId(null)', () => {
renderPopover(TIMED_OCCURRENCE);
fireEvent.keyDown(document, { key: 'Escape' });
expect(mockSetOpenEventId).toHaveBeenCalledWith(null);
});
it('clicking the close button calls setOpenEventId(null)', () => {
renderPopover(TIMED_OCCURRENCE);
fireEvent.click(screen.getByLabelText('Close'));
expect(mockSetOpenEventId).toHaveBeenCalledWith(null);
});
it('clicking the backdrop calls setOpenEventId(null)', () => {
renderPopover(TIMED_OCCURRENCE);
fireEvent.click(screen.getByTestId('popover-backdrop'));
expect(mockSetOpenEventId).toHaveBeenCalledWith(null);
});
it('renders nothing when openEventId is null', () => {
mockOpenEventId = null;
(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(selector?: (s: Record<string, unknown>) => unknown) => {
const state = {
openEventId: null,
setOpenEventId: mockSetOpenEventId,
setEventForm: mockSetEventForm,
setDeleteDialog: mockSetDeleteDialog,
};
if (typeof selector === 'function') return selector(state);
return state;
},
);
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
const { container } = render(
<QueryClientProvider client={client}>
<EventDetailPopover />
</QueryClientProvider>,
);
expect(container.firstChild).toBeNull();
});
it('XSS guard: HTML in title renders as escaped text, not as DOM elements', () => {
renderPopover(OCCURRENCE_WITH_HTML);
const heading = screen.getByRole('heading');
// <script> must NOT be injected as a DOM element
expect(heading.innerHTML).not.toContain('<script>');
// The raw text including angle brackets must appear as literal text
expect(heading.textContent).toContain('<script>alert("xss")</script>Team Meeting');
});
it('XSS guard: HTML in description renders as escaped text', () => {
renderPopover(OCCURRENCE_WITH_HTML);
const descEl = screen.getByTestId('event-description');
// <b> must NOT be rendered as a bold element
expect(descEl.innerHTML).not.toContain('<b>');
expect(descEl.textContent).toContain('<b>Bold</b> description');
});
// ── Phase 3 footer: Edit/Delete actions ────────────────────────────────────
it('footer renders an "Edit" button', () => {
renderPopover(TIMED_OCCURRENCE);
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
});
it('footer renders a "Delete" button', () => {
renderPopover(TIMED_OCCURRENCE);
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});
it('clicking "Edit" opens EventForm in edit mode and closes popover', () => {
renderPopover(TIMED_OCCURRENCE);
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
expect(mockSetEventForm).toHaveBeenCalledWith(true, 'edit', TIMED_OCCURRENCE.uid);
expect(mockSetOpenEventId).toHaveBeenCalledWith(null);
});
it('clicking "Delete" opens DeleteConfirmationDialog (setDeleteDialog)', () => {
renderPopover(TIMED_OCCURRENCE);
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
expect(mockSetDeleteDialog).toHaveBeenCalledWith(true, TIMED_OCCURRENCE.uid);
});
it('BUG-3 regression: IANA-bracketed start/end does not produce "Invalid Date" in rendered output', () => {
// Fastmail events are serialized with IANA bracket notation e.g. '2026-06-18T08:00:00-04:00[America/Toronto]'.
// new Date() cannot parse the bracket, so the date/time line showed "Invalid Date, Invalid Date Invalid Date".
// After the fix, the bracket is stripped before parsing.
const occurrence: CalendarOccurrence = {
...TIMED_OCCURRENCE,
id: 'iana-bracket-uid::1718712000000',
uid: 'iana-bracket-uid',
start: '2026-06-18T08:00:00-04:00[America/Toronto]',
end: '2026-06-18T09:00:00-04:00[America/Toronto]',
};
renderPopover(occurrence);
// The date/time text must not contain 'Invalid Date'
const dialogEl = screen.getByRole('dialog');
expect(dialogEl.textContent).not.toContain('Invalid Date');
// It must contain recognizable date content (month name or a digit)
// toLocaleDateString output varies by locale; check for a digit at minimum
const dateTimeText = dialogEl.textContent ?? '';
expect(dateTimeText).toMatch(/\d/);
});
});