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>
199 lines
6.0 KiB
TypeScript
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')
|
|
})
|
|
})
|