694 lines
22 KiB
TypeScript
694 lines
22 KiB
TypeScript
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")
|
||
})
|
||
})
|