From 2fbeffee9a8e0fdd9581a41e78cce427f3d60c93 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 18:44:40 -0400 Subject: [PATCH] test(03-06): add failing tests for EventDetailPopover footer and DeleteConfirmationDialog --- .../DeleteConfirmationDialog.test.tsx | 154 ++++++++++++++++++ .../components/EventDetailPopover.test.tsx | 81 +++++++-- 2 files changed, 222 insertions(+), 13 deletions(-) create mode 100644 apps/pwa/src/components/DeleteConfirmationDialog.test.tsx diff --git a/apps/pwa/src/components/DeleteConfirmationDialog.test.tsx b/apps/pwa/src/components/DeleteConfirmationDialog.test.tsx new file mode 100644 index 0000000..3f598b5 --- /dev/null +++ b/apps/pwa/src/components/DeleteConfirmationDialog.test.tsx @@ -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) => 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( + + + , + ) +} + +// ── 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') + }) +}) diff --git a/apps/pwa/src/components/EventDetailPopover.test.tsx b/apps/pwa/src/components/EventDetailPopover.test.tsx index 34e66d9..6e9da89 100644 --- a/apps/pwa/src/components/EventDetailPopover.test.tsx +++ b/apps/pwa/src/components/EventDetailPopover.test.tsx @@ -19,14 +19,28 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' // ── 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 vi.mock('../store/calendarStore.js', () => ({ - useCalendarStore: vi.fn(() => ({ - openEventId: mockOpenEventId, - setOpenEventId: mockSetOpenEventId, - })), + useCalendarStore: vi.fn((selector?: (s: Record) => unknown) => { + const state = { + openEventId: mockOpenEventId, + setOpenEventId: mockSetOpenEventId, + setEventForm: mockSetEventForm, + setDeleteDialog: mockSetDeleteDialog, + } + if (typeof selector === 'function') return selector(state) + return state + }), })) // ── Fixtures ─────────────────────────────────────────────────────────────────── @@ -85,10 +99,18 @@ import { useCalendarStore } from '../store/calendarStore.js' function renderPopover(occurrence = TIMED_OCCURRENCE) { mockOpenEventId = occurrence.id - ;(useCalendarStore as unknown as ReturnType).mockImplementation(() => ({ - openEventId: mockOpenEventId, - setOpenEventId: mockSetOpenEventId, - })) + ;(useCalendarStore as unknown as ReturnType).mockImplementation( + (selector?: (s: Record) => 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 } }, @@ -178,10 +200,18 @@ describe('EventDetailPopover', () => { it('renders nothing when openEventId is null', () => { mockOpenEventId = null - ;(useCalendarStore as unknown as ReturnType).mockImplementation(() => ({ - openEventId: null, - setOpenEventId: mockSetOpenEventId, - })) + ;(useCalendarStore as unknown as ReturnType).mockImplementation( + (selector?: (s: Record) => 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 } }, }) @@ -210,6 +240,31 @@ describe('EventDetailPopover', () => { expect(descEl.textContent).toContain('Bold 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".