Files
fete/frontend/e2e/cancel-rsvp.spec.ts
nitrix b067c0ef1e
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
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>
2026-03-13 16:23:04 +01:00

277 lines
10 KiB
TypeScript

import { http, HttpResponse } from 'msw'
import { test, expect } from './msw-setup'
import type { StoredEvent } from '../src/composables/useEventStorage'
const STORAGE_KEY = 'fete:events'
const fullEvent = {
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
title: 'Summer BBQ',
description: 'Bring your own drinks!',
dateTime: '2026-03-15T20:00:00+01:00',
timezone: 'Europe/Berlin',
location: 'Central Park, NYC',
attendeeCount: 12,
}
const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012'
function seedEvents(events: StoredEvent[]): string {
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
}
function rsvpSeed(): StoredEvent {
return {
eventToken: fullEvent.eventToken,
title: fullEvent.title,
dateTime: fullEvent.dateTime,
rsvpToken,
rsvpName: 'Anna',
}
}
test.describe('US1: Cancel RSVP from Event Detail View', () => {
test('status bar shows cancel affordance when RSVP\'d', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Status bar visible
const statusBar = page.getByRole('button', { name: /You're attending/ })
await expect(statusBar).toBeVisible()
// Cancel button hidden initially
await expect(page.getByRole('button', { name: 'Cancel RSVP' })).not.toBeVisible()
})
test('tapping status bar reveals cancel button', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Tap status bar
await page.getByRole('button', { name: /You're attending/ }).click()
// Cancel button appears
await expect(page.getByRole('button', { name: 'Cancel RSVP' })).toBeVisible()
})
test('confirm cancellation → localStorage cleared, count decremented, bar reset', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return new HttpResponse(null, { status: 204 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Expand → Cancel RSVP → Confirm in dialog
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
// Confirm dialog
await expect(page.getByText('The organizer will no longer see you as attending.')).toBeVisible()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// Bar resets to CTA state
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
await expect(page.getByText("You're attending!")).not.toBeVisible()
// Attendee count decremented
await expect(page.getByText('11 going')).toBeVisible()
// localStorage cleared
const stored = await page.evaluate(() => {
const raw = localStorage.getItem('fete:events')
return raw ? JSON.parse(raw) : null
})
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
expect(event?.rsvpToken).toBeUndefined()
expect(event?.rsvpName).toBeUndefined()
})
test('server error → error message, state unchanged', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return HttpResponse.json({ error: 'fail' }, { status: 500 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Expand → Cancel → Confirm in dialog
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// Error message
await expect(page.getByText('Could not cancel RSVP. Please try again.')).toBeVisible()
// Attendee count unchanged
await expect(page.getByText('12 going')).toBeVisible()
})
test('re-RSVP after cancel works', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return new HttpResponse(null, { status: 204 })
}),
http.post('*/api/events/:token/rsvps', () => {
return HttpResponse.json(
{ rsvpToken: 'new-rsvp-token', name: 'Max' },
{ status: 201 },
)
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Cancel first
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// CTA should be back
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
// Re-RSVP
await page.getByRole('button', { name: "I'm attending" }).click()
const dialog = page.getByRole('dialog', { name: 'RSVP' })
await dialog.getByLabel('Your name').fill('Max')
await dialog.getByRole('button', { name: 'Count me in' }).click()
// Status bar returns
await expect(page.getByText("You're attending!")).toBeVisible()
})
})
test.describe('US2: Auto-Cancel on Event List Removal', () => {
test('removal of RSVP\'d event shows attendance warning in dialog', async ({ page }) => {
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await expect(page.getByText('your attendance will be cancelled')).toBeVisible()
})
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',
}
await page.addInitScript(seedEvents([watcherEvent]))
await page.goto('/')
// Watcher events are removed directly without dialog
await page.getByRole('button', { name: /Remove Watcher Event/ }).click()
// 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 }) => {
network.use(
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return new HttpResponse(null, { status: 204 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await page.getByRole('button', { name: 'Remove', exact: true }).click()
// Event gone
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
// localStorage updated
const stored = await page.evaluate(() => {
const raw = localStorage.getItem('fete:events')
return raw ? JSON.parse(raw) : null
})
const found = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
expect(found).toBeUndefined()
})
test('server error on DELETE → error message, event stays in list', async ({ page, network }) => {
network.use(
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return HttpResponse.json({ error: 'fail' }, { status: 500 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await page.getByRole('button', { name: 'Remove', exact: true }).click()
// Event still in list
await expect(page.getByText('Summer BBQ')).toBeVisible()
})
test('dismiss dialog → no changes', async ({ page }) => {
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await page.getByRole('button', { name: 'Cancel' }).click()
// Event still there
await expect(page.getByText('Summer BBQ')).toBeVisible()
})
})
test.describe('US3: Cancel RSVP with Stale/Invalid Token', () => {
test('cancel from detail view with stale token (404) → treated as success', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return HttpResponse.json({ error: 'not found' }, { status: 404 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto(`/events/${fullEvent.eventToken}`)
// Cancel flow
await page.getByRole('button', { name: /You're attending/ }).click()
await page.locator('.rsvp-bar__cancel').click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel RSVP' }).click()
// Treated as success — CTA returns
await expect(page.getByRole('button', { name: "I'm attending" })).toBeVisible()
// localStorage cleaned
const stored = await page.evaluate(() => {
const raw = localStorage.getItem('fete:events')
return raw ? JSON.parse(raw) : null
})
const event = stored?.find((e: StoredEvent) => e.eventToken === fullEvent.eventToken)
expect(event?.rsvpToken).toBeUndefined()
})
test('event list removal with stale token (404) → treated as success', async ({ page, network }) => {
network.use(
http.delete('*/api/events/:token/rsvps/:rsvpToken', () => {
return HttpResponse.json({ error: 'not found' }, { status: 404 })
}),
)
await page.addInitScript(seedEvents([rsvpSeed()]))
await page.goto('/')
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
await page.getByRole('button', { name: 'Remove', exact: true }).click()
// Event removed from list
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
})
})