361 lines
8.6 KiB
TypeScript
361 lines
8.6 KiB
TypeScript
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<typeof import('@/composables/useApi')>()
|
|
return {
|
|
...actual,
|
|
useApi: vi.fn(),
|
|
}
|
|
})
|
|
|
|
let requestMock: ReturnType<typeof vi.fn>
|
|
|
|
function setup(role: 'owner' | 'admin', handlers: Record<string, () => Promise<unknown>>) {
|
|
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)
|
|
})
|
|
})
|