Add RSVP frontend: bottom sheet form, RsvpBar, and localStorage persistence
Introduces BottomSheet and RsvpBar components, integrates the RSVP submission flow into EventDetailView, extends useEventStorage with saveRsvp/getRsvp, and adds unit tests plus an E2E spec for the RSVP workflow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
185
frontend/e2e/event-rsvp.spec.ts
Normal file
185
frontend/e2e/event-rsvp.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { test, expect } from './msw-setup'
|
||||
|
||||
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,
|
||||
expired: false,
|
||||
}
|
||||
|
||||
test.describe('US1: RSVP submission flow', () => {
|
||||
test('submits RSVP, updates attendee count, and persists in localStorage', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json(fullEvent)
|
||||
}),
|
||||
http.post('*/api/events/:token/rsvps', () => {
|
||||
return HttpResponse.json(
|
||||
{ rsvpToken: 'd4e5f6a7-b8c9-0123-4567-890abcdef012', name: 'Max Mustermann' },
|
||||
{ status: 201 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// CTA is visible
|
||||
const cta = page.getByRole('button', { name: "I'm attending" })
|
||||
await expect(cta).toBeVisible()
|
||||
|
||||
// Open bottom sheet
|
||||
await cta.click()
|
||||
const dialog = page.getByRole('dialog', { name: 'RSVP' })
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// Fill name and submit
|
||||
await dialog.getByLabel('Your name').fill('Max Mustermann')
|
||||
await dialog.getByRole('button', { name: 'Count me in' }).click()
|
||||
|
||||
// Bottom sheet closes, status bar appears
|
||||
await expect(dialog).not.toBeVisible()
|
||||
await expect(page.getByText("You're attending!")).toBeVisible()
|
||||
await expect(cta).not.toBeVisible()
|
||||
|
||||
// Attendee count incremented
|
||||
await expect(page.getByText('13')).toBeVisible()
|
||||
|
||||
// Verify localStorage
|
||||
const stored = await page.evaluate(() => {
|
||||
const raw = localStorage.getItem('fete:events')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
})
|
||||
expect(stored).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
rsvpToken: 'd4e5f6a7-b8c9-0123-4567-890abcdef012',
|
||||
rsvpName: 'Max Mustermann',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test('shows validation error when name is empty', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json(fullEvent)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
await page.getByRole('button', { name: "I'm attending" }).click()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'RSVP' })
|
||||
await dialog.getByRole('button', { name: 'Count me in' }).click()
|
||||
|
||||
await expect(page.getByText('Please enter your name.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('restores RSVP status from localStorage on page load', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json(fullEvent)
|
||||
}),
|
||||
)
|
||||
|
||||
// Pre-seed localStorage
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'fete:events',
|
||||
JSON.stringify([
|
||||
{
|
||||
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
title: 'Summer BBQ',
|
||||
dateTime: '2026-03-15T20:00:00+01:00',
|
||||
expiryDate: '',
|
||||
rsvpToken: 'existing-rsvp-token',
|
||||
rsvpName: 'Anna',
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Status bar should show, not CTA
|
||||
await expect(page.getByText("You're attending!")).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('shows error when server is unreachable during RSVP', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json(fullEvent)
|
||||
}),
|
||||
http.post('*/api/events/:token/rsvps', () => {
|
||||
return HttpResponse.json(
|
||||
{ type: 'about:blank', title: 'Bad Request', status: 400 },
|
||||
{ status: 400, headers: { 'Content-Type': 'application/problem+json' } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
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()
|
||||
|
||||
await expect(page.getByText('Could not submit RSVP. Please try again.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not show RSVP bar for organizer', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json(fullEvent)
|
||||
}),
|
||||
)
|
||||
|
||||
// Pre-seed localStorage with organizer token
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'fete:events',
|
||||
JSON.stringify([
|
||||
{
|
||||
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
organizerToken: 'org-token-123',
|
||||
title: 'Summer BBQ',
|
||||
dateTime: '2026-03-15T20:00:00+01:00',
|
||||
expiryDate: '',
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
// Event content should load
|
||||
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
||||
|
||||
// But no RSVP bar
|
||||
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
|
||||
await expect(page.getByText("You're attending!")).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('does not show RSVP bar on expired event', async ({ page, network }) => {
|
||||
network.use(
|
||||
http.get('*/api/events/:token', () => {
|
||||
return HttpResponse.json({ ...fullEvent, expired: true })
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||
|
||||
await expect(page.getByText('This event has ended.')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user