import { beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { ApiError, NetworkError, useApi } from '@/composables/useApi' import { useSessionStore } from '@/stores/session' import { DISMISSED_VERSION_KEY, useUpdateStore } from '@/stores/update' import { CHECK_PATH, DOWNLOAD_PATH, STATUS_PATH, downloading, newerAvailable, ready, startable, upToDate, } from '@/views/__tests__/updateTestKit' import { accessFor } from '@/test/access' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn(), } }) let requestMock: ReturnType function setup(role: 'owner' | 'admin', handlers: Record Promise>) { setActivePinia(createPinia()) const session = useSessionStore() session.session = { verified: true, displayName: '', access: accessFor(role) } session.status = 'ready' requestMock = vi.fn((path: string) => handlers[path]!()) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock }) return useUpdateStore() } beforeEach(() => { localStorage.clear() }) describe('load', () => { it('shares one request between concurrent callers', async () => { const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(upToDate()), }) await Promise.all([store.load(), store.load()]) expect(requestMock).toHaveBeenCalledTimes(1) expect(store.loadState).toBe('ready') expect(store.status?.latest).toBe('v2.2.0-beta') }) it('reports an error when nothing was ever loaded, and keeps the last status otherwise', async () => { const store = setup('owner', { [STATUS_PATH]: () => Promise.reject(new ApiError(500)), }) await store.load() expect(store.loadState).toBe('error') const kept = setup('owner', { [STATUS_PATH]: () => Promise.resolve(upToDate()), }) await kept.load() requestMock.mockImplementation(() => Promise.reject(new NetworkError())) await kept.load() expect(kept.loadState).toBe('ready') expect(kept.status).not.toBeNull() }) }) describe('check', () => { it('stores the result and returns to idle', async () => { const store = setup('owner', { [CHECK_PATH]: () => Promise.resolve(newerAvailable()), }) const result = await store.check() expect(result).toBe('updated') expect(store.phase).toBe('idle') expect(store.available).toBe(true) expect(requestMock).toHaveBeenCalledWith(CHECK_PATH, { method: 'POST' }) }) it('answers busy and sends nothing while a check is running', async () => { const store = setup('owner', { [CHECK_PATH]: () => new Promise(() => undefined), }) store.check() expect(await store.check()).toBe('busy') expect(requestMock).toHaveBeenCalledTimes(1) }) it.each([ { name: '429 with Retry-After', error: new ApiError(429, 42), result: 'limited', phase: 'limited', retry: 42, }, { name: '429 without Retry-After', error: new ApiError(429), result: 'limited', phase: 'limited', retry: 60, }, { name: '403', error: new ApiError(403), result: 'forbidden', phase: 'idle', retry: 0, }, { name: '500', error: new ApiError(500), result: 'failed', phase: 'failed', retry: 0, }, { name: 'a network failure', error: new NetworkError(), result: 'failed', phase: 'failed', retry: 0, }, ])('maps $name', async ({ error, result, phase, retry }) => { const store = setup('owner', { [CHECK_PATH]: () => Promise.reject(error), }) expect(await store.check()).toBe(result) expect(store.phase).toBe(phase) expect(store.retryAfter).toBe(retry) }) }) describe('banner dismissal', () => { it('hides the banner for the dismissed version only and keeps the cue', async () => { const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(newerAvailable()), }) await store.load() expect(store.bannerVisible).toBe(true) store.dismissBanner() expect(store.bannerVisible).toBe(false) expect(store.showCue).toBe(true) expect(localStorage.getItem(DISMISSED_VERSION_KEY)).toBe('v2.3.0-beta') }) it('reads the remembered version when created', async () => { localStorage.setItem(DISMISSED_VERSION_KEY, 'v2.3.0-beta') const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(newerAvailable()), }) await store.load() expect(store.bannerVisible).toBe(false) }) it('shows no cue for an admin', async () => { const store = setup('admin', { [STATUS_PATH]: () => Promise.resolve(newerAvailable()), }) await store.load() expect(store.showCue).toBe(false) expect(store.bannerVisible).toBe(false) }) }) describe('startDownload', () => { it('stores the 202 body and returns to idle', async () => { const store = setup('owner', { [DOWNLOAD_PATH]: () => Promise.resolve(downloading()), }) const result = await store.startDownload() expect(result).toBe('started') expect(store.applyPhase).toBe('idle') expect(store.status?.state).toBe('downloading') expect(store.jobRunning).toBe(true) expect(requestMock).toHaveBeenCalledWith(DOWNLOAD_PATH, { method: 'POST', }) }) it('answers busy and sends nothing while a start is in flight', async () => { const store = setup('owner', { [DOWNLOAD_PATH]: () => new Promise(() => undefined), }) store.startDownload() expect(await store.startDownload()).toBe('busy') expect(store.applyPhase).toBe('starting') expect(requestMock).toHaveBeenCalledTimes(1) }) it.each([ { name: '400', error: new ApiError(400), result: 'nothing', phase: 'idle', retry: 0, }, { name: '403', error: new ApiError(403), result: 'forbidden', phase: 'idle', retry: 0, }, { name: '409', error: new ApiError(409), result: 'conflict', phase: 'idle', retry: 0, }, { name: '422', error: new ApiError(422), result: 'untrusted', phase: 'untrusted', retry: 0, }, { name: '429 with Retry-After', error: new ApiError(429, 42), result: 'limited', phase: 'limited', retry: 42, }, { name: '429 without Retry-After', error: new ApiError(429), result: 'limited', phase: 'limited', retry: 60, }, { name: '500', error: new ApiError(500), result: 'failed', phase: 'failed', retry: 0, }, { name: 'a network failure', error: new NetworkError(), result: 'failed', phase: 'failed', retry: 0, }, ])('maps $name', async ({ error, result, phase, retry }) => { const store = setup('owner', { [DOWNLOAD_PATH]: () => Promise.reject(error), }) expect(await store.startDownload()).toBe(result) expect(store.applyPhase).toBe(phase) expect(store.retryAfter).toBe(retry) }) it('forgets a failed start when the status is loaded again', async () => { const store = setup('owner', { [DOWNLOAD_PATH]: () => Promise.reject(new ApiError(500)), [STATUS_PATH]: () => Promise.resolve(startable()), }) await store.startDownload() await store.load() expect(store.applyPhase).toBe('idle') }) }) describe('poll', () => { it('replaces the status and keeps it when a poll fails', async () => { const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(downloading(60)), }) await store.poll() expect(store.status?.progress?.percent).toBe(60) requestMock.mockImplementation(() => Promise.reject(new ApiError(500))) await store.poll() expect(store.status?.progress?.percent).toBe(60) expect(store.loadState).toBe('idle') }) it('skips a poll while another is in flight', async () => { const store = setup('owner', { [STATUS_PATH]: () => new Promise(() => undefined), }) store.poll() await store.poll() expect(requestMock).toHaveBeenCalledTimes(1) }) }) describe('job state', () => { it.each([ { name: 'downloading', status: downloading(), running: true, readyToInstall: false, }, { name: 'ready', status: ready(), running: false, readyToInstall: true, }, { name: 'idle', status: startable(), running: false, readyToInstall: false, }, { name: 'downloading without the helper', status: { ...downloading(), can_apply: false }, running: false, readyToInstall: false, }, ])('reads $name', async ({ status, running, readyToInstall }) => { const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(status), }) await store.load() expect(store.jobRunning).toBe(running) expect(store.readyToInstall).toBe(readyToInstall) }) it('shows no ready state to an admin', async () => { const store = setup('admin', { [STATUS_PATH]: () => Promise.resolve(ready()), }) await store.load() expect(store.readyToInstall).toBe(false) expect(store.jobRunning).toBe(false) }) })