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>
464 lines
14 KiB
TypeScript
464 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
|
import EventDetailView from '../EventDetailView.vue'
|
|
import { api } from '@/api/client'
|
|
|
|
vi.mock('@/api/client', () => ({
|
|
api: {
|
|
GET: vi.fn(),
|
|
POST: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const mockSaveRsvp = vi.fn()
|
|
const mockGetRsvp = vi.fn()
|
|
const mockGetOrganizerToken = vi.fn()
|
|
const mockSaveWatch = vi.fn()
|
|
const mockIsStored = vi.fn()
|
|
const mockRemoveEvent = vi.fn()
|
|
|
|
vi.mock('@/composables/useEventStorage', () => ({
|
|
useEventStorage: vi.fn(() => ({
|
|
saveCreatedEvent: vi.fn(),
|
|
getStoredEvents: vi.fn(() => []),
|
|
getOrganizerToken: mockGetOrganizerToken,
|
|
saveRsvp: mockSaveRsvp,
|
|
getRsvp: mockGetRsvp,
|
|
removeRsvp: vi.fn(),
|
|
saveWatch: mockSaveWatch,
|
|
isStored: mockIsStored,
|
|
removeEvent: mockRemoveEvent,
|
|
})),
|
|
}))
|
|
|
|
function createTestRouter(_token?: string) {
|
|
return createRouter({
|
|
history: createMemoryHistory(),
|
|
routes: [
|
|
{ path: '/', name: 'home', component: { template: '<div />' } },
|
|
{ path: '/events/:eventToken', name: 'event', component: EventDetailView },
|
|
],
|
|
})
|
|
}
|
|
|
|
async function mountWithToken(token = 'test-token') {
|
|
const router = createTestRouter(token)
|
|
await router.push(`/events/${token}`)
|
|
await router.isReady()
|
|
return mount(EventDetailView, {
|
|
global: { plugins: [router] },
|
|
attachTo: document.body,
|
|
})
|
|
}
|
|
|
|
const fullEvent = {
|
|
eventToken: 'abc-123',
|
|
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,
|
|
}
|
|
|
|
function mockLoadedEvent(eventOverrides = {}) {
|
|
vi.mocked(api.GET).mockResolvedValue({
|
|
data: { ...fullEvent, ...eventOverrides },
|
|
error: undefined,
|
|
response: new Response(null, { status: 200 }),
|
|
} as never)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
mockGetRsvp.mockReturnValue(undefined)
|
|
mockGetOrganizerToken.mockReturnValue(undefined)
|
|
mockIsStored.mockReturnValue(false)
|
|
mockSaveWatch.mockClear()
|
|
mockRemoveEvent.mockClear()
|
|
})
|
|
|
|
describe('EventDetailView', () => {
|
|
// Loading state
|
|
it('renders skeleton shimmer placeholders while loading', async () => {
|
|
vi.mocked(api.GET).mockReturnValue(new Promise(() => {}))
|
|
|
|
const wrapper = await mountWithToken()
|
|
|
|
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
|
|
expect(wrapper.findAll('.skeleton').length).toBeGreaterThanOrEqual(3)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
// Loaded state — all fields
|
|
it('renders all event fields when loaded', async () => {
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.detail__title').text()).toBe('Summer BBQ')
|
|
expect(wrapper.text()).toContain('Bring your own drinks!')
|
|
expect(wrapper.text()).toContain('Central Park, NYC')
|
|
expect(wrapper.text()).toContain('12')
|
|
expect(wrapper.text()).toContain('Europe/Berlin')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
// Loaded state — locale-formatted date/time
|
|
it('formats date/time with Intl.DateTimeFormat and timezone', async () => {
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
const dateField = wrapper.findAll('.detail__meta-text')[0]!
|
|
expect(dateField.text()).toContain('(Europe/Berlin)')
|
|
expect(dateField.text()).toContain('2026')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
// Loaded state — optional fields absent
|
|
it('does not render description and location when absent', async () => {
|
|
mockLoadedEvent({ description: undefined, location: undefined, attendeeCount: 0 })
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).not.toContain('Description')
|
|
expect(wrapper.text()).not.toContain('Location')
|
|
expect(wrapper.text()).toContain('0')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
// Not found state
|
|
it('renders "event not found" when API returns 404', async () => {
|
|
vi.mocked(api.GET).mockResolvedValue({
|
|
data: undefined,
|
|
error: { type: 'about:blank', title: 'Not Found', status: 404 },
|
|
response: new Response(null, { status: 404 }),
|
|
} as never)
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('Event not found.')
|
|
expect(wrapper.find('.detail__title').exists()).toBe(false)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
// Server error + retry
|
|
it('renders error state with retry button on server error', async () => {
|
|
vi.mocked(api.GET).mockResolvedValue({
|
|
data: undefined,
|
|
error: { type: 'about:blank', title: 'Internal Server Error', status: 500 },
|
|
response: new Response(null, { status: 500 }),
|
|
} as never)
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('Something went wrong.')
|
|
expect(wrapper.find('button').text()).toBe('Retry')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
// Retry button re-fetches
|
|
it('retry button triggers a new fetch', async () => {
|
|
vi.mocked(api.GET)
|
|
.mockResolvedValueOnce({
|
|
data: undefined,
|
|
error: { type: 'about:blank', title: 'Error', status: 500 },
|
|
response: new Response(null, { status: 500 }),
|
|
} as never)
|
|
.mockResolvedValueOnce({
|
|
data: fullEvent,
|
|
error: undefined,
|
|
response: new Response(null, { status: 200 }),
|
|
} as never)
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('Something went wrong.')
|
|
|
|
await wrapper.find('button').trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.detail__title').text()).toBe('Summer BBQ')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
// RSVP bar
|
|
it('shows RSVP CTA bar on active event', async () => {
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
|
|
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending")
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('does not show RSVP bar for organizer', async () => {
|
|
mockGetOrganizerToken.mockReturnValue('org-token-123')
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
|
|
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('shows RSVP status bar when localStorage has RSVP', async () => {
|
|
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true)
|
|
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
|
|
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
// RSVP form submission
|
|
it('opens bottom sheet when CTA is clicked', async () => {
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
|
|
|
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('shows validation error when submitting empty name', async () => {
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
|
await flushPromises()
|
|
|
|
// Form is inside Teleport — find via document.body
|
|
const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement
|
|
form.dispatchEvent(new Event('submit', { bubbles: true }))
|
|
await flushPromises()
|
|
|
|
expect(document.body.querySelector('.rsvp-form__field-error')?.textContent).toBe('Please enter your name.')
|
|
expect(vi.mocked(api.POST)).not.toHaveBeenCalled()
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('submits RSVP, saves to storage, and shows status', async () => {
|
|
mockLoadedEvent()
|
|
vi.mocked(api.POST).mockResolvedValue({
|
|
data: { rsvpToken: 'rsvp-token-1', name: 'Max' },
|
|
error: undefined,
|
|
response: new Response(null, { status: 201 }),
|
|
} as never)
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
// Open sheet
|
|
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
|
await flushPromises()
|
|
|
|
// Fill name via Teleported input
|
|
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement
|
|
input.value = 'Max'
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
await flushPromises()
|
|
|
|
// Submit form
|
|
const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement
|
|
form.dispatchEvent(new Event('submit', { bubbles: true }))
|
|
await flushPromises()
|
|
|
|
// Verify API call
|
|
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events/{eventToken}/rsvps', {
|
|
params: { path: { eventToken: 'test-token' } },
|
|
body: { name: 'Max' },
|
|
})
|
|
|
|
// Verify storage
|
|
expect(mockSaveRsvp).toHaveBeenCalledWith(
|
|
'abc-123',
|
|
'rsvp-token-1',
|
|
'Max',
|
|
'Summer BBQ',
|
|
'2026-03-15T20:00:00+01:00',
|
|
)
|
|
|
|
// Verify UI switched to status
|
|
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
|
|
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
|
|
|
// Verify attendee count incremented
|
|
expect(wrapper.text()).toContain('13')
|
|
|
|
wrapper.unmount()
|
|
})
|
|
|
|
// Attendee list (organizer)
|
|
it('shows attendee list for organizer', async () => {
|
|
mockGetOrganizerToken.mockReturnValue('org-token-123')
|
|
mockLoadedEvent()
|
|
vi.mocked(api.GET)
|
|
.mockResolvedValueOnce({
|
|
data: fullEvent,
|
|
error: undefined,
|
|
response: new Response(null, { status: 200 }),
|
|
} as never)
|
|
.mockResolvedValueOnce({
|
|
data: { attendees: [{ name: 'Alice' }, { name: 'Bob' }] },
|
|
error: undefined,
|
|
response: new Response(null, { status: 200 }),
|
|
} as never)
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.attendee-list').exists()).toBe(true)
|
|
expect(wrapper.text()).toContain('Alice')
|
|
expect(wrapper.text()).toContain('Bob')
|
|
expect(wrapper.find('.attendee-list__heading').text()).toBe('2 Attendees')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('does not show attendee list for visitor', async () => {
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.attendee-list').exists()).toBe(false)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('shows error when RSVP submission fails', async () => {
|
|
mockLoadedEvent()
|
|
vi.mocked(api.POST).mockResolvedValue({
|
|
data: undefined,
|
|
error: { type: 'about:blank', title: 'Bad Request', status: 400 },
|
|
response: new Response(null, { status: 400 }),
|
|
} as never)
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
|
|
await flushPromises()
|
|
|
|
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement
|
|
input.value = 'Max'
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
await flushPromises()
|
|
|
|
const form = document.body.querySelector('.rsvp-form')! as HTMLFormElement
|
|
form.dispatchEvent(new Event('submit', { bubbles: true }))
|
|
await flushPromises()
|
|
|
|
expect(document.body.textContent).toContain('Could not submit RSVP. Please try again.')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
// Bookmark — T007: bookmark state is passed to RsvpBar via props
|
|
it('passes bookmarked=false to RsvpBar when event is not in storage', async () => {
|
|
mockIsStored.mockReturnValue(false)
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
|
expect(rsvpBar.props('bookmarked')).toBe(false)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('passes bookmarked=true to RsvpBar when event is in storage', async () => {
|
|
mockIsStored.mockReturnValue(true)
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
|
expect(rsvpBar.props('bookmarked')).toBe(true)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('bookmark event emitted from RsvpBar calls saveWatch', async () => {
|
|
mockIsStored.mockReturnValue(false)
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
|
rsvpBar.vm.$emit('bookmark')
|
|
await flushPromises()
|
|
|
|
expect(mockSaveWatch).toHaveBeenCalledWith('test-token', 'Summer BBQ', '2026-03-15T20:00:00+01:00')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('bookmark event emitted from RsvpBar calls removeEvent when user is watcher', async () => {
|
|
mockIsStored.mockReturnValue(true)
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
|
rsvpBar.vm.$emit('bookmark')
|
|
await flushPromises()
|
|
|
|
expect(mockRemoveEvent).toHaveBeenCalledWith('test-token')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('bookmark event ignored when user is attendee', async () => {
|
|
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
|
|
mockIsStored.mockReturnValue(true)
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
|
rsvpBar.vm.$emit('bookmark')
|
|
await flushPromises()
|
|
|
|
expect(mockRemoveEvent).not.toHaveBeenCalled()
|
|
expect(mockSaveWatch).not.toHaveBeenCalled()
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('passes bookmarked=true to RsvpBar after removeRsvp (event still in storage)', async () => {
|
|
mockIsStored.mockReturnValue(true)
|
|
mockGetRsvp.mockReturnValue(undefined)
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
|
expect(rsvpBar.props('bookmarked')).toBe(true)
|
|
wrapper.unmount()
|
|
})
|
|
})
|