Organizers can now cancel events directly from the event list via the
existing PATCH /events/{eventToken} API. The confirmation dialog shows
role-differentiated messaging: "Cancel event?" with a severity warning
for organizers vs. "Remove event?" for attendees. Responses 204, 409,
and 404 all result in successful removal from the local list.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
|
import EventList from '../EventList.vue'
|
|
|
|
vi.mock('../../api/client', () => ({
|
|
api: {
|
|
PATCH: vi.fn(),
|
|
DELETE: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const router = createRouter({
|
|
history: createMemoryHistory(),
|
|
routes: [
|
|
{ path: '/', component: { template: '<div />' } },
|
|
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
|
|
],
|
|
})
|
|
|
|
// Fixed "now": Wednesday, 2026-03-11 12:00
|
|
const NOW = new Date(2026, 2, 11, 12, 0, 0)
|
|
|
|
const mockEvents = [
|
|
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00' },
|
|
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00' },
|
|
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00' },
|
|
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00' },
|
|
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00' },
|
|
{ eventToken: 'org-1', title: 'Organized Event', dateTime: '2026-03-11T19:00:00', organizerToken: 'org-token' },
|
|
{ eventToken: 'rsvp-1', title: 'Attending Event', dateTime: '2026-03-11T20:00:00', rsvpToken: 'rsvp-token', rsvpName: 'Max' },
|
|
]
|
|
|
|
const removeEventMock = vi.fn()
|
|
|
|
vi.mock('../../composables/useEventStorage', () => ({
|
|
isValidStoredEvent: (e: unknown) => {
|
|
if (typeof e !== 'object' || e === null) return false
|
|
const obj = e as Record<string, unknown>
|
|
return typeof obj.eventToken === 'string' && obj.eventToken.length > 0
|
|
&& typeof obj.title === 'string' && obj.title.length > 0
|
|
&& typeof obj.dateTime === 'string' && obj.dateTime.length > 0
|
|
},
|
|
useEventStorage: () => ({
|
|
getStoredEvents: () => mockEvents,
|
|
getRsvp: (token: string) => {
|
|
const evt = mockEvents.find((e) => e.eventToken === token)
|
|
if (evt && 'rsvpToken' in evt && 'rsvpName' in evt) {
|
|
return { rsvpToken: evt.rsvpToken, rsvpName: evt.rsvpName }
|
|
}
|
|
return undefined
|
|
},
|
|
getOrganizerToken: (token: string) => {
|
|
const evt = mockEvents.find((e) => e.eventToken === token)
|
|
if (evt && 'organizerToken' in evt) {
|
|
return (evt as Record<string, unknown>).organizerToken as string
|
|
}
|
|
return undefined
|
|
},
|
|
removeEvent: removeEventMock,
|
|
}),
|
|
}))
|
|
|
|
vi.mock('../../composables/useRelativeTime', () => ({
|
|
formatRelativeTime: (dateTime: string) => {
|
|
if (dateTime.includes('03-01')) return '10 days ago'
|
|
if (dateTime.includes('06-15')) return 'in 1 year'
|
|
if (dateTime.includes('03-11T18')) return 'in 6 hours'
|
|
if (dateTime.includes('03-11T19')) return 'in 7 hours'
|
|
if (dateTime.includes('03-11T20')) return 'in 8 hours'
|
|
if (dateTime.includes('03-13')) return 'in 2 days'
|
|
if (dateTime.includes('03-16')) return 'in 5 days'
|
|
return 'sometime'
|
|
},
|
|
}))
|
|
|
|
function mountList() {
|
|
return mount(EventList, {
|
|
global: {
|
|
plugins: [router],
|
|
stubs: { Teleport: true },
|
|
},
|
|
})
|
|
}
|
|
|
|
describe('EventList', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(NOW)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('renders section headers for each non-empty section', () => {
|
|
const wrapper = mountList()
|
|
const headers = wrapper.findAll('.section-header')
|
|
expect(headers).toHaveLength(5)
|
|
expect(headers[0]!.text()).toBe('Today')
|
|
expect(headers[1]!.text()).toBe('This Week')
|
|
expect(headers[2]!.text()).toBe('Next Week')
|
|
expect(headers[3]!.text()).toBe('Later')
|
|
expect(headers[4]!.text()).toBe('Past')
|
|
})
|
|
|
|
it('renders events within their correct sections', () => {
|
|
const wrapper = mountList()
|
|
const sections = wrapper.findAll('.event-section')
|
|
expect(sections).toHaveLength(5)
|
|
|
|
expect(sections[0]!.text()).toContain('Today Event')
|
|
expect(sections[1]!.text()).toContain('This Week Event')
|
|
expect(sections[2]!.text()).toContain('Next Week Event')
|
|
expect(sections[3]!.text()).toContain('Later Event')
|
|
expect(sections[4]!.text()).toContain('Past Event')
|
|
})
|
|
|
|
it('renders all valid events as cards', () => {
|
|
const wrapper = mountList()
|
|
const cards = wrapper.findAll('.event-card')
|
|
expect(cards).toHaveLength(7)
|
|
})
|
|
|
|
it('marks past events with isPast class', () => {
|
|
const wrapper = mountList()
|
|
const pastSection = wrapper.findAll('.event-section')[4]!
|
|
const pastCards = pastSection.findAll('.event-card')
|
|
expect(pastCards).toHaveLength(1)
|
|
expect(pastCards[0]!.classes()).toContain('event-card--past')
|
|
})
|
|
|
|
it('does not mark non-past events with isPast class', () => {
|
|
const wrapper = mountList()
|
|
const todaySection = wrapper.findAll('.event-section')[0]!
|
|
const cards = todaySection.findAll('.event-card')
|
|
expect(cards[0]!.classes()).not.toContain('event-card--past')
|
|
})
|
|
|
|
it('sections have aria-label attributes', () => {
|
|
const wrapper = mountList()
|
|
const sections = wrapper.findAll('section')
|
|
expect(sections[0]!.attributes('aria-label')).toBe('Today')
|
|
expect(sections[1]!.attributes('aria-label')).toBe('This Week')
|
|
expect(sections[2]!.attributes('aria-label')).toBe('Next Week')
|
|
expect(sections[3]!.attributes('aria-label')).toBe('Later')
|
|
expect(sections[4]!.attributes('aria-label')).toBe('Past')
|
|
})
|
|
|
|
it('does not render date subheader in "Today" section', () => {
|
|
const wrapper = mountList()
|
|
const todaySection = wrapper.findAll('.event-section')[0]!
|
|
expect(todaySection.find('.date-subheader').exists()).toBe(false)
|
|
})
|
|
|
|
it('renders date subheaders in non-today sections', () => {
|
|
const wrapper = mountList()
|
|
const thisWeekSection = wrapper.findAll('.event-section')[1]!
|
|
expect(thisWeekSection.find('.date-subheader').exists()).toBe(true)
|
|
|
|
const nextWeekSection = wrapper.findAll('.event-section')[2]!
|
|
expect(nextWeekSection.find('.date-subheader').exists()).toBe(true)
|
|
|
|
const laterSection = wrapper.findAll('.event-section')[3]!
|
|
expect(laterSection.find('.date-subheader').exists()).toBe(true)
|
|
|
|
const pastSection = wrapper.findAll('.event-section')[4]!
|
|
expect(pastSection.find('.date-subheader').exists()).toBe(true)
|
|
})
|
|
|
|
it('assigns watcher role when event has no organizerToken and no rsvpToken', () => {
|
|
const wrapper = mountList()
|
|
const badges = wrapper.findAll('.event-card__badge--watcher')
|
|
expect(badges.length).toBeGreaterThanOrEqual(1)
|
|
expect(badges[0]!.text()).toBe('Watching')
|
|
})
|
|
|
|
it('assigns organizer role when event has organizerToken', () => {
|
|
const wrapper = mountList()
|
|
const badge = wrapper.find('.event-card__badge--organizer')
|
|
expect(badge.exists()).toBe(true)
|
|
expect(badge.text()).toBe('Organizing')
|
|
})
|
|
|
|
it('assigns attendee role when event has rsvpToken', () => {
|
|
const wrapper = mountList()
|
|
const badge = wrapper.find('.event-card__badge--attendee')
|
|
expect(badge.exists()).toBe(true)
|
|
expect(badge.text()).toBe('Attending')
|
|
})
|
|
})
|
|
|
|
describe('EventList — Organizer Cancel (US1)', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(NOW)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('T005: confirmDelete calls PATCH cancel-event API when role is organizer', async () => {
|
|
const { api } = await import('../../api/client')
|
|
const patchMock = vi.mocked(api.PATCH)
|
|
patchMock.mockResolvedValue({ response: { status: 204 } } as never)
|
|
|
|
const wrapper = mountList()
|
|
|
|
// Find the organizer event delete button and click it
|
|
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
|
|
expect(orgCard).toBeTruthy()
|
|
await orgCard!.find('.event-card__delete').trigger('click')
|
|
|
|
// Confirm the dialog
|
|
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
|
|
await confirmBtn.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(patchMock).toHaveBeenCalledWith(
|
|
'/events/{eventToken}',
|
|
expect.objectContaining({
|
|
params: expect.objectContaining({
|
|
path: { eventToken: 'org-1' },
|
|
query: { organizerToken: 'org-token' },
|
|
}),
|
|
body: { cancelled: true },
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('T006: confirmDelete treats 409 response as success (removes event from list)', async () => {
|
|
const { api } = await import('../../api/client')
|
|
const patchMock = vi.mocked(api.PATCH)
|
|
patchMock.mockResolvedValue({ response: { status: 409 } } as never)
|
|
|
|
removeEventMock.mockClear()
|
|
|
|
const wrapper = mountList()
|
|
|
|
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
|
|
await orgCard!.find('.event-card__delete').trigger('click')
|
|
|
|
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
|
|
await confirmBtn.trigger('click')
|
|
await flushPromises()
|
|
|
|
// 409 should be treated as success — removeEvent should have been called
|
|
expect(removeEventMock).toHaveBeenCalledWith('org-1')
|
|
})
|
|
|
|
it('T006b: confirmDelete treats 404 response as success (removes event from list)', async () => {
|
|
const { api } = await import('../../api/client')
|
|
const patchMock = vi.mocked(api.PATCH)
|
|
patchMock.mockResolvedValue({ response: { status: 404 } } as never)
|
|
|
|
removeEventMock.mockClear()
|
|
|
|
const wrapper = mountList()
|
|
|
|
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
|
|
await orgCard!.find('.event-card__delete').trigger('click')
|
|
|
|
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
|
|
await confirmBtn.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(removeEventMock).toHaveBeenCalledWith('org-1')
|
|
})
|
|
})
|
|
|
|
describe('EventList — Dialog Differentiation (US2)', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(NOW)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('T013: deleteDialogTitle returns organizer-specific text when role is organizer', async () => {
|
|
const wrapper = mountList()
|
|
|
|
// Click delete on organizer event
|
|
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
|
|
await orgCard!.find('.event-card__delete').trigger('click')
|
|
|
|
const dialogTitle = wrapper.find('.confirm-dialog__title')
|
|
expect(dialogTitle.text()).toBe('Cancel event?')
|
|
})
|
|
|
|
it('T014: deleteDialogMessage returns existing attendee text when role is attendee', async () => {
|
|
const wrapper = mountList()
|
|
|
|
// Click delete on attendee event
|
|
const attCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Attending Event'))
|
|
await attCard!.find('.event-card__delete').trigger('click')
|
|
|
|
const dialogTitle = wrapper.find('.confirm-dialog__title')
|
|
expect(dialogTitle.text()).toBe('Remove event?')
|
|
|
|
const dialogMsg = wrapper.find('.confirm-dialog__message')
|
|
expect(dialogMsg.text()).toContain('attendance will be cancelled')
|
|
})
|
|
})
|