import { afterEach, describe, expect, it, vi } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' import router from '@/router' import { initializeLocale, setLocale } from '@/i18n' import { useSessionStore } from '@/stores/session' import type { ActivityItem } from '@/lib/activity' import HomeView from '../HomeView.vue' import { anonymous, makeItem, target } from './activityFixtures' import { articleEnvelope, articleListRoute } from './articleEnvelope' import { accessFor } from '@/test/access' const ACTIVITY_ROUTE = 'GET /v1/activity?size=5' const ARTICLES_ROUTE = articleListRoute({ size: 5, sort: 'updated_at:desc' }) type Handler = () => Response | Promise function response(status: number, body: unknown = null) { return { ok: status >= 200 && status < 300, status, headers: { get: () => null }, json: async () => body, } as unknown as Response } function page(items: ActivityItem[], total = items.length) { return response(200, { items, total, page: 1, size: 5 }) } function recentItems(count: number) { const created = new Date(Date.now() - 30_000).toISOString() return Array.from({ length: count }, (_, index) => makeItem({ id: index + 1, created_at: created }), ) } const articles = [ { id: 1, title: 'First article', body: '', images: [], is_online: true, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-06T00:00:00Z', }, ] let wrapper: Awaited> | null = null let calls: string[] = [] async function mountHome(role: 'owner' | 'admin' | undefined, handlers: Record) { setActivePinia(createPinia()) const session = useSessionStore() session.session = { verified: true, username: 'marta', access: accessFor(role) } session.status = 'ready' calls = [] global.fetch = vi.fn((url: string, init?: RequestInit) => { const key = `${init?.method ?? 'GET'} ${url}` calls.push(key) const handler = handlers[key] if (!handler) { return Promise.reject(new TypeError(`Unexpected fetch call: ${key}`)) } return Promise.resolve(handler()) }) as unknown as typeof fetch await router.push('/home') const mounted = mount(HomeView, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) wrapper = mounted await flushPromises() return mounted } function card() { return wrapper!.find('[data-testid="recent-activity-card"]') } function rows() { return wrapper!.findAll('[data-testid="recent-activity-row"]') } afterEach(async () => { wrapper?.unmount() wrapper = null vi.useRealTimers() localStorage.clear() initializeLocale() await router.push('/login') }) describe('Home, owner or admin, the last five events with the actor name', () => { it.each(['owner', 'admin'] as const)('shows five rows with names for %s', async (role) => { await mountHome(role, { [ACTIVITY_ROUTE]: () => page(recentItems(5)), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) expect(card().find('h2').text()).toBe('Recent activity') const viewAll = card().find('a[href="/activity"]') expect(viewAll.text()).toBe('View all') expect(rows()).toHaveLength(5) const first = rows()[0]! expect(first.find('[data-testid="activity-sentence"]').text()).toBe( 'Published the article Spring release', ) expect(first.find('[data-testid="activity-sentence"] .font-semibold').text()).toBe( 'Spring release', ) expect(first.find('[data-testid="recent-activity-meta"]').text()).toMatch( /^Marta Alves · .+/, ) expect(first.text()).toContain('MA') expect(calls.filter((call) => call === ACTIVITY_ROUTE)).toHaveLength(1) expect(calls.some((call) => call.includes('actor'))).toBe(false) expect(calls.some((call) => call.includes('summary') || call.includes('export'))).toBe( false, ) }) }) describe('Home, regular user, own events only and no actor name', () => { it.each([['editor' as const]])('shows only the time for %s', async (role) => { await mountHome(role as undefined, { [ACTIVITY_ROUTE]: () => page(recentItems(3)), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) expect(card().find('h2').text()).toBe('Your recent activity') expect(rows()).toHaveLength(3) const meta = rows()[0]!.find('[data-testid="recent-activity-meta"]').text() expect(meta).not.toContain('Marta') expect(meta).not.toContain('·') }) }) describe('Home rows, failure and security markers', () => { it('shows the badge and the shield as the outcome and security dictate', async () => { const items = [ makeItem({ id: 1 }), makeItem({ id: 2, outcome: 'failed' }), makeItem({ id: 3, security: true }), makeItem({ id: 4, outcome: 'failed', security: true }), ] await mountHome('owner', { [ACTIVITY_ROUTE]: () => page(items), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) const marks = rows().map((row) => ({ badge: row.text().includes('Failed'), shield: row.find('[data-testid="recent-activity-security"]').exists(), })) expect(marks).toEqual([ { badge: false, shield: false }, { badge: true, shield: false }, { badge: false, shield: true }, { badge: true, shield: false }, ]) }) }) describe('Home rows, unknown action, failed login and not interactive', () => { it('renders an unknown action and a failed login without throwing', async () => { const items = [ makeItem({ id: 1, action: 'article.archive', target: null }), makeItem({ id: 2, action: 'auth.login_failed', category: 'auth', actor: anonymous('alex@example.com'), target: null, outcome: 'failed', }), ] await mountHome('owner', { [ACTIVITY_ROUTE]: () => page(items), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) expect(rows()[0]!.text()).toContain('article.archive') expect(rows()[1]!.text()).toContain('Failed login attempt for alex@example.com') expect(rows()[1]!.text()).toContain('Failed') }) it('has no button or link inside a row and keeps the route on a click', async () => { await mountHome('owner', { [ACTIVITY_ROUTE]: () => page(recentItems(2)), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) const row = rows()[0]! await row.trigger('click') expect(row.find('a, button').exists()).toBe(false) expect(router.currentRoute.value.path).toBe('/home') }) it('truncates a long sentence on one line', async () => { const items = [ makeItem({ id: 1, target: target('article', '8', 'T'.repeat(150)), }), ] await mountHome('owner', { [ACTIVITY_ROUTE]: () => page(items), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) expect(rows()[0]!.find('[data-testid="recent-activity-sentence"]').classes()).toContain( 'truncate', ) expect(rows()[0]!.find('[data-testid="recent-activity-meta"]').classes()).toContain( 'truncate', ) }) }) describe('Home card, skeleton rows only after 150 ms', () => { it('draws nothing, then five skeletons, then the rows', async () => { vi.useFakeTimers() let resolve: (() => void) | null = null await mountHome('owner', { [ACTIVITY_ROUTE]: () => new Promise((done) => { resolve = () => done(page(recentItems(5))) }), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) expect(card().attributes('aria-busy')).toBeUndefined() expect(wrapper!.findAll('[data-testid="recent-activity-skeleton"]')).toHaveLength(0) await vi.advanceTimersByTimeAsync(150) expect(card().attributes('aria-busy')).toBe('true') expect(wrapper!.findAll('[data-testid="recent-activity-skeleton"]')).toHaveLength(5) resolve?.() await vi.advanceTimersByTimeAsync(0) await flushPromises() expect(card().attributes('aria-busy')).toBeUndefined() expect(rows()).toHaveLength(5) }) it('never shows a skeleton for a fast response', async () => { vi.useFakeTimers() await mountHome('owner', { [ACTIVITY_ROUTE]: () => page(recentItems(5)), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) await vi.advanceTimersByTimeAsync(200) expect(wrapper!.findAll('[data-testid="recent-activity-skeleton"]')).toHaveLength(0) expect(rows()).toHaveLength(5) }) }) describe('Home card, error shows a retryable alert that leaves the page alone', () => { it.each([ ['500', () => response(500)], ['403', () => response(403)], ['network', () => Promise.reject(new TypeError('offline'))], ])('shows the alert on %s and recovers on Try again', async (_name, failure) => { let attempt = 0 await mountHome('owner', { [ACTIVITY_ROUTE]: () => { attempt += 1 return attempt === 1 ? (failure() as Response) : page(recentItems(2)) }, [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) const alert = card().find('[role="alert"]') expect(alert.text()).toContain("Couldn't load recent activity") expect(wrapper!.find('[data-testid="stat-tile"]').exists()).toBe(true) expect(wrapper!.text()).toContain('Connect your website') const retry = alert.findAll('button').find((button) => button.text() === 'Try again') await retry!.trigger('click') await flushPromises() expect(calls.filter((call) => call === ACTIVITY_ROUTE)).toHaveLength(2) expect(card().find('[role="alert"]').exists()).toBe(false) expect(rows()).toHaveLength(2) }) }) describe('Home card, empty when there are no events yet', () => { it.each([ ['owner' as const, 'Actions on your site will appear here.'], ['editor', 'Your actions will appear here.'], ])('says so for %s', async (role, text) => { await mountHome(role as 'owner', { [ACTIVITY_ROUTE]: () => page([], 0), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) const empty = card().find('[data-testid="recent-activity-empty"]') expect(empty.text()).toContain('No activity yet') expect(empty.text()).toContain(text) expect(card().find('a[href="/activity"]').text()).toBe('View all') }) }) describe('Home card and article list are independent', () => { it('shows the articles alert and the activity rows together', async () => { await mountHome('owner', { [ACTIVITY_ROUTE]: () => page(recentItems(5)), [ARTICLES_ROUTE]: () => response(500), }) expect(wrapper!.text()).toContain("Couldn't load your articles") expect(rows()).toHaveLength(5) }) it('shows the articles empty state and the card error together', async () => { await mountHome('owner', { [ACTIVITY_ROUTE]: () => response(500), [ARTICLES_ROUTE]: () => response(200, articleEnvelope([], { size: 5 })), }) expect(wrapper!.text()).toContain('Write your first article') expect(card().text()).toContain("Couldn't load recent activity") }) }) describe('Home layout puts Recent activity full width below Recently updated', () => { it('renders the section after the recent list inside the same single column', async () => { await mountHome('owner', { [ACTIVITY_ROUTE]: () => page(recentItems(1)), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) const column = card().element.parentElement! expect(column.className).not.toContain('grid-cols') const list = column.querySelector('[data-testid="recent-list"]')! expect( list.compareDocumentPosition(card().element) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy() }) }) describe('Home card, the request URL', () => { it('asks for exactly /v1/activity?size=5 and nothing else', async () => { await mountHome('owner', { [ACTIVITY_ROUTE]: () => page(recentItems(5)), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) const activityCalls = calls.filter((call) => call.includes('/v1/activity')) expect(activityCalls).toEqual(['GET /v1/activity?size=5']) }) }) describe('Home card, "View all" opens the activity page', () => { it('navigates to /activity', async () => { await mountHome('owner', { [ACTIVITY_ROUTE]: () => page(recentItems(1)), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) const push = vi.spyOn(router, 'push') await card().find('a[href="/activity"]').trigger('click') expect(push).toHaveBeenCalledWith('/activity') push.mockRestore() }) }) describe('Home card, the French interface', () => { it('titles and words the card in French', async () => { setLocale('fr') await mountHome('owner', { [ACTIVITY_ROUTE]: () => page([], 0), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) expect(card().find('h2').text()).toBe('Activité récente') expect(card().text()).toContain('Tout voir') expect(card().text()).toContain('Aucune activité pour le moment') }) }) describe('Home card, not shown without activity.view', () => { it('renders no recent activity card and makes no activity request', async () => { await mountHome(undefined, { [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })), }) expect(card().exists()).toBe(false) }) })