This commit is contained in:
2026-10-19 00:03:48 +07:00
parent a58f0866d3
commit f74b2a8a41
8 changed files with 290 additions and 28 deletions
+90 -8
View File
@@ -48,6 +48,7 @@ async function fixture(t: TestContext, secure = true) {
upload: null as { filename: string; data: Buffer } | null,
bindings: [] as Record<string, unknown>[],
unbindError: 0,
unbindResponse: null as ((signal?: AbortSignal) => Promise<Response>) | null,
createScreenError: 0,
offline: false,
server: 'https://server.test',
@@ -62,6 +63,7 @@ async function fixture(t: TestContext, secure = true) {
accessExpired: false,
refreshed: false,
refreshCalls: 0,
refreshResponse: null as (() => Promise<Response>) | null,
advanceCalls: 0,
resultError: false,
title: 'Дело',
@@ -110,6 +112,7 @@ async function fixture(t: TestContext, secure = true) {
return json({ accessToken: 'ACCESS-SECRET', refreshToken: 'REFRESH-SECRET' })
if (path === '/api/auth/refresh') {
model.refreshCalls++
if (model.refreshResponse) return model.refreshResponse()
await new Promise((resolve) => setTimeout(resolve, 5))
if (model.refreshExpired) return json({}, 401)
model.refreshed = true
@@ -121,6 +124,7 @@ async function fixture(t: TestContext, secure = true) {
: json({ screenID: '12', accessToken: 'SCREEN-SECRET' })
if (path.endsWith('/unbind')) {
assert.equal(auth, 'Bearer ACCESS-SECRET')
if (model.unbindResponse) return model.unbindResponse(init?.signal ?? undefined)
return json({}, model.unbindError || 200)
}
if (path === '/api/screens/state') {
@@ -607,6 +611,44 @@ for (const gameId of [7, 8]) {
})
}
test('отмена подготовки не ждёт общий refresh и не отменяет обновление для другого запроса', async (t) => {
const f = await fixture(t)
await f.prepare()
f.model.accessExpired = true
let started!: () => void
let release!: () => void
const refreshStarted = new Promise<void>((resolve) => { started = resolve })
const refreshReleased = new Promise<void>((resolve) => { release = resolve })
f.model.refreshResponse = async () => {
started()
await refreshReleased
f.model.refreshed = true
return Response.json({ accessToken: 'ACCESS-ROTATED', refreshToken: 'REFRESH-ROTATED' })
}
const cancelled = f.online.act({ action: 'check', gameId: 7 }).then(
() => 'completed',
(error: Error) => { assert.match(error.message, /отмен|ABORTED/); return 'cancelled' },
)
await refreshStarted
const background = f.online.refreshPlan()
await new Promise<void>((resolve) => setImmediate(resolve))
await f.online.act({ action: 'cancel' })
try {
assert.equal(await Promise.race([
cancelled,
new Promise<string>((resolve) => { const timer = setTimeout(() => resolve('still waiting'), 1000); timer.unref() }),
]), 'cancelled')
assert.equal(f.model.refreshed, false)
} finally {
release()
await background
await cancelled
}
assert.equal(f.model.refreshCalls, 1)
assert.equal(f.online.snapshot().authenticated, true)
assert.equal(f.online.planStatus().notice, '')
})
for (const status of [200, 404]) {
test(`отключение отвязывает экран, сохраняет игру и переживает перезапуск: ${status}`, async (t) => {
const f = await fixture(t)
@@ -634,23 +676,63 @@ for (const status of [200, 404]) {
}
for (const failure of ['server', 'network'] as const) {
test(`ошибка отключения сохраняет сессию для повтора: ${failure}`, async (t) => {
test(`ошибка отключения прекращает локальную сессию и не восстанавливает её: ${failure}`, async (t) => {
const f = await fixture(t)
await f.prepare()
const saved = await readFile(join(f.directory, 'connection', 'credentials.bin'))
f.model.unbindError = failure === 'server' ? 500 : 0
f.model.offline = failure === 'network'
await assert.rejects(f.online.act({ action: 'disconnect' }))
assert.equal(f.online.snapshot().screenId, '12')
assert.equal(f.online.snapshot().connection, 'waiting')
assert.deepEqual(await readFile(join(f.directory, 'connection', 'credentials.bin')), saved)
f.model.unbindError = 0
f.model.offline = false
await f.online.act({ action: 'disconnect' })
assert.equal(f.online.snapshot().screenId, null)
assert.equal(f.online.snapshot().connection, 'disabled')
assert.match(f.online.snapshot().notice, /отключён локально/)
const saved = JSON.parse(f.secrets.decrypt(await readFile(join(f.directory, 'connection', 'credentials.bin'))))
assert.equal(saved.screen, null)
f.model.unbindError = 0
f.model.offline = false
const count = f.requests.length
await f.online.poll()
assert.equal(f.requests.length, count)
assert.equal(f.online.snapshot().screenId, null)
const restored = new Online(join(f.directory, 'connection'), f.store, new Controller(() => {}), f.secrets, f.fetchAPI)
t.after(() => restored.close())
await restored.initialize()
assert.equal(restored.snapshot().screenId, null)
})
}
test('локальное отключение сохраняется до ответа сервера, отвязка ограничена пятью секундами', async (t) => {
const f = await fixture(t)
await f.prepare()
const timeout = AbortSignal.timeout
let bounded = false
t.mock.method(AbortSignal, 'timeout', (milliseconds: number) => {
if (milliseconds === 5000) bounded = true
return timeout(milliseconds === 5000 ? 20 : milliseconds)
})
let started!: () => void
const requested = new Promise<void>((resolve) => { started = resolve })
f.model.unbindResponse = async (signal) => {
started()
return new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true })
})
}
const disconnect = f.online.act({ action: 'disconnect' })
await requested
assert.equal(f.online.snapshot().screenId, null)
const saved = JSON.parse(f.secrets.decrypt(await readFile(join(f.directory, 'connection', 'credentials.bin'))))
assert.equal(saved.screen, null)
// AbortSignal timers are unref'ed; keep the test alive until the simulated timeout.
const keepAlive = setTimeout(() => {}, 1000)
try {
await disconnect
} finally {
clearTimeout(keepAlive)
}
assert.equal(bounded, true)
assert.match(f.online.snapshot().notice, /не подтвердил отвязку/)
})
test('ошибка отвязки сохраняет прежнюю сессию и не создаёт новую', async (t) => {
const f = await fixture(t)
await f.prepare()