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() 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 { 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 let getMeMock: ReturnType let wrapper: VueWrapper | null = null function deferred() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void const promise = new Promise((res, rej) => { resolve = res reject = rej }) return { promise, resolve, reject } } async function mountPage( role: 'owner' | 'admin' | 'editor' | undefined, load: () => Promise, patch: (body: unknown) => Promise = () => 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() 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() 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') }) })