/** * EventForm tests — Task 2 (TDD RED → GREEN) * * Covers: * - All required fields render (title, all-day, start/end date/time, recurrence, location, description) * - All-day toggle hides time inputs; un-toggling shows them * - Calendar picker absent when writable-calendars returns 1 calendar (D-02) * - Calendar picker present when writable-calendars returns 2 calendars (D-02) * - Empty title shows "Title is required" error * - End-before-start shows "End time must be after start" error * - Submit in create mode calls createEvent mutation * - Submit in edit mode calls updateEvent mutation * - Escape key closes the form * - Backdrop click closes the form * - role="dialog" aria-modal="true" * - No dangerouslySetInnerHTML usage (security) */ import 'temporal-polyfill/global' import React from 'react' import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' // ── Module mocks ────────────────────────────────────────────────────────────── // vi.hoisted() is required for variables used inside vi.mock() factory functions // to avoid TDZ (temporal dead zone) issues — decision D-03-04-hoisting. const { mockSetEventForm, mockSetLastSyncedUid, mockCreateEvent, mockUpdateEvent, mockFetchWritableCalendars, } = vi.hoisted(() => ({ mockSetEventForm: vi.fn(), mockSetLastSyncedUid: vi.fn(), mockCreateEvent: vi.fn().mockResolvedValue({ uid: 'new-uid-123' }), mockUpdateEvent: vi.fn().mockResolvedValue({ uid: 'edit-uid-456' }), mockFetchWritableCalendars: vi.fn(), })) let mockEventFormOpen = true let mockEventFormMode: 'create' | 'edit' = 'create' let mockEventFormUid: string | null = null vi.mock('../store/calendarStore.js', () => ({ useCalendarStore: vi.fn((selector?: (s: Record) => unknown) => { const state = { eventFormOpen: mockEventFormOpen, eventFormMode: mockEventFormMode, eventFormUid: mockEventFormUid, setEventForm: mockSetEventForm, setLastSyncedUid: mockSetLastSyncedUid, } // Support both selector form and plain call form if (typeof selector === 'function') return selector(state) return state }), })) vi.mock('../api/client.js', () => ({ createEvent: mockCreateEvent, updateEvent: mockUpdateEvent, fetchWritableCalendars: mockFetchWritableCalendars, })) // ── Fixtures ─────────────────────────────────────────────────────────────────── import type { CalendarOccurrence } from '../api/client.js' import type { WritableCalendar } from '../api/client.js' const ONE_CALENDAR: WritableCalendar[] = [ { url: 'https://caldav.fastmail.com/cal1', displayName: 'My Calendar', color: '#4A90D9', isShared: false }, ] const TWO_CALENDARS: WritableCalendar[] = [ { url: 'https://caldav.fastmail.com/cal1', displayName: 'My Calendar', color: '#4A90D9', isShared: false }, { url: 'https://caldav.fastmail.com/cal2', displayName: 'Family', color: '#F25C7A', isShared: true }, ] const EDIT_OCCURRENCE: CalendarOccurrence = { id: 'edit-uid-456::2026-06-15T10:00:00', uid: 'edit-uid-456', calendarId: 1, calendarName: 'My Calendar', ownerUserId: 1, ownerName: 'Alice', color: '#4A90D9', isShared: false, title: 'Existing Meeting', start: '2026-06-15T10:00:00-04:00', end: '2026-06-15T11:00:00-04:00', allDay: false, location: 'Office', description: 'Weekly sync', } // ── Import component (after mocks) ──────────────────────────────────────────── import { EventForm } from './EventForm.js' import { useCalendarStore } from '../store/calendarStore.js' // ── Helpers ─────────────────────────────────────────────────────────────────── function renderForm(options: { mode?: 'create' | 'edit' uid?: string | null calendars?: WritableCalendar[] eventOccurrence?: CalendarOccurrence } = {}) { const { mode = 'create', uid = null, calendars = ONE_CALENDAR, eventOccurrence } = options mockEventFormOpen = true mockEventFormMode = mode mockEventFormUid = uid ;(useCalendarStore as unknown as ReturnType).mockImplementation( (selector?: (s: Record) => unknown) => { const state = { eventFormOpen: true, eventFormMode: mode, eventFormUid: uid, setEventForm: mockSetEventForm, setLastSyncedUid: mockSetLastSyncedUid, } if (typeof selector === 'function') return selector(state) return state }, ) mockFetchWritableCalendars.mockResolvedValue(calendars) const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }) // Pre-populate the events cache for edit mode pre-population if (eventOccurrence) { client.setQueryData(['events'], { occurrences: [eventOccurrence] }) } // Pre-populate writable calendars cache client.setQueryData(['writableCalendars'], calendars) return render( , ) } // ── Tests ───────────────────────────────────────────────────────────────────── describe('EventForm', () => { beforeEach(() => { vi.clearAllMocks() mockEventFormOpen = true mockEventFormMode = 'create' mockEventFormUid = null }) // ── Role and accessibility ───────────────────────────────────────────────── it('has role="dialog" and aria-modal="true"', () => { renderForm() const dialog = screen.getByRole('dialog') expect(dialog).toBeDefined() expect(dialog.getAttribute('aria-modal')).toBe('true') }) it('has aria-label "New Event" in create mode', () => { renderForm({ mode: 'create' }) const dialog = screen.getByRole('dialog') expect(dialog.getAttribute('aria-label')).toBe('New Event') }) it('has aria-label "Edit Event" in edit mode', () => { renderForm({ mode: 'edit', uid: 'edit-uid-456', eventOccurrence: EDIT_OCCURRENCE }) const dialog = screen.getByRole('dialog') expect(dialog.getAttribute('aria-label')).toBe('Edit Event') }) // ── Required fields ──────────────────────────────────────────────────────── it('renders title input with placeholder "Event title"', () => { renderForm() const titleInput = screen.getByPlaceholderText('Event title') expect(titleInput).toBeDefined() }) it('renders All-day toggle', () => { renderForm() expect(screen.getByText('All day')).toBeDefined() }) it('renders start date input', () => { renderForm() // Start date input should exist const dateInputs = document.querySelectorAll('input[type="date"]') expect(dateInputs.length).toBeGreaterThanOrEqual(1) }) it('renders recurrence picker labeled "Repeat"', () => { renderForm() expect(screen.getByText('Repeat')).toBeDefined() }) it('renders recurrence options: None, Daily, Weekly, Monthly, Yearly', () => { renderForm() expect(screen.getByText('None')).toBeDefined() expect(screen.getByText('Daily')).toBeDefined() expect(screen.getByText('Weekly')).toBeDefined() expect(screen.getByText('Monthly')).toBeDefined() expect(screen.getByText('Yearly')).toBeDefined() }) it('renders location input with placeholder "Add location"', () => { renderForm() expect(screen.getByPlaceholderText('Add location')).toBeDefined() }) it('renders description textarea with placeholder "Add description"', () => { renderForm() expect(screen.getByPlaceholderText('Add description')).toBeDefined() }) // ── All-day toggle behavior ──────────────────────────────────────────────── it('toggling All-day ON hides time inputs', () => { renderForm() const timeInputsBefore = document.querySelectorAll('input[type="time"]') expect(timeInputsBefore.length).toBeGreaterThan(0) const allDaySwitch = screen.getByRole('switch') fireEvent.click(allDaySwitch) const timeInputsAfter = document.querySelectorAll('input[type="time"]') expect(timeInputsAfter.length).toBe(0) }) it('toggling All-day OFF shows time inputs with default 09:00 / 10:00', () => { renderForm() // Toggle on then off const allDaySwitch = screen.getByRole('switch') fireEvent.click(allDaySwitch) // ON - hides time fireEvent.click(allDaySwitch) // OFF - shows time with defaults const timeInputs = document.querySelectorAll('input[type="time"]') expect(timeInputs.length).toBeGreaterThan(0) }) // ── Calendar picker D-02 ─────────────────────────────────────────────────── it('calendar picker is absent when fetchWritableCalendars returns 1 calendar (D-02)', () => { renderForm({ calendars: ONE_CALENDAR }) // Should not show "Calendar" label when only 1 writable calendar const calendarLabel = screen.queryByText('Calendar') expect(calendarLabel).toBeNull() }) it('calendar picker is present when fetchWritableCalendars returns 2 calendars (D-02)', () => { renderForm({ calendars: TWO_CALENDARS }) // Should show "Calendar" label when >1 writable calendar expect(screen.getByText('Calendar')).toBeDefined() }) // ── Validation ───────────────────────────────────────────────────────────── it('shows "Title is required" when submitting with empty title', async () => { renderForm() const saveButton = screen.getByText('Create Event') fireEvent.click(saveButton) await waitFor(() => { expect(screen.getByText('Title is required')).toBeDefined() }) }) it('shows "End time must be after start" when end is before start', async () => { renderForm() // Fill a title so title validation passes fireEvent.change(screen.getByPlaceholderText('Event title'), { target: { value: 'Valid Title' }, }) // Set start date to today const dateInputs = document.querySelectorAll('input[type="date"]') // Set end date to before start const today = new Date() const tomorrow = new Date(today) tomorrow.setDate(today.getDate() + 1) const todayStr = today.toISOString().slice(0, 10) const yesterdayStr = new Date(today.setDate(today.getDate() - 1)).toISOString().slice(0, 10) if (dateInputs.length >= 2) { fireEvent.change(dateInputs[0], { target: { value: todayStr } }) // Set time inputs: start time later than end time const timeInputs = document.querySelectorAll('input[type="time"]') if (timeInputs.length >= 2) { fireEvent.change(timeInputs[0], { target: { value: '15:00' } }) fireEvent.change(timeInputs[1], { target: { value: '10:00' } }) } fireEvent.change(dateInputs[1], { target: { value: todayStr } }) } const saveButton = screen.getByText('Create Event') fireEvent.click(saveButton) await waitFor(() => { expect(screen.getByText('End time must be after start')).toBeDefined() }) }) // ── Form submission ──────────────────────────────────────────────────────── it('submitting in create mode calls createEvent mutation', async () => { renderForm({ mode: 'create' }) fireEvent.change(screen.getByPlaceholderText('Event title'), { target: { value: 'New Meeting' }, }) const saveButton = screen.getByText('Create Event') fireEvent.click(saveButton) await waitFor(() => { expect(mockCreateEvent).toHaveBeenCalled() }) }) it('submitting in edit mode calls updateEvent mutation', async () => { renderForm({ mode: 'edit', uid: 'edit-uid-456', eventOccurrence: EDIT_OCCURRENCE, }) // Pre-populated form — just click save const saveButton = screen.getByText('Save Changes') fireEvent.click(saveButton) await waitFor(() => { expect(mockUpdateEvent).toHaveBeenCalled() }) }) it('successful create closes the form via setEventForm(false)', async () => { renderForm({ mode: 'create' }) fireEvent.change(screen.getByPlaceholderText('Event title'), { target: { value: 'Test Event' }, }) fireEvent.click(screen.getByText('Create Event')) await waitFor(() => { expect(mockSetEventForm).toHaveBeenCalledWith(false) }) }) // ── Close behaviors ──────────────────────────────────────────────────────── it('pressing Escape closes the form', () => { renderForm() fireEvent.keyDown(document, { key: 'Escape' }) expect(mockSetEventForm).toHaveBeenCalledWith(false) }) it('clicking the backdrop closes the form', () => { renderForm() const backdrop = screen.getByTestId('event-form-backdrop') fireEvent.click(backdrop) expect(mockSetEventForm).toHaveBeenCalledWith(false) }) it('clicking Cancel closes the form', () => { renderForm() fireEvent.click(screen.getByText('Cancel')) expect(mockSetEventForm).toHaveBeenCalledWith(false) }) // ── Edit mode pre-population ─────────────────────────────────────────────── it('edit mode pre-populates title from TanStack Query cache', () => { renderForm({ mode: 'edit', uid: 'edit-uid-456', eventOccurrence: EDIT_OCCURRENCE, }) const titleInput = screen.getByPlaceholderText('Event title') as HTMLInputElement expect(titleInput.value).toBe('Existing Meeting') }) })