feat(selfhost): start full updates from administration
This commit is contained in:
101
frontend/tests/selfhost-updates.test.cjs
Normal file
101
frontend/tests/selfhost-updates.test.cjs
Normal file
@@ -0,0 +1,101 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
|
||||
const check = { status: 'checked', command: 'check', requestId: 'checked-request', plan: {
|
||||
version: '3.2.0', revision: 'a'.repeat(40), updateAvailable: true, requiresComposeAdoption: false, retiredServices: [],
|
||||
} }
|
||||
const overview = (job = check, engine = { status: 'checked' }) => ({ acceptingRequests: true, engine, request: job, installed: {}, backend: { version: '3.1.0' } })
|
||||
async function setup(t, api, saved = null) {
|
||||
const mounted = [], unmounted = [], calls = [], storage = new Map(saved ? [['fedeo:update:admin', saved]] : [])
|
||||
let nextId = 0
|
||||
const context = vm.createContext({
|
||||
ref: vue.ref, computed: vue.computed, onMounted: fn => mounted.push(fn), onBeforeUnmount: fn => unmounted.push(fn),
|
||||
useAuthStore: () => ({ user: { id: 'admin' } }),
|
||||
useNuxtApp: () => ({ $api: async (path, options) => { calls.push({ path, options }); return api(path, options) } }),
|
||||
localStorage: { getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value) },
|
||||
crypto: { randomUUID: () => `request-id-${++nextId}` }, setTimeout: () => 1, clearTimeout() {},
|
||||
})
|
||||
const source = fs.readFileSync(__dirname + '/../composables/useSelfhostUpdates.ts', 'utf8').replace(/export /g, '')
|
||||
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }) + '\nglobalThis.ui = useSelfhostUpdates()', context)
|
||||
mounted.forEach(fn => fn()); await new Promise(setImmediate)
|
||||
t.after(() => unmounted.forEach(fn => fn()))
|
||||
return { ui: context.ui, calls, storage }
|
||||
}
|
||||
test('checked version enables one apply; duplicate click is blocked and polling survives restart', async t => {
|
||||
let state = overview(), offline = false, finish
|
||||
const f = await setup(t, async (path, options) => {
|
||||
if (offline) throw new Error('network')
|
||||
if (options.method === 'POST') return new Promise(resolve => { finish = resolve })
|
||||
return path.endsWith('/status') ? state : state.request
|
||||
})
|
||||
assert.equal(f.ui.canApply.value, true)
|
||||
const start = f.ui.apply(); await f.ui.apply()
|
||||
assert.equal(f.calls.filter(c => c.options.method === 'POST').length, 1)
|
||||
const body = f.calls.at(-1).options.body
|
||||
assert.equal(body.version, '3.2.0'); assert.equal(body.checkRequestId, 'checked-request')
|
||||
state = overview({ status: 'running', command: 'apply', requestId: body.requestId }, { status: 'running', recoveryRequired: true })
|
||||
finish(state.request); await start
|
||||
await f.ui.refresh(); assert.equal(f.ui.recovery.value, false)
|
||||
offline = true; await f.ui.refresh()
|
||||
assert.match(f.ui.message.value, /Host führt das Update weiter/)
|
||||
assert.equal(f.ui.canApply.value, false)
|
||||
offline = false; state = overview({ ...state.request, status: 'succeeded' }, { status: 'succeeded' })
|
||||
await f.ui.refresh(); assert.equal(f.ui.job.value.status, 'succeeded')
|
||||
})
|
||||
test('lost start response is recovered after reload without replaying an update', async t => {
|
||||
const first = await setup(t, async (path, options) => {
|
||||
if (options.method === 'POST') throw new Error('response lost')
|
||||
return path.endsWith('/status') ? overview() : check
|
||||
})
|
||||
await first.ui.apply()
|
||||
const id = first.ui.pending.value.body.requestId
|
||||
const saved = first.storage.get('fedeo:update:admin')
|
||||
const running = { status: 'running', command: 'apply', requestId: id }
|
||||
const second = await setup(t, async path => path.endsWith('/status') ? overview(running) : running, saved)
|
||||
assert.equal(second.ui.pending.value, null)
|
||||
assert.equal(second.ui.job.value.requestId, id)
|
||||
assert.equal(second.calls.some(c => c.options.method === 'POST'), false)
|
||||
})
|
||||
test('unconfirmed submission retains its original ID for explicit retry', async t => {
|
||||
const saved = JSON.stringify({ pending: { command: 'apply', body: { requestId: 'original-request', version: '3.2.0', checkRequestId: 'checked-request' } } })
|
||||
const f = await setup(t, async (path, options) => {
|
||||
if (options.method === 'POST') return { status: 'queued', command: 'apply', requestId: options.body.requestId }
|
||||
if (path.endsWith('/status')) return overview()
|
||||
throw { statusCode: 404 }
|
||||
}, saved)
|
||||
assert.equal(f.ui.canApply.value, false)
|
||||
await f.ui.sendPending()
|
||||
assert.equal(f.calls.at(-1).options.body.requestId, 'original-request')
|
||||
})
|
||||
test('recovery, up-to-date, unmanaged compose and other active jobs prevent applying', async t => {
|
||||
for (const state of [overview(check, { status: 'failed', recoveryRequired: true }),
|
||||
overview({ ...check, status: 'up_to_date' }),
|
||||
overview({ ...check, plan: { ...check.plan, requiresComposeAdoption: true } }),
|
||||
overview({ status: 'running', command: 'apply', requestId: 'someone-else' })]) {
|
||||
const f = await setup(t, async () => state)
|
||||
assert.equal(f.ui.canApply.value, false)
|
||||
await f.ui.apply(); assert.equal(f.calls.some(c => c.options.method === 'POST'), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('an older polling response cannot erase a new submission whose response was lost', async t => {
|
||||
let hold = false, release
|
||||
const f = await setup(t, async (path, options) => {
|
||||
if (options.method === 'POST') throw new Error('response lost')
|
||||
if (path.endsWith('/status')) return overview()
|
||||
if (hold) return new Promise(resolve => { release = resolve })
|
||||
return check
|
||||
})
|
||||
hold = true
|
||||
const polling = f.ui.refresh()
|
||||
await new Promise(setImmediate)
|
||||
await f.ui.apply()
|
||||
const id = f.ui.pending.value.body.requestId
|
||||
release(check); await polling
|
||||
assert.equal(f.ui.pending.value.body.requestId, id)
|
||||
assert.equal(f.ui.canApply.value, false)
|
||||
})
|
||||
Reference in New Issue
Block a user