From 76b48d8b615b2b198570abde22f402734e38054b Mon Sep 17 00:00:00 2001 From: nitrix Date: Fri, 6 Mar 2026 22:34:00 +0100 Subject: [PATCH] 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 --- frontend/e2e/event-create.spec.ts | 3 +- frontend/e2e/event-view.spec.ts | 127 +++++++++++ frontend/src/assets/main.css | 13 ++ frontend/src/router/index.ts | 2 +- frontend/src/views/EventCreateView.vue | 1 + frontend/src/views/EventDetailView.vue | 214 ++++++++++++++++++ .../views/__tests__/EventDetailView.spec.ts | 198 ++++++++++++++++ 7 files changed, 555 insertions(+), 3 deletions(-) create mode 100644 frontend/e2e/event-view.spec.ts create mode 100644 frontend/src/views/EventDetailView.vue create mode 100644 frontend/src/views/__tests__/EventDetailView.spec.ts diff --git a/frontend/e2e/event-create.spec.ts b/frontend/e2e/event-create.spec.ts index 0db9f01..aac9ceb 100644 --- a/frontend/e2e/event-create.spec.ts +++ b/frontend/e2e/event-create.spec.ts @@ -12,7 +12,7 @@ test.describe('US-1: Create an event', () => { 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.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 expect(page).toHaveURL(/\/events\/.+/) - await expect(page.getByText('Event created!')).toBeVisible() }) test('stores event data in localStorage after creation', async ({ page }) => { diff --git a/frontend/e2e/event-view.spec.ts b/frontend/e2e/event-view.spec.ts new file mode 100644 index 0000000..e25c054 --- /dev/null +++ b/frontend/e2e/event-view.spec.ts @@ -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() + }) +}) diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 62440ad..2da8244 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -163,6 +163,19 @@ textarea.form-field { 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 */ .text-center { text-align: center; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 07bc62d..3d63d78 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -17,7 +17,7 @@ const router = createRouter({ { path: '/events/:token', name: 'event', - component: () => import('../views/EventStubView.vue'), + component: () => import('../views/EventDetailView.vue'), }, ], }) diff --git a/frontend/src/views/EventCreateView.vue b/frontend/src/views/EventCreateView.vue index e48af62..aa8ea6c 100644 --- a/frontend/src/views/EventCreateView.vue +++ b/frontend/src/views/EventCreateView.vue @@ -184,6 +184,7 @@ async function handleSubmit() { title: form.title.trim(), description: form.description.trim() || undefined, dateTime: dateTimeWithOffset, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, location: form.location.trim() || undefined, expiryDate: form.expiryDate, }, diff --git a/frontend/src/views/EventDetailView.vue b/frontend/src/views/EventDetailView.vue new file mode 100644 index 0000000..b946f98 --- /dev/null +++ b/frontend/src/views/EventDetailView.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/frontend/src/views/__tests__/EventDetailView.spec.ts b/frontend/src/views/__tests__/EventDetailView.spec.ts new file mode 100644 index 0000000..634f94d --- /dev/null +++ b/frontend/src/views/__tests__/EventDetailView.spec.ts @@ -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: '
' } }, + { 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') + }) +})