test(03-05): add failing tests for EventForm modal component
- Fields: title, all-day toggle, start/end date/time, recurrence, location, description - D-02: calendar picker absent with 1 calendar, present with 2 calendars - Validation: empty title shows error, end-before-start shows error - Create mode calls createEvent mutation; edit mode calls updateEvent mutation - Escape and backdrop close the form; Cancel button closes - role=dialog aria-modal=true; edit mode pre-populates title from TanStack cache
This commit is contained in:
@@ -0,0 +1,368 @@
|
|||||||
|
/**
|
||||||
|
* 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 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Mock the calendarStore
|
||||||
|
const mockSetEventForm = vi.fn()
|
||||||
|
let mockEventFormOpen = true
|
||||||
|
let mockEventFormMode: 'create' | 'edit' = 'create'
|
||||||
|
let mockEventFormUid: string | null = null
|
||||||
|
|
||||||
|
vi.mock('../store/calendarStore.js', () => ({
|
||||||
|
useCalendarStore: vi.fn(() => ({
|
||||||
|
eventFormOpen: mockEventFormOpen,
|
||||||
|
eventFormMode: mockEventFormMode,
|
||||||
|
eventFormUid: mockEventFormUid,
|
||||||
|
setEventForm: mockSetEventForm,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock write client calls
|
||||||
|
const mockCreateEvent = vi.fn().mockResolvedValue({ uid: 'new-uid-123' })
|
||||||
|
const mockUpdateEvent = vi.fn().mockResolvedValue({ uid: 'edit-uid-456' })
|
||||||
|
const mockFetchWritableCalendars = vi.fn()
|
||||||
|
|
||||||
|
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<typeof vi.fn>).mockImplementation(() => ({
|
||||||
|
eventFormOpen: true,
|
||||||
|
eventFormMode: mode,
|
||||||
|
eventFormUid: uid,
|
||||||
|
setEventForm: mockSetEventForm,
|
||||||
|
}))
|
||||||
|
|
||||||
|
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(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<EventForm />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user