feat: signed releases, release feed, notification (read-only)

This commit is contained in:
fsylvain committed 2026-10-01 15:01:53 +02:00
1 parent a49eec909c
commit 834493a4f5
49 files changed
+4191 -18

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
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, STATUS_PATH, newerAvailable, upToDate } from '@/views/__tests__/updateTestKit'
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: '', 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)
})
})