Add client-side watch/bookmark functionality: users can save events to localStorage without RSVPing via a bookmark button next to the "I'm attending" CTA. Watched events appear in the event list with a "Watching" label. Bookmark is only visible for visitors (not attendees or organizers). Includes spec, plan, research, tasks, unit tests, and E2E tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
253 lines
7.9 KiB
TypeScript
253 lines
7.9 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
|
import EventCreateView from '../EventCreateView.vue'
|
|
import { api } from '@/api/client'
|
|
vi.mock('@/api/client', () => ({
|
|
api: {
|
|
POST: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/composables/useEventStorage', () => ({
|
|
useEventStorage: vi.fn(() => ({
|
|
saveCreatedEvent: vi.fn(),
|
|
getStoredEvents: vi.fn(() => []),
|
|
getOrganizerToken: vi.fn(),
|
|
saveRsvp: vi.fn(),
|
|
getRsvp: vi.fn(),
|
|
})),
|
|
}))
|
|
|
|
function createTestRouter() {
|
|
return createRouter({
|
|
history: createMemoryHistory(),
|
|
routes: [
|
|
{ path: '/', name: 'home', component: { template: '<div />' } },
|
|
{ path: '/create', name: 'create-event', component: EventCreateView },
|
|
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
|
|
],
|
|
})
|
|
}
|
|
|
|
describe('EventCreateView', () => {
|
|
it('renders all form fields', async () => {
|
|
const router = createTestRouter()
|
|
await router.push('/create')
|
|
await router.isReady()
|
|
|
|
const wrapper = mount(EventCreateView, {
|
|
global: { plugins: [router] },
|
|
})
|
|
|
|
expect(wrapper.find('#title').exists()).toBe(true)
|
|
expect(wrapper.find('#description').exists()).toBe(true)
|
|
expect(wrapper.find('#dateTime').exists()).toBe(true)
|
|
expect(wrapper.find('#location').exists()).toBe(true)
|
|
})
|
|
|
|
it('has required attribute on required fields', async () => {
|
|
const router = createTestRouter()
|
|
await router.push('/create')
|
|
await router.isReady()
|
|
|
|
const wrapper = mount(EventCreateView, {
|
|
global: { plugins: [router] },
|
|
})
|
|
|
|
expect(wrapper.find('#title').attributes('required')).toBeDefined()
|
|
expect(wrapper.find('#dateTime').attributes('required')).toBeDefined()
|
|
})
|
|
|
|
it('does not have required attribute on optional fields', async () => {
|
|
const router = createTestRouter()
|
|
await router.push('/create')
|
|
await router.isReady()
|
|
|
|
const wrapper = mount(EventCreateView, {
|
|
global: { plugins: [router] },
|
|
})
|
|
|
|
expect(wrapper.find('#description').attributes('required')).toBeUndefined()
|
|
expect(wrapper.find('#location').attributes('required')).toBeUndefined()
|
|
})
|
|
|
|
it('has a submit button', async () => {
|
|
const router = createTestRouter()
|
|
await router.push('/create')
|
|
await router.isReady()
|
|
|
|
const wrapper = mount(EventCreateView, {
|
|
global: { plugins: [router] },
|
|
})
|
|
|
|
const button = wrapper.find('button[type="submit"]')
|
|
expect(button.exists()).toBe(true)
|
|
expect(button.text()).toBe('Create Event')
|
|
})
|
|
|
|
it('shows server error when network request fails', async () => {
|
|
vi.mocked(api.POST).mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
|
|
|
const router = createTestRouter()
|
|
await router.push('/create')
|
|
await router.isReady()
|
|
|
|
const wrapper = mount(EventCreateView, {
|
|
global: { plugins: [router] },
|
|
})
|
|
|
|
// Fill required fields
|
|
await wrapper.find('#title').setValue('My Event')
|
|
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
|
|
|
await wrapper.find('form').trigger('submit')
|
|
await flushPromises()
|
|
|
|
const alerts = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
|
|
expect(alerts).toContain('Could not reach the server. Please try again.')
|
|
|
|
// Submit button should not remain disabled
|
|
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeUndefined()
|
|
})
|
|
|
|
it('clears field error when user types into that field', async () => {
|
|
const router = createTestRouter()
|
|
await router.push('/create')
|
|
await router.isReady()
|
|
|
|
const wrapper = mount(EventCreateView, {
|
|
global: { plugins: [router] },
|
|
})
|
|
|
|
// Submit empty form to trigger validation errors
|
|
await wrapper.find('form').trigger('submit')
|
|
|
|
const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
|
|
expect(errorsBefore.length).toBeGreaterThanOrEqual(2)
|
|
|
|
// Type into title field
|
|
await wrapper.find('#title').setValue('My Event')
|
|
|
|
// Title error should be cleared (span removed from DOM), but other errors should remain
|
|
const titleError = wrapper.find('#title').element.closest('.form-group')!.querySelector('[role="alert"]')
|
|
expect(titleError).toBeNull()
|
|
|
|
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
|
expect(dateTimeError.textContent).not.toBe('')
|
|
})
|
|
|
|
it('shows validation errors when submitting empty form', async () => {
|
|
const router = createTestRouter()
|
|
await router.push('/create')
|
|
await router.isReady()
|
|
|
|
const wrapper = mount(EventCreateView, {
|
|
global: { plugins: [router] },
|
|
})
|
|
|
|
await wrapper.find('form').trigger('submit')
|
|
|
|
const errorElements = wrapper.findAll('[role="alert"]')
|
|
const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0)
|
|
expect(errorTexts.length).toBeGreaterThanOrEqual(2)
|
|
})
|
|
|
|
it('submits successfully, saves to storage, and navigates to event page', async () => {
|
|
const mockSave = vi.fn()
|
|
vi.mocked(vi.importActual<typeof import('@/composables/useEventStorage')>)
|
|
const { useEventStorage } = await import('@/composables/useEventStorage')
|
|
vi.mocked(useEventStorage).mockReturnValue({
|
|
saveCreatedEvent: mockSave,
|
|
getStoredEvents: vi.fn(() => []),
|
|
getOrganizerToken: vi.fn(),
|
|
saveRsvp: vi.fn(),
|
|
getRsvp: vi.fn(),
|
|
removeRsvp: vi.fn(),
|
|
saveWatch: vi.fn(),
|
|
isStored: vi.fn(() => false),
|
|
removeEvent: vi.fn(),
|
|
})
|
|
|
|
vi.mocked(api.POST).mockResolvedValueOnce({
|
|
data: {
|
|
eventToken: 'abc-123',
|
|
organizerToken: 'org-456',
|
|
title: 'Birthday Party',
|
|
dateTime: '2026-12-25T18:00:00+01:00',
|
|
timezone: 'Europe/Berlin',
|
|
},
|
|
error: undefined,
|
|
response: new Response(),
|
|
})
|
|
|
|
const router = createTestRouter()
|
|
const pushSpy = vi.spyOn(router, 'push')
|
|
await router.push('/create')
|
|
await router.isReady()
|
|
|
|
const wrapper = mount(EventCreateView, {
|
|
global: { plugins: [router] },
|
|
})
|
|
|
|
await wrapper.find('#title').setValue('Birthday Party')
|
|
await wrapper.find('#description').setValue('Come celebrate!')
|
|
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
|
await wrapper.find('#location').setValue('Berlin')
|
|
|
|
await wrapper.find('form').trigger('submit')
|
|
await flushPromises()
|
|
|
|
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events', {
|
|
body: expect.objectContaining({
|
|
title: 'Birthday Party',
|
|
description: 'Come celebrate!',
|
|
location: 'Berlin',
|
|
}),
|
|
})
|
|
|
|
expect(mockSave).toHaveBeenCalledWith({
|
|
eventToken: 'abc-123',
|
|
organizerToken: 'org-456',
|
|
title: 'Birthday Party',
|
|
dateTime: '2026-12-25T18:00:00+01:00',
|
|
})
|
|
|
|
expect(pushSpy).toHaveBeenCalledWith({
|
|
name: 'event',
|
|
params: { eventToken: 'abc-123' },
|
|
})
|
|
})
|
|
|
|
it('displays server-side field errors on the correct fields', async () => {
|
|
vi.mocked(api.POST).mockResolvedValueOnce({
|
|
data: undefined,
|
|
error: {
|
|
fieldErrors: [{ field: 'title', message: 'Title already taken' }],
|
|
},
|
|
response: new Response(),
|
|
} as ReturnType<typeof api.POST> extends Promise<infer R> ? R : never)
|
|
|
|
const router = createTestRouter()
|
|
await router.push('/create')
|
|
await router.isReady()
|
|
|
|
const wrapper = mount(EventCreateView, {
|
|
global: { plugins: [router] },
|
|
})
|
|
|
|
await wrapper.find('#title').setValue('Duplicate Event')
|
|
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
|
|
|
await wrapper.find('form').trigger('submit')
|
|
await flushPromises()
|
|
|
|
const titleError = wrapper.find('#title-error')
|
|
expect(titleError.exists()).toBe(true)
|
|
expect(titleError.text()).toBe('Title already taken')
|
|
|
|
// Other field errors should not be present
|
|
expect(wrapper.find('#dateTime-error').exists()).toBe(false)
|
|
})
|
|
})
|