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>
This commit is contained in:
@@ -12,7 +12,7 @@ test.describe('US-1: Create an event', () => {
|
|||||||
await expect(page.getByText('Expiry date is required.')).toBeVisible()
|
await expect(page.getByText('Expiry date is required.')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('creates an event and redirects to stub page', async ({ page }) => {
|
test('creates an event and redirects to event detail page', async ({ page }) => {
|
||||||
await page.goto('/create')
|
await page.goto('/create')
|
||||||
|
|
||||||
await page.getByLabel(/title/i).fill('Summer BBQ')
|
await page.getByLabel(/title/i).fill('Summer BBQ')
|
||||||
@@ -24,7 +24,6 @@ test.describe('US-1: Create an event', () => {
|
|||||||
await page.getByRole('button', { name: /create event/i }).click()
|
await page.getByRole('button', { name: /create event/i }).click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/events\/.+/)
|
await expect(page).toHaveURL(/\/events\/.+/)
|
||||||
await expect(page.getByText('Event created!')).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('stores event data in localStorage after creation', async ({ page }) => {
|
test('stores event data in localStorage after creation', async ({ page }) => {
|
||||||
|
|||||||
127
frontend/e2e/event-view.spec.ts
Normal file
127
frontend/e2e/event-view.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { test, expect } from './msw-setup'
|
||||||
|
|
||||||
|
const fullEvent = {
|
||||||
|
eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US-1: View event details', () => {
|
||||||
|
test('displays all event fields for a valid event', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Bring your own drinks!')).toBeVisible()
|
||||||
|
await expect(page.getByText('Central Park, NYC')).toBeVisible()
|
||||||
|
await expect(page.getByText('12')).toBeVisible()
|
||||||
|
await expect(page.getByText('Europe/Berlin')).toBeVisible()
|
||||||
|
await expect(page.getByText('2026')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not load external resources', async ({ page, network }) => {
|
||||||
|
const externalRequests: string[] = []
|
||||||
|
page.on('request', (req) => {
|
||||||
|
const url = new URL(req.url())
|
||||||
|
if (!['localhost', '127.0.0.1'].includes(url.hostname)) {
|
||||||
|
externalRequests.push(req.url())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
|
||||||
|
expect(externalRequests).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US-2: View expired event', () => {
|
||||||
|
test('shows "event has ended" banner for expired event', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json({ ...fullEvent, expired: true })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByText('This event has ended.')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('US-4: Event not found', () => {
|
||||||
|
test('shows "event not found" for unknown token', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ type: 'urn:problem-type:event-not-found', title: 'Event Not Found', status: 404, detail: 'Event not found.' },
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto('/events/00000000-0000-0000-0000-000000000000')
|
||||||
|
|
||||||
|
await expect(page.getByText('Event not found.')).toBeVisible()
|
||||||
|
// No event data visible
|
||||||
|
await expect(page.locator('.detail__title')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Server error', () => {
|
||||||
|
test('shows error message and retry button on 500', async ({ page, network }) => {
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ type: 'about:blank', title: 'Internal Server Error', status: 500, detail: 'An unexpected error occurred.' },
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
|
||||||
|
await expect(page.getByText('Something went wrong.')).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retry button re-fetches the event', async ({ page, network }) => {
|
||||||
|
let callCount = 0
|
||||||
|
network.use(
|
||||||
|
http.get('*/api/events/:token', () => {
|
||||||
|
callCount++
|
||||||
|
if (callCount === 1) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ type: 'about:blank', title: 'Error', status: 500 },
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/problem+json' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return HttpResponse.json(fullEvent)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/events/${fullEvent.eventToken}`)
|
||||||
|
await expect(page.getByText('Something went wrong.')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Retry' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -163,6 +163,19 @@ textarea.form-field {
|
|||||||
padding-left: 0.25rem;
|
padding-left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Skeleton shimmer loading state */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--color-card) 25%, #e0e0e0 50%, var(--color-card) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Utility */
|
/* Utility */
|
||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '/events/:token',
|
path: '/events/:token',
|
||||||
name: 'event',
|
name: 'event',
|
||||||
component: () => import('../views/EventStubView.vue'),
|
component: () => import('../views/EventDetailView.vue'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ async function handleSubmit() {
|
|||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
description: form.description.trim() || undefined,
|
description: form.description.trim() || undefined,
|
||||||
dateTime: dateTimeWithOffset,
|
dateTime: dateTimeWithOffset,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
location: form.location.trim() || undefined,
|
location: form.location.trim() || undefined,
|
||||||
expiryDate: form.expiryDate,
|
expiryDate: form.expiryDate,
|
||||||
},
|
},
|
||||||
|
|||||||
214
frontend/src/views/EventDetailView.vue
Normal file
214
frontend/src/views/EventDetailView.vue
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<template>
|
||||||
|
<main class="detail">
|
||||||
|
<header class="detail__header">
|
||||||
|
<RouterLink to="/" class="detail__back" aria-label="Back to home">←</RouterLink>
|
||||||
|
<span class="detail__brand">fete</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="state === 'loading'" class="detail__card" aria-busy="true" aria-label="Loading event details">
|
||||||
|
<div class="skeleton skeleton--title" />
|
||||||
|
<div class="skeleton skeleton--line" />
|
||||||
|
<div class="skeleton skeleton--line skeleton--short" />
|
||||||
|
<div class="skeleton skeleton--line" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loaded state -->
|
||||||
|
<div v-else-if="state === 'loaded' && event" class="detail__card">
|
||||||
|
<h1 class="detail__title">{{ event.title }}</h1>
|
||||||
|
|
||||||
|
<dl class="detail__fields">
|
||||||
|
<div class="detail__field">
|
||||||
|
<dt class="detail__label">Date & Time</dt>
|
||||||
|
<dd class="detail__value">{{ formattedDateTime }}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="event.description" class="detail__field">
|
||||||
|
<dt class="detail__label">Description</dt>
|
||||||
|
<dd class="detail__value">{{ event.description }}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="event.location" class="detail__field">
|
||||||
|
<dt class="detail__label">Location</dt>
|
||||||
|
<dd class="detail__value">{{ event.location }}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail__field">
|
||||||
|
<dt class="detail__label">Attendees</dt>
|
||||||
|
<dd class="detail__value">{{ event.attendeeCount }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
|
||||||
|
This event has ended.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Not found state -->
|
||||||
|
<div v-else-if="state === 'not-found'" class="detail__card detail__card--center" role="status">
|
||||||
|
<p class="detail__message">Event not found.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="state === 'error'" class="detail__card detail__card--center" role="alert">
|
||||||
|
<p class="detail__message">Something went wrong.</p>
|
||||||
|
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
import { api } from '@/api/client'
|
||||||
|
import type { components } from '@/api/schema'
|
||||||
|
|
||||||
|
type GetEventResponse = components['schemas']['GetEventResponse']
|
||||||
|
type State = 'loading' | 'loaded' | 'not-found' | 'error'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const state = ref<State>('loading')
|
||||||
|
const event = ref<GetEventResponse | null>(null)
|
||||||
|
|
||||||
|
const formattedDateTime = computed(() => {
|
||||||
|
if (!event.value) return ''
|
||||||
|
const formatted = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'long',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(event.value.dateTime))
|
||||||
|
return `${formatted} (${event.value.timezone})`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchEvent() {
|
||||||
|
state.value = 'loading'
|
||||||
|
event.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error, response } = await api.GET('/events/{token}', {
|
||||||
|
params: { path: { token: route.params.token as string } },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
state.value = response.status === 404 ? 'not-found' : 'error'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.value = data!
|
||||||
|
state.value = 'loaded'
|
||||||
|
} catch {
|
||||||
|
state.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchEvent)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2xl);
|
||||||
|
padding-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__back {
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__brand {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__card {
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__card--center {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__value {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__banner {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__banner--expired {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #e65100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__message {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton sizes */
|
||||||
|
.skeleton--title {
|
||||||
|
height: 1.6rem;
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton--line {
|
||||||
|
height: 1rem;
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton--short {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
198
frontend/src/views/__tests__/EventDetailView.spec.ts
Normal file
198
frontend/src/views/__tests__/EventDetailView.spec.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user