From 433fb9f900f00b7558a23dcba91c23afc182e418 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 10:47:41 -0400 Subject: [PATCH] test(02-05): add failing tests for EventDetailPopover (TDD RED) - renders title/location/description/calendar-name as plain text - Escape key and backdrop click close the popover via setOpenEventId(null) - close button aria-label=Close assertion - XSS guard: HTML-looking title and description render as escaped text not DOM elements - renders null when openEventId is null --- .../components/EventDetailPopover.test.tsx | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 apps/pwa/src/components/EventDetailPopover.test.tsx diff --git a/apps/pwa/src/components/EventDetailPopover.test.tsx b/apps/pwa/src/components/EventDetailPopover.test.tsx new file mode 100644 index 0000000..428aa52 --- /dev/null +++ b/apps/pwa/src/components/EventDetailPopover.test.tsx @@ -0,0 +1,191 @@ +/** + * 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 = vi.fn() +let mockOpenEventId: string | null = null + +vi.mock('../store/calendarStore.js', () => ({ + useCalendarStore: vi.fn(() => ({ + openEventId: mockOpenEventId, + setOpenEventId: mockSetOpenEventId, + })), +})) + +// ── Fixtures ─────────────────────────────────────────────────────────────────── + +const TIMED_OCCURRENCE = { + id: 'test-uid::2026-06-15T10:00:00', + uid: 'test-uid', + calendarId: 1, + calendarName: 'My Calendar', + ownerUserId: 1, + 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', +} + +const OCCURRENCE_WITH_HTML = { + ...TIMED_OCCURRENCE, + id: 'xss-uid::2026-06-15T10:00:00', + uid: 'xss-uid', + title: 'Team Meeting', + description: 'Bold description', + location: 'Room', +} + +const ALLDAY_OCCURRENCE = { + id: 'allday-uid::2026-06-20', + uid: 'allday-uid', + calendarId: 2, + calendarName: 'Shared Calendar', + ownerUserId: 1, + color: '#F25C7A', + isShared: true, + title: 'Birthday Party', + start: '2026-06-20', + end: '2026-06-20', + allDay: true, + location: null, + description: null, +} + +// ── 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 ReturnType).mockImplementation(() => ({ + openEventId: mockOpenEventId, + setOpenEventId: mockSetOpenEventId, + })) + + 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 calendar name in footer', () => { + renderPopover(TIMED_OCCURRENCE) + 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 ReturnType).mockImplementation(() => ({ + openEventId: null, + setOpenEventId: mockSetOpenEventId, + })) + 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') + }) +})