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

479 lines
14 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, 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')
})
})