feat: update button (download, verify, stage)

This commit is contained in:
fsylvain committed 2026-10-01 18:13:45 +02:00
1 parent 834493a4f5
commit 70460e4170
41 files changed
+5345 -97

No files matched your search

+137 -1
View File
@@ -3,7 +3,16 @@ 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, STATUS_PATH, newerAvailable, upToDate } from '@/views/__tests__/updateTestKit'
import {
CHECK_PATH,
DOWNLOAD_PATH,
STATUS_PATH,
downloading,
newerAvailable,
ready,
startable,
upToDate,
} from '@/views/__tests__/updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -136,3 +145,130 @@ describe('banner dismissal', () => {
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)
})
})