test(03-06): add failing tests for EventDetailPopover footer and DeleteConfirmationDialog

This commit is contained in:
Lucas Berger
2026-06-05 18:44:40 -04:00
parent aa7c4c37d4
commit 2fbeffee9a
2 changed files with 222 additions and 13 deletions
@@ -0,0 +1,154 @@
/**
* DeleteConfirmationDialog tests — Task 3 (TDD RED → GREEN)
*
* Covers UI-SPEC §DeleteConfirmationDialog:
* - Renders heading "Delete event?" and body copy
* - Cancel button closes without deleting
* - Escape closes without deleting
* - Delete (red) button calls deleteEvent, sets lastSyncedUid, closes dialog and popover
* - Focus trapped inside dialog
* - T-03-17: mandatory two-tap confirmation (no single-tap delete)
* - T-03-18: failed delete toast persists (wired via lastSyncedUid → SyncStateToast)
*/
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() required for variables referenced inside vi.mock() factories (D-03-04-hoisting)
const {
mockDeleteEvent,
mockSetDeleteDialog,
mockSetLastSyncedUid,
mockSetOpenEventId,
mockDeleteDialogOpen,
mockDeleteDialogUid,
} = vi.hoisted(() => ({
mockDeleteEvent: vi.fn(),
mockSetDeleteDialog: vi.fn(),
mockSetLastSyncedUid: vi.fn(),
mockSetOpenEventId: vi.fn(),
mockDeleteDialogOpen: { value: true },
mockDeleteDialogUid: { value: 'event-uid-to-delete' as string | null },
}))
vi.mock('../store/calendarStore.js', () => ({
useCalendarStore: (selector: (s: Record<string, unknown>) => unknown) => {
const state = {
deleteDialogOpen: mockDeleteDialogOpen.value,
deleteDialogUid: mockDeleteDialogUid.value,
setDeleteDialog: mockSetDeleteDialog,
setLastSyncedUid: mockSetLastSyncedUid,
setOpenEventId: mockSetOpenEventId,
}
return selector(state)
},
}))
vi.mock('../api/client.js', () => ({
deleteEvent: mockDeleteEvent,
}))
// ── Import component (after mocks) ────────────────────────────────────────────
import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js'
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
function renderDialog() {
const client = makeQueryClient()
return render(
<QueryClientProvider client={client}>
<DeleteConfirmationDialog />
</QueryClientProvider>,
)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('DeleteConfirmationDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDeleteDialogOpen.value = true
mockDeleteDialogUid.value = 'event-uid-to-delete'
mockDeleteEvent.mockResolvedValue(undefined)
})
it('renders heading "Delete event?"', () => {
renderDialog()
expect(screen.getByRole('heading', { name: /delete event\?/i })).toBeInTheDocument()
})
it('renders the body copy about Fastmail calendar', () => {
renderDialog()
expect(
screen.getByText('This will be removed from your Fastmail calendar.'),
).toBeInTheDocument()
})
it('renders Cancel and Delete buttons', () => {
renderDialog()
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument()
})
it('Cancel button closes dialog without deleting', () => {
renderDialog()
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(mockSetDeleteDialog).toHaveBeenCalledWith(false)
expect(mockDeleteEvent).not.toHaveBeenCalled()
})
it('Escape key closes dialog without deleting', () => {
renderDialog()
fireEvent.keyDown(document, { key: 'Escape' })
expect(mockSetDeleteDialog).toHaveBeenCalledWith(false)
expect(mockDeleteEvent).not.toHaveBeenCalled()
})
it('Delete button calls deleteEvent with the uid', async () => {
renderDialog()
fireEvent.click(screen.getByRole('button', { name: /^delete$/i }))
await waitFor(() => {
expect(mockDeleteEvent).toHaveBeenCalledWith('event-uid-to-delete')
})
})
it('Delete button sets lastSyncedUid to the uid', async () => {
renderDialog()
fireEvent.click(screen.getByRole('button', { name: /^delete$/i }))
await waitFor(() => {
expect(mockSetLastSyncedUid).toHaveBeenCalledWith('event-uid-to-delete')
})
})
it('Delete button closes dialog and popover on success', async () => {
renderDialog()
fireEvent.click(screen.getByRole('button', { name: /^delete$/i }))
await waitFor(() => {
expect(mockSetDeleteDialog).toHaveBeenCalledWith(false)
expect(mockSetOpenEventId).toHaveBeenCalledWith(null)
})
})
it('renders nothing when deleteDialogOpen is false', () => {
mockDeleteDialogOpen.value = false
const { container } = renderDialog()
expect(container.firstChild).toBeNull()
})
it('has role="dialog" and aria-modal="true"', () => {
renderDialog()
const dialog = screen.getByRole('dialog')
expect(dialog).toBeInTheDocument()
expect(dialog).toHaveAttribute('aria-modal', 'true')
})
})
@@ -19,14 +19,28 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// ── Module mocks ────────────────────────────────────────────────────────────── // ── Module mocks ──────────────────────────────────────────────────────────────
const mockSetOpenEventId = vi.fn() const {
mockSetOpenEventId,
mockSetEventForm,
mockSetDeleteDialog,
} = vi.hoisted(() => ({
mockSetOpenEventId: vi.fn(),
mockSetEventForm: vi.fn(),
mockSetDeleteDialog: vi.fn(),
}))
let mockOpenEventId: string | null = null let mockOpenEventId: string | null = null
vi.mock('../store/calendarStore.js', () => ({ vi.mock('../store/calendarStore.js', () => ({
useCalendarStore: vi.fn(() => ({ useCalendarStore: vi.fn((selector?: (s: Record<string, unknown>) => unknown) => {
const state = {
openEventId: mockOpenEventId, openEventId: mockOpenEventId,
setOpenEventId: mockSetOpenEventId, setOpenEventId: mockSetOpenEventId,
})), setEventForm: mockSetEventForm,
setDeleteDialog: mockSetDeleteDialog,
}
if (typeof selector === 'function') return selector(state)
return state
}),
})) }))
// ── Fixtures ─────────────────────────────────────────────────────────────────── // ── Fixtures ───────────────────────────────────────────────────────────────────
@@ -85,10 +99,18 @@ import { useCalendarStore } from '../store/calendarStore.js'
function renderPopover(occurrence = TIMED_OCCURRENCE) { function renderPopover(occurrence = TIMED_OCCURRENCE) {
mockOpenEventId = occurrence.id mockOpenEventId = occurrence.id
;(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({ ;(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(selector?: (s: Record<string, unknown>) => unknown) => {
const state = {
openEventId: mockOpenEventId, openEventId: mockOpenEventId,
setOpenEventId: mockSetOpenEventId, setOpenEventId: mockSetOpenEventId,
})) setEventForm: mockSetEventForm,
setDeleteDialog: mockSetDeleteDialog,
}
if (typeof selector === 'function') return selector(state)
return state
},
)
const client = new QueryClient({ const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } }, defaultOptions: { queries: { retry: false, staleTime: Infinity } },
@@ -178,10 +200,18 @@ describe('EventDetailPopover', () => {
it('renders nothing when openEventId is null', () => { it('renders nothing when openEventId is null', () => {
mockOpenEventId = null mockOpenEventId = null
;(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({ ;(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(selector?: (s: Record<string, unknown>) => unknown) => {
const state = {
openEventId: null, openEventId: null,
setOpenEventId: mockSetOpenEventId, setOpenEventId: mockSetOpenEventId,
})) setEventForm: mockSetEventForm,
setDeleteDialog: mockSetDeleteDialog,
}
if (typeof selector === 'function') return selector(state)
return state
},
)
const client = new QueryClient({ const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } }, defaultOptions: { queries: { retry: false, staleTime: Infinity } },
}) })
@@ -210,6 +240,31 @@ describe('EventDetailPopover', () => {
expect(descEl.textContent).toContain('<b>Bold</b> description') 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', () => { 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]'. // 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". // new Date() cannot parse the bracket, so the date/time line showed "Invalid Date, Invalid Date Invalid Date".