import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' import { flushPromises } from '@vue/test-utils' import router from '@/router' import { ApiError, NetworkError, useApi } from '@/composables/useApi' import { initializeLocale } from '@/i18n' import type { ActivityItem } from '@/lib/activity' import { LIST_PATH, SUMMARY_PATH, callsTo, chipTexts, clickButton, days, findButton, expectIcon, mountActivity, queryObject, rows, stripCards, textOf, unmountActivity, } from './activityHarness' import { activityItems, anonymous, apiKey, makeItem, pageOf, summaryDefault, summaryNone, summaryZero, system, target, user, } from './activityFixtures' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) let toastAdd: ReturnType vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useToast: () => ({ add: toastAdd }) } }) beforeEach(() => { toastAdd = vi.fn() localStorage.clear() initializeLocale() }) afterEach(async () => { vi.useRealTimers() unmountActivity() vi.mocked(useApi).mockReset() await router.push('/login') }) function pageText() { return textOf(document.body) } function stripTexts() { return stripCards().map(textOf) } describe('Navigation', () => { it('lists Activity between Users and Integration with the history icon and the current page', async () => { await mountActivity() const links = Array.from( document.body.querySelectorAll('nav[aria-label="Main"] a'), ) expect(links.map((link) => link.getAttribute('href'))).toEqual([ '/home', '/article', '/media', '/authors', '/users', '/schema', '/activity', '/integration', '/settings', ]) const activity = links[6]! expect(activity.getAttribute('aria-current')).toBe('page') await expectIcon(activity, 'history') expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe('Activity') expect(router.currentRoute.value.meta).toEqual({ auth: 'verified', title: 'Activity', }) }) }) describe('Default: an owner sees the strip, the filter bar and a day-grouped table', () => { it('draws the header, the strip, the filter bar, the rows and the pager', async () => { await mountActivity() expect(textOf(document.body.querySelector('h1'))).toBe('Activity') expect(pageText()).toContain('Who did what, and when, across your site.') expect(findButton('Export')?.disabled).toBe(false) expect(stripTexts()).toEqual([ expect.stringContaining('Total actions'), expect.stringContaining('Active people'), expect.stringContaining('Failed actions'), expect.stringContaining('Security events'), ]) expect(stripTexts()[0]).toContain('1,284') expect(stripTexts()[0]).toContain('12% vs previous 7 days') expect(stripTexts()[1]).toContain('9') expect(stripTexts()[1]).toContain('7 people, 2 API keys') expect(stripTexts()[2]).toContain('0.5% of all actions') expect(stripTexts()[3]).toContain('11 flagged') await expectIcon(stripCards()[0], 'trending-up') await expectIcon(stripCards()[2], 'circle-x') await expectIcon(stripCards()[3], 'shield-alert') const search = document.body.querySelector('input[type="text"]')! expect(search.placeholder).toBe('Search activity') expect(pageText()).toContain('All people') expect(pageText()).toContain('All categories') const outcome = document.body.querySelector('[role="group"][aria-label="Outcome"]')! expect(outcome.querySelector('[aria-pressed="true"]')?.textContent?.trim()).toBe('All') const dates = document.body.querySelector('[role="group"][aria-label="Dates"]')! expect(dates.querySelector('[aria-pressed="true"]')?.textContent?.trim()).toBe('7 days') expect(chipTexts()).toEqual([]) expect(rows()).toHaveLength(activityItems.length) expect(days()).toEqual(['3 Oct 2026', '2 Oct 2026']) expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain( 'Showing 1–25 of 1,284', ) expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain( 'Rows per page', ) }) it('requests the list and the summary once each with the default query', async () => { await mountActivity() expect(callsTo(LIST_PATH)).toHaveLength(1) expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({ range: '7d', page: '1', size: '25', }) expect(callsTo(SUMMARY_PATH)).toHaveLength(1) expect(queryObject(callsTo(SUMMARY_PATH)[0])).toEqual({ range: '7d' }) }) it('gives every When cell the full local time with seconds as its title', async () => { await mountActivity() const cells = Array.from( document.body.querySelectorAll('[data-testid="activity-when"]'), ) expect(cells).toHaveLength(activityItems.length) for (const cell of cells) { expect(cell.textContent?.trim()).toMatch(/^\d{2}:\d{2}$/) expect(cell.title).toMatch(/^\d{1,2} \w{3} 2026, \d{2}:\d{2}:\d{2}$/) } }) }) describe('Rows: the Actor cell and the sentence follow the event', () => { const cases: { name: string item: ActivityItem actor: string sentence: string badge: string shield: boolean }[] = [ { name: 'user, success', item: makeItem({ id: 1 }), actor: 'Marta Alves', sentence: 'Published the article Spring release', badge: 'Succeeded', shield: false, }, { name: 'api key', item: makeItem({ id: 2, action: 'media.upload', category: 'media', actor: apiKey(7, 'Website sync'), target: target('image', '4', 'north-wall.jpg'), }), actor: 'Website sync API key', sentence: 'Uploaded the image north-wall.jpg', badge: 'Succeeded', shield: false, }, { name: 'system', item: makeItem({ id: 3, action: 'system.media_purge', category: 'system', actor: system, target: null, params: { merged: 3, articles_updated: 5 }, }), actor: 'System System', sentence: 'Ran the scheduled duplicate purge: 3 merged, 5 articles updated', badge: 'Succeeded', shield: false, }, { name: 'anonymous failed login', item: makeItem({ id: 4, action: 'auth.login_failed', category: 'auth', actor: anonymous('alex@example.com'), target: null, outcome: 'failed', security: true, }), actor: 'Not signed in', sentence: 'Failed login attempt for alex@example.com', badge: 'Failed', shield: true, }, { name: 'failed publish', item: makeItem({ id: 5, actor: user(4, 'Inès Duarte'), outcome: 'failed', }), actor: 'Inès Duarte', sentence: 'Failed to publish the article Spring release', badge: 'Failed', shield: false, }, { name: 'security revoke', item: makeItem({ id: 6, action: 'api_key.revoke', category: 'api_keys', actor: user(2, 'Jonas Weber'), target: target('api_key', '71', 'Mobile app'), security: true, }), actor: 'Jonas Weber', sentence: 'Revoked API key Mobile app', badge: 'Succeeded', shield: true, }, ] it.each(cases)('$name', async ({ item, actor, sentence, badge, shield }) => { await mountActivity({ list: () => pageOf([item]) }) const row = rows()[0]! const cells = row.querySelectorAll('[role="cell"]') expect(textOf(cells[1])).toContain(actor.split(' ')[0]) if (actor === 'Not signed in') { expect(textOf(cells[1])).toBe('Not signed in') } expect(textOf(row.querySelector('[data-testid="activity-sentence"]'))).toBe(sentence) const strong = Array.from( row.querySelectorAll('[data-testid="activity-sentence"] .font-semibold'), ).map(textOf) const name = item.action === 'auth.login_failed' ? item.actor.name : item.target?.name expect(strong).toEqual(name ? [name] : []) expect(textOf(cells[cells.length - 1])).toBe(badge) expect(!!row.querySelector('[aria-label="Security"]')).toBe(shield) expect(row.className.includes('shadow-flag')).toBe(shield) }) it('shows the actor initials, the key and the system badge', async () => { await mountActivity({ list: () => pageOf(activityItems) }) const actorCells = Array.from( document.body.querySelectorAll('[data-testid="activity-actor"]'), ) expect(textOf(actorCells[0])).toBe('MAMarta Alves') const keyCell = actorCells.find((cell) => textOf(cell).includes('Website sync'))! expect(textOf(keyCell)).toContain('API key') await expectIcon(keyCell, 'key-round') const systemCell = actorCells.find((cell) => textOf(cell).startsWith('System'))! expect(textOf(systemCell)).toBe('SystemSystem') await expectIcon(systemCell, 'cog') const anonymousCell = actorCells.find((cell) => textOf(cell) === 'Not signed in')! await expectIcon(anonymousCell, 'user-round-x') }) }) describe('Rows: targets', () => { it('strikes a deleted target through with a Deleted badge and links a living one', async () => { await mountActivity({ list: () => pageOf([ makeItem({ id: 1, action: 'media.delete', category: 'media', target: target('image', '9', 'old-banner.png', false), }), makeItem({ id: 2 }), ]), }) const targets = Array.from( document.body.querySelectorAll('[data-testid="activity-target"]'), ) expect(targets[0]!.querySelector('.line-through')?.textContent).toBe('old-banner.png') expect(textOf(targets[0])).toContain('Deleted') expect(textOf(targets[0])).toContain('Image') const link = targets[1]!.querySelector('a')! expect(link.getAttribute('href')).toBe('/article/8/edit') expect(textOf(targets[1])).toContain('Article') }) it('navigates from the target link without opening the details', async () => { await mountActivity() const link = document.body.querySelector( '[data-testid="activity-row"] a[href="/article/8/edit"]', )! link.click() await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article/8/edit')) expect(document.body.querySelector('[data-testid="activity-details"]')).toBeNull() }) it.each([ ['article', '8', '/article/8/edit'], ['image', '4', '/media'], ['user', '3', '/users'], ['invite', '2', '/users'], ['api_key', '71', '/integration/api-keys'], ])('links a %s target to %s', async (type, id, href) => { await mountActivity({ list: () => pageOf([makeItem({ id: 1, target: target(type, id, 'Some name') })]), }) expect( document.body.querySelector('[data-testid="activity-target"] a')?.getAttribute('href'), ).toBe(href) }) it('shows a target type with no page as plain text', async () => { await mountActivity({ list: () => pageOf([ makeItem({ id: 1, action: 'system.update_downloaded', category: 'system', actor: system, target: target('release', 'v1.4.0', 'v1.4.0'), }), ]), }) const cell = document.body.querySelector('[data-testid="activity-target"]')! expect(cell.querySelector('a')).toBeNull() expect(textOf(cell)).toBe('v1.4.0Release') }) it('shows a muted dash when there is no target', async () => { await mountActivity({ list: () => pageOf([makeItem({ id: 1, target: null, action: 'auth.login' })]), }) const cell = document.body.querySelector( '[data-testid="activity-row"] [role="cell"]:nth-child(4)', )! expect(textOf(cell)).toBe('—') expect(cell.querySelector('.text-muted')).toBeTruthy() }) it('renders an unknown action code as it is and still opens the details', async () => { await mountActivity({ list: () => pageOf([ makeItem({ id: 1, action: 'article.archive', target: null, }), ]), }) const row = rows()[0]! expect(textOf(row.querySelector('[data-testid="activity-sentence"]'))).toBe( 'article.archive', ) row.click() await flushPromises() expect(document.body.querySelector('[data-testid="activity-details"]')).toBeTruthy() }) }) describe('Day grouping', () => { it('groups by local day in API order under separators', async () => { await mountActivity({ list: () => pageOf([ makeItem({ id: 1, created_at: '2026-10-03T12:30:00Z' }), makeItem({ id: 2, created_at: '2026-10-03T12:10:00Z' }), makeItem({ id: 3, created_at: '2026-10-02T12:40:00Z' }), ]), }) expect(days()).toEqual(['3 Oct 2026', '2 Oct 2026']) const order = Array.from( document.body.querySelectorAll( '[data-testid="activity-day"], [data-testid="activity-row"]', ), ).map((element) => element.getAttribute('data-activity-id') ?? textOf(element)) expect(order).toEqual(['3 Oct 2026', '1', '2', '2 Oct 2026', '3']) }) }) describe('Loading: skeletons after 150 ms', () => { it('draws nothing in place for 150 ms, then 4 skeleton cards and 9 skeleton rows', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const never = new Promise(() => undefined) await mountActivity({ list: () => never, summary: () => never }) expect(document.body.querySelector('h1')?.textContent).toBe('Activity') expect(findButton('Export')?.disabled).toBe(false) expect( document.body .querySelector('[data-testid="activity-filter-bar"] input') ?.getAttribute('aria-label'), ).toBe('Search activity') expect(document.body.querySelector('[data-testid="activity-strip-skeleton"]')).toBeNull() expect(document.body.querySelector('[data-testid="activity-skeleton-row"]')).toBeNull() vi.advanceTimersByTime(149) await flushPromises() expect(document.body.querySelector('[data-testid="activity-skeleton-row"]')).toBeNull() vi.advanceTimersByTime(1) await flushPromises() expect( document.body.querySelectorAll('[data-testid="activity-strip-skeleton"]'), ).toHaveLength(4) expect( document.body.querySelectorAll('[data-testid="activity-skeleton-row"]'), ).toHaveLength(9) expect(document.body.querySelector('[role="table"]')?.getAttribute('aria-busy')).toBe( 'true', ) expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull() }) it('draws 5 skeleton cards below md and replaces them with the data on resolve', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) let resolveList: (value: unknown) => void = () => undefined const pending = new Promise((resolve) => (resolveList = resolve)) await mountActivity({ desktop: false, list: () => pending }) vi.advanceTimersByTime(150) await flushPromises() expect( document.body.querySelectorAll('[data-testid="activity-skeleton-card"]'), ).toHaveLength(5) resolveList(pageOf(activityItems)) await flushPromises() expect( document.body.querySelectorAll('[data-testid="activity-skeleton-card"]'), ).toHaveLength(0) expect(rows()).toHaveLength(activityItems.length) }) }) describe('Strip', () => { it('shows a calm 0 for a quiet period', async () => { await mountActivity({ summary: () => summaryZero }) const texts = stripTexts() expect(texts[0]).toContain('214') expect(texts[0]).toContain('4% vs previous 7 days') await expectIcon(stripCards()[0], 'trending-down') expect(texts[1]).toContain('5') expect(texts[1]).toContain('4 people, 1 API key') expect(texts[2]).toContain('Nothing failed') expect(texts[3]).toContain('Nothing to flag') await expectIcon(stripCards()[2], 'circle-check') await expectIcon(stripCards()[3], 'circle-check') expect(stripCards()[3]!.className).not.toContain('rc-warning-soft') expect(stripCards()[2]!.className).not.toContain('rc-error-soft') const bars = stripCards()[2]!.querySelectorAll( '[data-testid="activity-bars"] span', ) expect(Array.from(bars).map((bar) => bar.style.height)).toEqual(Array(7).fill('2px')) }) it.each([ [40, 0, 'No earlier data'], [40, 40, 'No change'], ])('reads the Total sub-line for %i against %i as %s', async (total, previous, subline) => { await mountActivity({ summary: () => ({ ...summaryDefault, total, previous_total: previous, }), }) expect(stripTexts()[0]).toContain(subline) await expectIcon(stripCards()[0], 'trending-up', false) await expectIcon(stripCards()[0], 'trending-down', false) }) it('sizes the bars from the series on desktop and mobile', async () => { await mountActivity() const heights = Array.from( stripCards()[0]!.querySelectorAll('[data-testid="activity-bars"] span'), ).map((bar) => bar.style.height) expect(heights).toEqual(['9px', '13px', '11px', '18px', '16px', '20px', '13px']) unmountActivity() await mountActivity({ desktop: false }) const mobile = Array.from( stripCards()[0]!.querySelectorAll('[data-testid="activity-bars"] span'), ).map((bar) => bar.style.height) expect(mobile).toEqual(['7px', '11px', '9px', '14px', '12px', '16px', '11px']) }) it('is not drawn when the summary fails, and the list still works', async () => { for (const failure of [new ApiError(500), new ApiError(403), new NetworkError()]) { await mountActivity({ summary: () => { throw failure }, }) expect(stripCards()).toHaveLength(0) expect(document.body.querySelector('[data-testid="activity-strip"]')).toBeNull() expect(document.body.querySelector('[role="alert"]')).toBeNull() expect(rows()).toHaveLength(activityItems.length) expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain( 'Showing 1–25 of 1,284', ) unmountActivity() } }) }) describe('Empty states', () => { it('shows No activity yet with Export disabled and no table or pager', async () => { await mountActivity({ list: () => pageOf([], { total: 0 }), summary: () => summaryNone, }) expect(pageText()).toContain('No activity yet') expect(pageText()).toContain( 'Actions on articles, media, users and API keys will appear here.', ) await expectIcon(document.body.querySelector('[data-slot="root"]'), 'history') expect(findButton('Export')?.disabled).toBe(true) expect(document.body.querySelector('[role="table"]')).toBeNull() expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull() const texts = stripTexts() expect(texts[0]).toContain('No data yet') expect(texts[1]).toContain('No data yet') expect(texts[2]).toContain('Nothing failed') expect(texts[3]).toContain('Nothing to flag') }) it('offers Clear filters when filters match nothing and resets them', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) let empty = false await mountActivity({ list: () => (empty ? pageOf([], { total: 0 }) : pageOf(activityItems)), }) empty = true const search = document.body.querySelector('input[type="text"]')! search.value = 'sitemap' search.dispatchEvent(new Event('input', { bubbles: true })) await nextTick() vi.advanceTimersByTime(300) await flushPromises() expect(chipTexts()).toEqual(['Search: sitemap']) expect(pageText()).toContain('No activity matches these filters') expect(pageText()).toContain('Try a different search or a wider date range.') expect(findButton('Export')?.disabled).toBe(false) expect(callsTo(SUMMARY_PATH)).toHaveLength(1) empty = false await clickButton('Clear filters') expect(queryObject(callsTo(LIST_PATH).at(-1))).toEqual({ range: '7d', page: '1', size: '25', }) expect(rows()).toHaveLength(activityItems.length) expect(chipTexts()).toEqual([]) }) }) describe('Error with retry', () => { it.each([ ['a 500 response', () => new ApiError(500)], ['a 400 response', () => new ApiError(400)], ['a network failure', () => new NetworkError()], ])('replaces the table with an alert after %s and retries', async (_, failure) => { let failing = true await mountActivity({ list: () => { if (failing) throw failure() return pageOf(activityItems) }, }) expect(stripCards()).toHaveLength(4) expect( document.body .querySelector('[data-testid="activity-filter-bar"] input') ?.getAttribute('aria-label'), ).toBe('Search activity') const alert = document.body.querySelector('[role="alert"]')! expect(textOf(alert)).toContain("Couldn't load activity") expect(textOf(alert)).toContain('Check your connection and try again.') expect(findButton('Export')?.disabled).toBe(true) expect(document.body.querySelector('[role="table"]')).toBeNull() expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull() failing = false await clickButton('Try again') expect(callsTo(LIST_PATH)).toHaveLength(2) expect(queryObject(callsTo(LIST_PATH)[1])).toEqual(queryObject(callsTo(LIST_PATH)[0])) expect(rows()).toHaveLength(activityItems.length) expect(document.body.querySelector('[role="alert"]')).toBeNull() }) }) describe('Forbidden (403)', () => { it('shows only the header and the lock panel, and goes home', async () => { await mountActivity({ list: () => { throw new ApiError(403) }, }) expect(textOf(document.body.querySelector('h1'))).toBe('Activity') expect(findButton('Export')).toBeUndefined() expect(stripCards()).toHaveLength(0) expect(document.body.querySelector('[data-testid="activity-filter-bar"]')).toBeNull() expect(document.body.querySelector('[data-testid="activity-chips"]')).toBeNull() expect(document.body.querySelector('[role="table"]')).toBeNull() expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull() await expectIcon(document.body.querySelector('main, #dashboard-content'), 'lock') expect(pageText()).toContain("You can't view this activity") expect(pageText()).toContain( "Your role doesn't include this page. Ask an owner or admin if you need access.", ) await clickButton('Back to home') await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home')) }) it('shows no panel for a 401, which the API client handles globally', async () => { await mountActivity({ list: () => { throw new ApiError(401) }, }) expect(pageText()).not.toContain("You can't view this activity") expect(pageText()).not.toContain("Couldn't load activity") expect(document.body.querySelector('[role="table"]')).toBeNull() }) it('shows no panel for a 403 on an unverified session', async () => { await mountActivity({ verified: false, list: () => { throw new ApiError(403) }, }) expect(callsTo(LIST_PATH)).toHaveLength(0) expect(pageText()).not.toContain("You can't view this activity") }) })