/** * 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) => 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: 'Team Meeting', description: 'Bold description', location: '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).mockImplementation( (selector?: (s: Record) => 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( , ); } // ── 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).mockImplementation( (selector?: (s: Record) => 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( , ); 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'); // Team Meeting'); }); it('XSS guard: HTML in description renders as escaped text', () => { renderPopover(OCCURRENCE_WITH_HTML); const descEl = screen.getByTestId('event-description'); // must NOT be rendered as a bold element expect(descEl.innerHTML).not.toContain(''); expect(descEl.textContent).toContain('Bold 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/); }); });