Files
familysync/apps/pwa/src/components/EventDetailPopover.test.tsx
T
Lucas Berger 69e5ae8726 fix(06): set hasRrule on EventDetailPopover test fixtures
Plan 06-05 added hasRrule as a required field on the PWA CalendarOccurrence
type (mirroring 06-03's server-side field), but pre-existing fixtures in
EventDetailPopover.test.tsx did not set it — breaking tsc --noEmit / vite build
(vitest passed because esbuild strips types). Both fixtures are non-recurring
single events, so hasRrule: false is correct. Post-merge integration fix.
2026-06-10 11:44:37 -04:00

293 lines
11 KiB
TypeScript
Raw 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,
}
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,
}
// ── 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/)
})
})