Files
fete/frontend/src/views/__tests__/EventDetailView.spec.ts
nitrix 76b48d8b61 Add EventDetailView with loading, expired, not-found, and error states
New view fetches event via openapi-fetch, formats date/time with
Intl.DateTimeFormat. Skeleton shimmer during loading (CSS-only).
Create form now sends auto-detected timezone.
Unit tests for all five view states, E2E tests with MSW mocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:34:51 +01:00

199 lines
6.0 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(),
},
}))
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div />' } },
{ path: '/events/:token', 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] },
})
}
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,
}
beforeEach(() => {
vi.restoreAllMocks()
})
describe('EventDetailView', () => {
// T014: 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)
})
// T013: Loaded state — all fields
it('renders all event fields when loaded', async () => {
vi.mocked(api.GET).mockResolvedValue({
data: fullEvent,
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
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')
})
// T013: Loaded state — locale-formatted date/time
it('formats date/time with Intl.DateTimeFormat and timezone', async () => {
vi.mocked(api.GET).mockResolvedValue({
data: fullEvent,
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
const wrapper = await mountWithToken()
await flushPromises()
const dateField = wrapper.findAll('.detail__value')[0]
expect(dateField.text()).toContain('(Europe/Berlin)')
// The formatted date part is locale-dependent but should contain the year
expect(dateField.text()).toContain('2026')
})
// T013: Loaded state — optional fields absent
it('does not render description and location when absent', async () => {
vi.mocked(api.GET).mockResolvedValue({
data: {
...fullEvent,
description: undefined,
location: undefined,
attendeeCount: 0,
},
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.text()).not.toContain('Description')
expect(wrapper.text()).not.toContain('Location')
expect(wrapper.text()).toContain('0')
})
// T020 (US2): Expired state
it('renders "event has ended" banner when expired', async () => {
vi.mocked(api.GET).mockResolvedValue({
data: { ...fullEvent, expired: true },
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.text()).toContain('This event has ended.')
expect(wrapper.find('.detail__banner--expired').exists()).toBe(true)
})
// T020 (US2): No expired banner when not expired
it('does not render expired banner when event is active', async () => {
vi.mocked(api.GET).mockResolvedValue({
data: fullEvent,
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.detail__banner--expired').exists()).toBe(false)
})
// T023 (US4): 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.')
// No event data in DOM
expect(wrapper.find('.detail__title').exists()).toBe(false)
})
// T027: 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')
})
// T027: 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')
})
})