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: '
' } }, { path: '/events/:eventToken', name: 'event', component: { template: '
' } }, ], }) // 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 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).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') }) })