401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
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<Response>
|
|
|
|
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<ReturnType<typeof mountHome>> | null = null
|
|
let calls: string[] = []
|
|
|
|
async function mountHome(role: 'owner' | 'admin' | undefined, handlers: Record<string, Handler>) {
|
|
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)
|
|
})
|
|
})
|