Add client-side watch/bookmark functionality: users can save events to localStorage without RSVPing via a bookmark button next to the "I'm attending" CTA. Watched events appear in the event list with a "Watching" label. Bookmark is only visible for visitors (not attendees or organizers). Includes spec, plan, research, tasks, unit tests, and E2E tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
219 lines
7.6 KiB
TypeScript
219 lines
7.6 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,
|
|
cancelled: false,
|
|
}
|
|
|
|
const rsvpToken = 'd4e5f6a7-b8c9-0123-4567-890abcdef012'
|
|
const organizerToken = 'org-token-1234'
|
|
|
|
function seedEvents(events: StoredEvent[]): string {
|
|
return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})`
|
|
}
|
|
|
|
function watchSeed(): StoredEvent {
|
|
return {
|
|
eventToken: fullEvent.eventToken,
|
|
title: fullEvent.title,
|
|
dateTime: fullEvent.dateTime,
|
|
}
|
|
}
|
|
|
|
function rsvpSeed(): StoredEvent {
|
|
return {
|
|
eventToken: fullEvent.eventToken,
|
|
title: fullEvent.title,
|
|
dateTime: fullEvent.dateTime,
|
|
rsvpToken,
|
|
rsvpName: 'Anna',
|
|
}
|
|
}
|
|
|
|
function organizerSeed(): StoredEvent {
|
|
return {
|
|
eventToken: fullEvent.eventToken,
|
|
title: fullEvent.title,
|
|
dateTime: fullEvent.dateTime,
|
|
organizerToken,
|
|
}
|
|
}
|
|
|
|
test.describe('US1: Watch event from detail page', () => {
|
|
test('bookmark unfilled by default, tapping watches the event', async ({ page, network }) => {
|
|
network.use(
|
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
|
)
|
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
|
|
|
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
|
await expect(bookmark).toBeVisible()
|
|
await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event')
|
|
|
|
await bookmark.click()
|
|
|
|
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
|
|
|
// Navigate to event list via back link
|
|
await page.locator('.detail__back').click()
|
|
|
|
// Event appears with "Watching" label
|
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
|
await expect(page.getByText('Watching')).toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe('US2: Un-watch event from detail page', () => {
|
|
test('tapping filled bookmark un-watches the event', async ({ page, network }) => {
|
|
network.use(
|
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
|
)
|
|
await page.addInitScript(seedEvents([watchSeed()]))
|
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
|
|
|
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
|
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
|
|
|
await bookmark.click()
|
|
|
|
await expect(bookmark).toHaveAttribute('aria-label', 'Watch this event')
|
|
|
|
// Navigate to event list via back link (avoid page.goto re-running addInitScript)
|
|
await page.locator('.detail__back').click()
|
|
|
|
// Event is gone
|
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe('US3: Bookmark reflects attending status', () => {
|
|
test('bookmark is not visible when user has RSVPed, list shows Attendee', async ({ page, network }) => {
|
|
network.use(
|
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
|
)
|
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
|
|
|
// Bookmark not shown for attendees — RsvpBar shows status state
|
|
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
|
await expect(bookmark).not.toBeVisible()
|
|
|
|
// Navigate to list via back link
|
|
await page.locator('.detail__back').click()
|
|
await expect(page.getByText('Attendee')).toBeVisible()
|
|
await expect(page.getByText('Watching')).not.toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe('US4: RSVP cancellation preserves watch status', () => {
|
|
test('cancel RSVP → bookmark reappears, list shows Watching', 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}`)
|
|
|
|
// Cancel RSVP
|
|
await page.getByRole('button', { name: /You're attending/ }).click()
|
|
await page.locator('.rsvp-bar__cancel').click()
|
|
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel attendance' }).click()
|
|
|
|
// Bookmark reappears in CTA state, filled because event is still stored
|
|
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
|
await expect(bookmark).toBeVisible()
|
|
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
|
|
|
// Navigate to list via back link
|
|
await page.locator('.detail__back').click()
|
|
await expect(page.getByText('Watching')).toBeVisible()
|
|
await expect(page.getByText('Attendee')).not.toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe('US5: No bookmark for attendees and organizers', () => {
|
|
test('attendee does not see bookmark', async ({ page, network }) => {
|
|
network.use(
|
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
|
)
|
|
await page.addInitScript(seedEvents([rsvpSeed()]))
|
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
|
|
|
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
|
await expect(bookmark).not.toBeVisible()
|
|
})
|
|
|
|
test('organizer does not see bookmark', async ({ page, network }) => {
|
|
network.use(
|
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
|
)
|
|
await page.addInitScript(seedEvents([organizerSeed()]))
|
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
|
|
|
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
|
await expect(bookmark).not.toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe('US6: Un-watch from event list', () => {
|
|
test('deleting a watched event skips confirmation dialog', async ({ page }) => {
|
|
await page.addInitScript(seedEvents([watchSeed()]))
|
|
await page.goto('/')
|
|
|
|
await expect(page.getByText('Summer BBQ')).toBeVisible()
|
|
|
|
await page.getByRole('button', { name: /Remove Summer BBQ/ }).click()
|
|
|
|
// No confirmation dialog — event removed immediately
|
|
await expect(page.getByText('Remove event?')).not.toBeVisible()
|
|
await expect(page.getByText('Summer BBQ')).not.toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe('US7: Watcher upgrades to attendee', () => {
|
|
test('watch → RSVP → bookmark disappears, list shows Attendee', async ({ page, network }) => {
|
|
network.use(
|
|
http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)),
|
|
http.post('*/api/events/:token/rsvps', () => {
|
|
return HttpResponse.json(
|
|
{ rsvpToken: 'new-rsvp-token', name: 'Max' },
|
|
{ status: 201 },
|
|
)
|
|
}),
|
|
)
|
|
await page.addInitScript(seedEvents([watchSeed()]))
|
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
|
|
|
// Verify watching state — bookmark visible
|
|
const bookmark = page.locator('.rsvp-bar__bookmark-inner')
|
|
await expect(bookmark).toBeVisible()
|
|
await expect(bookmark).toHaveAttribute('aria-label', 'Stop watching this event')
|
|
|
|
// 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()
|
|
|
|
// Bookmark gone — status bar shown instead
|
|
await expect(bookmark).not.toBeVisible()
|
|
|
|
// Navigate to list via back link
|
|
await page.locator('.detail__back').click()
|
|
await expect(page.getByText('Attendee')).toBeVisible()
|
|
await expect(page.getByText('Watching')).not.toBeVisible()
|
|
})
|
|
})
|