feat: recent activity card on Home and Retention card on Settings
This commit is contained in:
1 parent
35db442cd3
commit
43469ef08d
33 files changed
+2748
-204
No files matched your search
@@ -0,0 +1,478 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import USelectMenu from '@nuxt/ui/components/SelectMenu.vue'
|
||||
import router from '@/router'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import type { WorkspaceSettings } from '@/composables/useSettings'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
import { DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH } from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return {
|
||||
...actual,
|
||||
useApi: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const SETTINGS_PATH = '/v1/settings/'
|
||||
const SAVED_VISIBLE_MS = 2000
|
||||
|
||||
function at(day: number, hour: number) {
|
||||
return new Date(2026, 9, day, hour, 0).toISOString()
|
||||
}
|
||||
|
||||
function settings(overrides: Partial<WorkspaceSettings> = {}): WorkspaceSettings {
|
||||
return {
|
||||
version: 'v2.2.0-beta',
|
||||
activity_retention_days: 90,
|
||||
activity_last_cleanup_at: at(6, 3),
|
||||
activity_last_cleanup_removed: 142,
|
||||
activity_next_cleanup_at: at(7, 3),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
let requestMock: ReturnType<typeof vi.fn>
|
||||
let getMeMock: ReturnType<typeof vi.fn>
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
async function mountPage(
|
||||
role: 'owner' | 'admin' | 'editor' | undefined,
|
||||
load: () => Promise<unknown>,
|
||||
patch: (body: unknown) => Promise<unknown> = () => Promise.resolve(settings()),
|
||||
) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, role }
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string, init?: RequestInit) => {
|
||||
if (path === SETTINGS_PATH && init?.method === 'PATCH') {
|
||||
return patch(JSON.parse(init.body as string))
|
||||
}
|
||||
if (path === SETTINGS_PATH) {
|
||||
return load()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
getMeMock = vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, role })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, request: requestMock })
|
||||
await router.push('/settings')
|
||||
wrapper = mount(SettingsView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function retention() {
|
||||
return document.body.querySelector('[data-testid="retention-card"]')
|
||||
}
|
||||
|
||||
function text() {
|
||||
return retention()?.textContent?.replace(/\s+/g, ' ') ?? ''
|
||||
}
|
||||
|
||||
function line(testId: string) {
|
||||
return document.body.querySelector(`[data-testid="${testId}"]`)?.textContent?.trim()
|
||||
}
|
||||
|
||||
function patches() {
|
||||
return requestMock.mock.calls.filter(([, init]) => init?.method === 'PATCH')
|
||||
}
|
||||
|
||||
function settingsGets() {
|
||||
return requestMock.mock.calls.filter(
|
||||
([path, init]) => path === SETTINGS_PATH && init?.method !== 'PATCH',
|
||||
)
|
||||
}
|
||||
|
||||
function button(label: string) {
|
||||
return Array.from(document.body.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
)
|
||||
}
|
||||
|
||||
async function choose(days: number) {
|
||||
wrapper!.findComponent(USelectMenu).vm.$emit('update:modelValue', days)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function selectedLabel() {
|
||||
const select = wrapper!.findComponent(USelectMenu)
|
||||
const value = select.props('modelValue')
|
||||
return (select.props('items') as { value: number; label: string }[]).find(
|
||||
(item) => item.value === value,
|
||||
)?.label
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Settings retention is hidden for everyone but the owner', () => {
|
||||
it.each([['admin' as const], ['editor' as const], [undefined]])(
|
||||
'renders nothing for %s',
|
||||
async (role) => {
|
||||
await mountPage(role, () => Promise.resolve(settings()))
|
||||
|
||||
expect(retention()).toBeNull()
|
||||
expect(document.body.textContent).not.toContain('Keep activity for')
|
||||
expect(document.body.textContent).toContain('Media cleanup')
|
||||
expect(settingsGets()).toHaveLength(1)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Settings retention, Default with 90 days', () => {
|
||||
it('shows the header, the select, the helper, the lock alert and the lines', async () => {
|
||||
await mountPage('owner', () => Promise.resolve(settings()))
|
||||
|
||||
expect(text()).toContain('Retention')
|
||||
expect(text()).toContain('How long activity is kept.')
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
expect(text()).toContain('Older events are removed automatically.')
|
||||
expect(text()).toContain('Activity logs are append-only')
|
||||
expect(line('retention-last')).toBe('6 Oct 2026, 03:00 · 142 events removed')
|
||||
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
|
||||
expect(text()).not.toContain('Saved')
|
||||
expect(patches()).toHaveLength(0)
|
||||
expect(settingsGets()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reads Never before the first cleanup', async () => {
|
||||
await mountPage('owner', () =>
|
||||
Promise.resolve(settings({ activity_last_cleanup_at: null })),
|
||||
)
|
||||
|
||||
expect(line('retention-last')).toBe('Never')
|
||||
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
|
||||
})
|
||||
|
||||
it('reads Never and Not scheduled for Forever when never cleaned', async () => {
|
||||
await mountPage('owner', () =>
|
||||
Promise.resolve(
|
||||
settings({
|
||||
activity_retention_days: 0,
|
||||
activity_last_cleanup_at: null,
|
||||
activity_next_cleanup_at: null,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(selectedLabel()).toBe('Forever')
|
||||
expect(line('retention-last')).toBe('Never')
|
||||
expect(line('retention-next')).toBe('Not scheduled')
|
||||
expect(text()).toContain('Nothing is removed automatically. The log grows over time.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, skeleton and load failure', () => {
|
||||
it('draws nothing in the body for 150 ms, then skeleton lines', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = deferred<unknown>()
|
||||
await mountPage('owner', () => pending.promise)
|
||||
|
||||
expect(retention()?.getAttribute('aria-busy')).toBeNull()
|
||||
expect(text()).not.toContain('Keep activity for')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(retention()?.getAttribute('aria-busy')).toBe('true')
|
||||
expect(text()).not.toContain('Keep activity for')
|
||||
})
|
||||
|
||||
it('shows the shared alert and re-runs the shared load', async () => {
|
||||
let attempt = 0
|
||||
await mountPage('owner', () => {
|
||||
attempt += 1
|
||||
return attempt === 1 ? Promise.reject(new ApiError(500)) : Promise.resolve(settings())
|
||||
})
|
||||
|
||||
expect(text()).toContain("Couldn't reach RenewCMS. Check your connection and try again.")
|
||||
|
||||
const inCard = Array.from(retention()!.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === 'Try again',
|
||||
)
|
||||
inCard!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(settingsGets()).toHaveLength(2)
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, a longer period saves at once', () => {
|
||||
it.each([
|
||||
[90, 365],
|
||||
[30, 90],
|
||||
[90, 0],
|
||||
])('saves %s to %s with one PATCH and shows Saved for 2 s', async (from, to) => {
|
||||
vi.useFakeTimers()
|
||||
const pending = deferred<unknown>()
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings({ activity_retention_days: from })),
|
||||
() => pending.promise,
|
||||
)
|
||||
|
||||
await choose(to)
|
||||
|
||||
expect(patches()).toHaveLength(1)
|
||||
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: to }))
|
||||
expect(text()).toContain('Saving…')
|
||||
expect(text()).not.toContain('Events older than')
|
||||
|
||||
pending.resolve(
|
||||
settings({
|
||||
activity_retention_days: to,
|
||||
activity_next_cleanup_at: to === 0 ? null : at(8, 3),
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(text()).toContain('Saved')
|
||||
expect(line('retention-next')).toBe(to === 0 ? 'Not scheduled' : '8 Oct 2026, 03:00')
|
||||
expect(line('retention-last')).toBe('6 Oct 2026, 03:00 · 142 events removed')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SAVED_VISIBLE_MS)
|
||||
|
||||
expect(text()).not.toContain('Saved')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, shortening asks first', () => {
|
||||
it('warns without sending, then applies', async () => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => Promise.resolve(settings({ activity_retention_days: 30 })),
|
||||
)
|
||||
|
||||
await choose(30)
|
||||
|
||||
expect(patches()).toHaveLength(0)
|
||||
expect(selectedLabel()).toBe('30 days')
|
||||
expect(text()).toContain('Events older than 30 days will be removed')
|
||||
expect(text()).toContain(
|
||||
"This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.",
|
||||
)
|
||||
|
||||
button('Apply 30 days')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toHaveLength(1)
|
||||
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: 30 }))
|
||||
expect(text()).not.toContain('Events older than')
|
||||
expect(text()).toContain('Saved')
|
||||
})
|
||||
|
||||
it('cancels back to the saved period without any request', async () => {
|
||||
await mountPage('owner', () => Promise.resolve(settings()))
|
||||
await choose(30)
|
||||
|
||||
button('Cancel')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(text()).not.toContain('Events older than')
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
expect(patches()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, 365, true],
|
||||
[365, 90, true],
|
||||
[90, 90, false],
|
||||
[30, 0, false],
|
||||
])('saved %s then chosen %s warns: %s', async (saved, chosen, warns) => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings({ activity_retention_days: saved })),
|
||||
() => Promise.resolve(settings({ activity_retention_days: chosen })),
|
||||
)
|
||||
|
||||
await choose(chosen)
|
||||
|
||||
expect(text().includes('Events older than')).toBe(warns)
|
||||
expect(patches()).toHaveLength(saved === chosen || warns ? 0 : 1)
|
||||
})
|
||||
|
||||
it('drops the warning when a longer period is chosen', async () => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => Promise.resolve(settings({ activity_retention_days: 365 })),
|
||||
)
|
||||
await choose(30)
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(text()).not.toContain('Events older than')
|
||||
expect(patches()).toHaveLength(1)
|
||||
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: 365 }))
|
||||
})
|
||||
|
||||
it('describes a shortening from Forever without a date', async () => {
|
||||
await mountPage('owner', () =>
|
||||
Promise.resolve(
|
||||
settings({ activity_retention_days: 0, activity_next_cleanup_at: null }),
|
||||
),
|
||||
)
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(text()).toContain('Events older than 365 days will be removed')
|
||||
expect(text()).toContain(
|
||||
"Older events will be removed at the next cleanup. It can't be undone.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, save error reverts the control and Try again retries', () => {
|
||||
it.each([
|
||||
['500', () => Promise.reject(new ApiError(500))],
|
||||
['400', () => Promise.reject(new ApiError(400))],
|
||||
['403', () => Promise.reject(new ApiError(403))],
|
||||
['network', () => Promise.reject(new NetworkError())],
|
||||
])('on %s', async (_name, failure) => {
|
||||
let attempt = 0
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => {
|
||||
attempt += 1
|
||||
return attempt === 1
|
||||
? failure()
|
||||
: Promise.resolve(settings({ activity_retention_days: 365 }))
|
||||
},
|
||||
)
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(text()).toContain("Couldn't save the retention period")
|
||||
expect(text()).toContain('Your previous setting is still in place.')
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
|
||||
expect(text()).not.toContain('Saved')
|
||||
|
||||
const retry = Array.from(retention()!.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === 'Try again',
|
||||
)
|
||||
retry!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toHaveLength(2)
|
||||
expect(patches()[1]![1].body).toBe(JSON.stringify({ activity_retention_days: 365 }))
|
||||
expect(text()).toContain('Saved')
|
||||
expect(text()).not.toContain("Couldn't save")
|
||||
})
|
||||
|
||||
it('retries an applied shortening without asking again', async () => {
|
||||
let attempt = 0
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => {
|
||||
attempt += 1
|
||||
return attempt === 1
|
||||
? Promise.reject(new ApiError(500))
|
||||
: Promise.resolve(settings({ activity_retention_days: 30 }))
|
||||
},
|
||||
)
|
||||
await choose(30)
|
||||
button('Apply 30 days')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(text()).not.toContain('Events older than')
|
||||
expect(text()).toContain("Couldn't save the retention period")
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
|
||||
Array.from(retention()!.querySelectorAll('button'))
|
||||
.find((candidate) => candidate.textContent?.trim() === 'Try again')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toHaveLength(2)
|
||||
expect(text()).not.toContain('Events older than')
|
||||
})
|
||||
|
||||
it('refreshes the session on a 403 and removes the card for a demoted owner', async () => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => Promise.reject(new ApiError(403)),
|
||||
)
|
||||
getMeMock.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: 'admin' })
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(getMeMock).toHaveBeenCalled()
|
||||
expect(retention()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no card alert on a 401', async () => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => Promise.reject(new ApiError(401)),
|
||||
)
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(text()).not.toContain("Couldn't save")
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, offline disables the controls', () => {
|
||||
it('disables the select and the warning buttons', async () => {
|
||||
await mountPage('owner', () => Promise.resolve(settings()))
|
||||
await choose(30)
|
||||
|
||||
const online = vi.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper!.findComponent(USelectMenu).props('disabled')).toBe(true)
|
||||
expect(button('Apply 30 days')!.disabled).toBe(true)
|
||||
expect(button('Cancel')!.disabled).toBe(true)
|
||||
online.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, the French interface', () => {
|
||||
it('words the card in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountPage('owner', () => Promise.resolve(settings()))
|
||||
|
||||
expect(text()).toContain('Conservation')
|
||||
expect(text()).toContain('Conserver l’activité pendant')
|
||||
expect(text()).toContain('Dernier nettoyage')
|
||||
expect(text()).toContain('Prochain nettoyage')
|
||||
expect(selectedLabel()).toBe('90 jours')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user