Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
Showing only changes of commit 433fb9f900 - Show all commits
@@ -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: '<script>alert("xss")</script>Team Meeting',
description: '<b>Bold</b> description',
location: '<img src=x onerror=alert(1)>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<typeof vi.fn>).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(
<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 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<typeof vi.fn>).mockImplementation(() => ({
openEventId: null,
setOpenEventId: mockSetOpenEventId,
}))
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')
})
})