Files
RenewCMS/web/src/views/__tests__/ActivityView.spec.ts
T

694 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
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<HTMLAnchorElement>('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<HTMLInputElement>('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<HTMLElement>('[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<HTMLAnchorElement>(
'[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<HTMLElement>(
'[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<HTMLElement>('[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<HTMLElement>('[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<HTMLInputElement>('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")
})
})