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>
277 lines
10 KiB
TypeScript
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()
|
|
})
|
|
})
|