Replace card-based event detail view with full-screen gradient layout. Add hero image with gradient overlay, icon-based meta rows, and "About" section. Content renders directly on the gradient background with white text for an app-native feel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
369 lines
11 KiB
TypeScript
369 lines
11 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()
|
|
|
|
vi.mock('@/composables/useEventStorage', () => ({
|
|
useEventStorage: vi.fn(() => ({
|
|
saveCreatedEvent: vi.fn(),
|
|
getStoredEvents: vi.fn(() => []),
|
|
getOrganizerToken: mockGetOrganizerToken,
|
|
saveRsvp: mockSaveRsvp,
|
|
getRsvp: mockGetRsvp,
|
|
removeEvent: vi.fn(),
|
|
})),
|
|
}))
|
|
|
|
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,
|
|
expired: false,
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
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()
|
|
})
|
|
|
|
// Expired state
|
|
it('renders "event has ended" banner when expired', async () => {
|
|
mockLoadedEvent({ expired: true })
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('This event has ended.')
|
|
expect(wrapper.find('.detail__banner--expired').exists()).toBe(true)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
// No expired banner when not expired
|
|
it('does not render expired banner when event is active', async () => {
|
|
mockLoadedEvent()
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.detail__banner--expired').exists()).toBe(false)
|
|
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('does not show RSVP bar on expired event', async () => {
|
|
mockLoadedEvent({ expired: true })
|
|
|
|
const wrapper = await mountWithToken()
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
|
expect(wrapper.find('.rsvp-bar').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').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').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').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/{token}/rsvps', {
|
|
params: { path: { token: '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()
|
|
})
|
|
|
|
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').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()
|
|
})
|
|
})
|