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.
293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
/**
|
||
* 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/)
|
||
})
|
||
})
|