Add organizer cancel-event flow to EventList
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m35s
CI / build-and-publish (push) Has been skipped

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:
2026-03-13 16:23:04 +01:00
parent 51ab99fc61
commit b067c0ef1e
12 changed files with 838 additions and 21 deletions

View 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',
)
})
})

View File

@@ -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 }) => {

View File

@@ -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()
})
})

View File

@@ -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) {

View File

@@ -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')
})
})