Add organizer cancel-event flow to EventList
Organizers can now cancel events directly from the event list via the
existing PATCH /events/{eventToken} API. The confirmation dialog shows
role-differentiated messaging: "Cancel event?" with a severity warning
for organizers vs. "Remove event?" for attendees. Responses 204, 409,
and 404 all result in successful removal from the local list.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
210
frontend/e2e/cancel-event-list.spec.ts
Normal file
210
frontend/e2e/cancel-event-list.spec.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { test, expect } from './msw-setup'
|
||||
import type { StoredEvent } from '../src/composables/useEventStorage'
|
||||
|
||||
const STORAGE_KEY = 'fete:events'
|
||||
|
||||
const organizerEvent: StoredEvent = {
|
||||
eventToken: 'org-event-aaa',
|
||||
title: 'Summer BBQ',
|
||||
dateTime: '2027-06-15T18:00:00Z',
|
||||
organizerToken: 'org-secret-token',
|
||||
}
|
||||
|
||||
const attendeeEvent: StoredEvent = {
|
||||
eventToken: 'att-event-bbb',
|
||||
title: 'Team Meeting',
|
||||
dateTime: '2027-01-10T09:00:00Z',
|
||||
rsvpToken: 'rsvp-token-1',
|
||||
rsvpName: 'Alice',
|
||||
}
|
||||
|
||||
function seedEvents(events: StoredEvent[]): string {
|
||||
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
||||
}
|
||||
|
||||
test.describe('US1: Organizer Cancels Event from List', () => {
|
||||
test('T001: organizer taps delete, confirms, event is removed after successful API call', async ({
|
||||
page,
|
||||
network,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent, attendeeEvent]))
|
||||
|
||||
const { http, HttpResponse } = await import('msw')
|
||||
let patchCalled = false
|
||||
network.use(
|
||||
http.patch('*/api/events/:token', ({ request, params }) => {
|
||||
const url = new URL(request.url)
|
||||
if (
|
||||
params['token'] === organizerEvent.eventToken &&
|
||||
url.searchParams.get('organizerToken') === organizerEvent.organizerToken
|
||||
) {
|
||||
patchCalled = true
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}
|
||||
return HttpResponse.json(
|
||||
{ type: 'about:blank', title: 'Forbidden', status: 403 },
|
||||
{ status: 403, headers: { 'Content-Type': 'application/problem+json' } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
|
||||
// Click delete on organizer event
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
|
||||
// Confirmation dialog appears with organizer-specific text
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
// Confirm cancellation
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event is removed from list
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
// Other event remains
|
||||
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||
|
||||
expect(patchCalled).toBe(true)
|
||||
})
|
||||
|
||||
test('T002: organizer confirms cancellation, API fails, event stays in list and error shown', async ({
|
||||
page,
|
||||
network,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
|
||||
const { http, HttpResponse } = await import('msw')
|
||||
network.use(
|
||||
http.patch('*/api/events/:token', () => {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
type: 'about:blank',
|
||||
title: 'Internal Server Error',
|
||||
status: 500,
|
||||
},
|
||||
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// Event stays in list
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
|
||||
test('T003: organizer confirms cancellation, API returns 409 Conflict, event is silently removed', async ({
|
||||
page,
|
||||
network,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
|
||||
const { http, HttpResponse } = await import('msw')
|
||||
network.use(
|
||||
http.patch('*/api/events/:token', () => {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
type: 'about:blank',
|
||||
title: 'Conflict',
|
||||
status: 409,
|
||||
detail: 'Event is already cancelled.',
|
||||
},
|
||||
{ status: 409, headers: { 'Content-Type': 'application/problem+json' } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
|
||||
// 409 treated as success — event removed
|
||||
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('T004: organizer opens cancel dialog then dismisses (cancel button), event remains', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
// Dismiss via Cancel button
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
|
||||
test('T004b: organizer opens cancel dialog then dismisses via Escape', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
|
||||
test('T004c: organizer opens cancel dialog then dismisses via overlay click', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible()
|
||||
|
||||
// Click on overlay (outside dialog)
|
||||
await page.locator('.confirm-dialog__overlay').click({ position: { x: 10, y: 10 } })
|
||||
|
||||
await expect(page.getByRole('alertdialog')).not.toBeVisible()
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('US2: Distinct Dialog for Organizer vs. Attendee', () => {
|
||||
test('T011: organizer dialog shows event-cancellation warning', async ({ page }) => {
|
||||
await page.addInitScript(seedEvents([organizerEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
const dialog = page.getByRole('alertdialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// Organizer-specific title and message
|
||||
await expect(dialog.locator('.confirm-dialog__title')).toHaveText('Cancel event?')
|
||||
await expect(dialog.locator('.confirm-dialog__message')).toContainText(
|
||||
'all attendees',
|
||||
)
|
||||
})
|
||||
|
||||
test('T012: attendee dialog preserves existing RSVP-cancellation message', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([attendeeEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Team Meeting/ }).click()
|
||||
const dialog = page.getByRole('alertdialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// Attendee-specific title and message
|
||||
await expect(dialog.locator('.confirm-dialog__title')).toHaveText('Remove event?')
|
||||
await expect(dialog.locator('.confirm-dialog__message')).toContainText(
|
||||
'attendance will be cancelled',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -162,20 +162,20 @@ test.describe('US2: Auto-Cancel on Event List Removal', () => {
|
||||
await expect(page.getByText('your attendance will be cancelled')).toBeVisible()
|
||||
})
|
||||
|
||||
test('removal of non-RSVP\'d event shows standard dialog', async ({ page }) => {
|
||||
const noRsvp: StoredEvent = {
|
||||
eventToken: 'no-rsvp-token',
|
||||
title: 'No RSVP Event',
|
||||
test('removal of non-RSVP\'d watcher event shows standard dialog', async ({ page }) => {
|
||||
const watcherEvent: StoredEvent = {
|
||||
eventToken: 'watcher-token',
|
||||
title: 'Watcher Event',
|
||||
dateTime: '2027-06-15T18:00:00Z',
|
||||
organizerToken: 'org-123',
|
||||
}
|
||||
await page.addInitScript(seedEvents([noRsvp]))
|
||||
await page.addInitScript(seedEvents([watcherEvent]))
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove No RSVP Event/ }).click()
|
||||
// Watcher events are removed directly without dialog
|
||||
await page.getByRole('button', { name: /Remove Watcher Event/ }).click()
|
||||
|
||||
await expect(page.getByText('This event will be removed from your list.')).toBeVisible()
|
||||
await expect(page.getByText('attendance will be cancelled')).not.toBeVisible()
|
||||
// Watcher removal is immediate — event disappears
|
||||
await expect(page.getByText('Watcher Event')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('confirm removal → DELETE called → event removed from list', async ({ page, network }) => {
|
||||
|
||||
@@ -93,19 +93,30 @@ test.describe('US4: Past Events Appear Faded', () => {
|
||||
})
|
||||
|
||||
test.describe('US3: Remove Event from List', () => {
|
||||
test('delete icon triggers confirmation dialog, confirm removes event', async ({ page }) => {
|
||||
test('delete icon triggers confirmation dialog, confirm removes event', async ({
|
||||
page,
|
||||
network,
|
||||
}) => {
|
||||
await page.addInitScript(seedEvents([futureEvent1, futureEvent2]))
|
||||
|
||||
const { http, HttpResponse } = await import('msw')
|
||||
network.use(
|
||||
http.patch('*/api/events/:token', () => {
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
// Both events visible
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
await expect(page.getByText('Team Meeting')).toBeVisible()
|
||||
|
||||
// Click delete on Summer BBQ
|
||||
// Click delete on Summer BBQ (organizer event)
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
|
||||
// Confirmation dialog appears
|
||||
await expect(page.getByText('Remove event?')).toBeVisible()
|
||||
// Confirmation dialog appears (organizer event shows "Cancel event?")
|
||||
await expect(page.getByText('Cancel event?')).toBeVisible()
|
||||
|
||||
// Confirm removal
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
@@ -120,13 +131,13 @@ test.describe('US3: Remove Event from List', () => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
||||
await expect(page.getByText('Remove event?')).toBeVisible()
|
||||
await expect(page.getByText('Cancel event?')).toBeVisible()
|
||||
|
||||
// Cancel
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// Dialog gone, event still there
|
||||
await expect(page.getByText('Remove event?')).not.toBeVisible()
|
||||
await expect(page.getByText('Cancel event?')).not.toBeVisible()
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</section>
|
||||
<ConfirmDialog
|
||||
:open="!!pendingDeleteToken"
|
||||
title="Remove event?"
|
||||
:title="deleteDialogTitle"
|
||||
:message="deleteDialogMessage"
|
||||
confirm-label="Remove"
|
||||
cancel-label="Cancel"
|
||||
@@ -49,13 +49,26 @@ import DateSubheader from './DateSubheader.vue'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import type { StoredEvent } from '../composables/useEventStorage'
|
||||
|
||||
const { getStoredEvents, getRsvp, removeEvent } = useEventStorage()
|
||||
const { getStoredEvents, getRsvp, getOrganizerToken, removeEvent } = useEventStorage()
|
||||
|
||||
const pendingDeleteToken = ref<string | null>(null)
|
||||
const deleteError = ref('')
|
||||
|
||||
const pendingDeleteRole = computed(() => {
|
||||
if (!pendingDeleteToken.value) return null
|
||||
const event = getStoredEvents().find((e) => e.eventToken === pendingDeleteToken.value)
|
||||
return event ? getRole(event) : null
|
||||
})
|
||||
|
||||
const deleteDialogTitle = computed(() => {
|
||||
return pendingDeleteRole.value === 'organizer' ? 'Cancel event?' : 'Remove event?'
|
||||
})
|
||||
|
||||
const deleteDialogMessage = computed(() => {
|
||||
if (!pendingDeleteToken.value) return ''
|
||||
if (pendingDeleteRole.value === 'organizer') {
|
||||
return 'This will permanently cancel the event for all attendees.'
|
||||
}
|
||||
const rsvp = getRsvp(pendingDeleteToken.value)
|
||||
if (rsvp) {
|
||||
return 'This event will be removed from your list and your attendance will be cancelled.'
|
||||
@@ -77,6 +90,32 @@ async function confirmDelete() {
|
||||
if (!pendingDeleteToken.value) return
|
||||
|
||||
const eventToken = pendingDeleteToken.value
|
||||
const organizerToken = getOrganizerToken(eventToken)
|
||||
|
||||
if (organizerToken) {
|
||||
try {
|
||||
const { response } = await api.PATCH('/events/{eventToken}', {
|
||||
params: {
|
||||
path: { eventToken },
|
||||
query: { organizerToken },
|
||||
},
|
||||
body: { cancelled: true },
|
||||
})
|
||||
|
||||
if (response.status !== 204 && response.status !== 409 && response.status !== 404) {
|
||||
deleteError.value = 'Could not cancel event. Please try again.'
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
deleteError.value = 'Could not cancel event. Please try again.'
|
||||
return
|
||||
}
|
||||
|
||||
removeEvent(eventToken)
|
||||
pendingDeleteToken.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const rsvp = getRsvp(eventToken)
|
||||
|
||||
if (rsvp) {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import EventList from '../EventList.vue'
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
PATCH: vi.fn(),
|
||||
DELETE: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
@@ -24,6 +31,8 @@ const mockEvents = [
|
||||
{ eventToken: 'rsvp-1', title: 'Attending Event', dateTime: '2026-03-11T20:00:00', rsvpToken: 'rsvp-token', rsvpName: 'Max' },
|
||||
]
|
||||
|
||||
const removeEventMock = vi.fn()
|
||||
|
||||
vi.mock('../../composables/useEventStorage', () => ({
|
||||
isValidStoredEvent: (e: unknown) => {
|
||||
if (typeof e !== 'object' || e === null) return false
|
||||
@@ -41,7 +50,14 @@ vi.mock('../../composables/useEventStorage', () => ({
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
removeEvent: vi.fn(),
|
||||
getOrganizerToken: (token: string) => {
|
||||
const evt = mockEvents.find((e) => e.eventToken === token)
|
||||
if (evt && 'organizerToken' in evt) {
|
||||
return (evt as Record<string, unknown>).organizerToken as string
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
removeEvent: removeEventMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -60,7 +76,10 @@ vi.mock('../../composables/useRelativeTime', () => ({
|
||||
|
||||
function mountList() {
|
||||
return mount(EventList, {
|
||||
global: { plugins: [router] },
|
||||
global: {
|
||||
plugins: [router],
|
||||
stubs: { Teleport: true },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,3 +189,118 @@ describe('EventList', () => {
|
||||
expect(badge.text()).toBe('Attending')
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventList — Organizer Cancel (US1)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('T005: confirmDelete calls PATCH cancel-event API when role is organizer', async () => {
|
||||
const { api } = await import('../../api/client')
|
||||
const patchMock = vi.mocked(api.PATCH)
|
||||
patchMock.mockResolvedValue({ response: { status: 204 } } as never)
|
||||
|
||||
const wrapper = mountList()
|
||||
|
||||
// Find the organizer event delete button and click it
|
||||
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
|
||||
expect(orgCard).toBeTruthy()
|
||||
await orgCard!.find('.event-card__delete').trigger('click')
|
||||
|
||||
// Confirm the dialog
|
||||
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
|
||||
await confirmBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(patchMock).toHaveBeenCalledWith(
|
||||
'/events/{eventToken}',
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
path: { eventToken: 'org-1' },
|
||||
query: { organizerToken: 'org-token' },
|
||||
}),
|
||||
body: { cancelled: true },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('T006: confirmDelete treats 409 response as success (removes event from list)', async () => {
|
||||
const { api } = await import('../../api/client')
|
||||
const patchMock = vi.mocked(api.PATCH)
|
||||
patchMock.mockResolvedValue({ response: { status: 409 } } as never)
|
||||
|
||||
removeEventMock.mockClear()
|
||||
|
||||
const wrapper = mountList()
|
||||
|
||||
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
|
||||
await orgCard!.find('.event-card__delete').trigger('click')
|
||||
|
||||
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
|
||||
await confirmBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// 409 should be treated as success — removeEvent should have been called
|
||||
expect(removeEventMock).toHaveBeenCalledWith('org-1')
|
||||
})
|
||||
|
||||
it('T006b: confirmDelete treats 404 response as success (removes event from list)', async () => {
|
||||
const { api } = await import('../../api/client')
|
||||
const patchMock = vi.mocked(api.PATCH)
|
||||
patchMock.mockResolvedValue({ response: { status: 404 } } as never)
|
||||
|
||||
removeEventMock.mockClear()
|
||||
|
||||
const wrapper = mountList()
|
||||
|
||||
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
|
||||
await orgCard!.find('.event-card__delete').trigger('click')
|
||||
|
||||
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
|
||||
await confirmBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(removeEventMock).toHaveBeenCalledWith('org-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventList — Dialog Differentiation (US2)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('T013: deleteDialogTitle returns organizer-specific text when role is organizer', async () => {
|
||||
const wrapper = mountList()
|
||||
|
||||
// Click delete on organizer event
|
||||
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
|
||||
await orgCard!.find('.event-card__delete').trigger('click')
|
||||
|
||||
const dialogTitle = wrapper.find('.confirm-dialog__title')
|
||||
expect(dialogTitle.text()).toBe('Cancel event?')
|
||||
})
|
||||
|
||||
it('T014: deleteDialogMessage returns existing attendee text when role is attendee', async () => {
|
||||
const wrapper = mountList()
|
||||
|
||||
// Click delete on attendee event
|
||||
const attCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Attending Event'))
|
||||
await attCard!.find('.event-card__delete').trigger('click')
|
||||
|
||||
const dialogTitle = wrapper.find('.confirm-dialog__title')
|
||||
expect(dialogTitle.text()).toBe('Remove event?')
|
||||
|
||||
const dialogMsg = wrapper.find('.confirm-dialog__message')
|
||||
expect(dialogMsg.text()).toContain('attendance will be cancelled')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user