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: '
' } }, { 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() }) })