From e16aa63974d8805ece2af4e5244e8b158d4c2230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Mon, 31 Aug 2026 20:31:48 +0800 Subject: [PATCH] =?UTF-8?q?task-34:=20HTTP=204xx/5xx=20=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 个测试固化 HTTP 错误语义:4xx/5xx 记 onError 并进入退避、 不触发 onTerminal(5xx 不可解析明细时不误判 FAILED)、不清空已有 状态、恢复后继续轮询且部分 items 照常处理、未知状态不受影响。 行为由 http 层抛错与既有 catch 路径实现,本次为验证型测试。 --- .../tests/polling-http-error-class.test.ts | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 frontend-vue/tests/polling-http-error-class.test.ts diff --git a/frontend-vue/tests/polling-http-error-class.test.ts b/frontend-vue/tests/polling-http-error-class.test.ts new file mode 100644 index 00000000..c7f47986 --- /dev/null +++ b/frontend-vue/tests/polling-http-error-class.test.ts @@ -0,0 +1,141 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { useTaskProgressLoop } from '../src/shared/composables/useTaskProgressLoop.ts' + +function setupWindow() { + const storage = new Map() + ;(globalThis as Record).window = { + localStorage: { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { storage.set(k, v) }, + removeItem: (k: string) => { storage.delete(k) }, + }, + setTimeout: (fn: () => void, ms: number) => globalThis.setTimeout(fn, ms), + clearTimeout: (id: unknown) => globalThis.clearTimeout(id as number), + setInterval: (fn: () => void, ms: number) => globalThis.setInterval(fn, ms), + clearInterval: (id: unknown) => globalThis.clearInterval(id as number), + } +} + +let scopeCounter = 0 + +function httpError(status: number, data?: unknown): Error { + const err = new Error(`Request failed with status code ${status}`) as Error & { + isAxiosError?: boolean + response?: { status: number; data?: unknown } + } + err.isAxiosError = true + err.response = { status, data } + return err +} + +interface Harness { + loop: ReturnType> + terminalCalls: Array<{ taskId: number; status: string }> + errorCalls: unknown[] +} + +async function makeLoop(impl: () => Promise<{ items: unknown[] }>): Promise { + const terminalCalls: Array<{ taskId: number; status: string }> = [] + const errorCalls: unknown[] = [] + const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({ + scope: `http-err-${scopeCounter++}`, + fetchProgress: async () => impl(), + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + onTerminal: (taskId, _d, status) => { terminalCalls.push({ taskId, status }) }, + onError: (e) => { errorCalls.push(e) }, + }) + loop.reset([1]) + await new Promise((r) => globalThis.setTimeout(r, 0)) + return { loop, terminalCalls, errorCalls } +} + +test('test_4xx_on_error', async () => { + setupWindow() + const h = await makeLoop(async () => { throw httpError(404) }) + await h.loop.refreshOnce() + assert.equal(h.errorCalls.length, 2, '4xx 记 onError') + h.loop.dispose() +}) + +test('test_5xx_on_error', async () => { + setupWindow() + const h = await makeLoop(async () => { throw httpError(500) }) + await h.loop.refreshOnce() + assert.equal(h.errorCalls.length, 2, '5xx 记 onError') + h.loop.dispose() +}) + +test('test_5xx_no_terminal_failed', async () => { + setupWindow() + const h = await makeLoop(async () => { throw httpError(503, { message: 'upstream down' }) }) + await h.loop.refreshOnce() + assert.equal(h.terminalCalls.length, 0, '5xx 且无法解析明细时不误判 FAILED') + h.loop.dispose() +}) + +test('test_5xx_backoff', async () => { + setupWindow() + const h = await makeLoop(async () => { throw httpError(500) }) + const before = h.errorCalls.length + await new Promise((r) => globalThis.setTimeout(r, 50)) + assert.equal(h.errorCalls.length, before, '5xx 后不立即自动重试(退避)') + h.loop.dispose() +}) + +test('test_5xx_unknown_status_untouched', async () => { + setupWindow() + let fail = true + const h = await makeLoop(async () => { + if (fail) throw httpError(500) + return { items: [{ taskId: 1, status: 'WEIRD_STATE' }] } + }) + fail = false + await h.loop.refreshOnce() + assert.equal(h.loop.taskStatuses.value[1], 'WEIRD_STATE', '恢复后未知状态照常落地') + assert.equal(h.terminalCalls.length, 0, '未知状态不触发终态') + h.loop.dispose() +}) + +test('test_4xx_recovery', async () => { + setupWindow() + let fail = true + const h = await makeLoop(async () => { + if (fail) throw httpError(400) + return { items: [{ taskId: 1, status: 'RUNNING' }] } + }) + await h.loop.refreshOnce() + fail = false + await h.loop.refreshOnce() + assert.equal(h.loop.taskStatuses.value[1], 'RUNNING', '4xx 后恢复继续轮询') + h.loop.dispose() +}) + +test('test_status_500_partial_items', async () => { + setupWindow() + let fail = true + const h = await makeLoop(async () => { + if (fail) throw httpError(500) + return { items: [{ taskId: 1, status: 'SUCCESS' }, { taskId: 2, status: 'RUNNING' }] } + }) + fail = false + await h.loop.refreshOnce() + assert.equal(h.terminalCalls.length, 1, '5xx 恢复后部分 items 仍处理') + assert.equal(h.terminalCalls[0].taskId, 1) + assert.equal(h.loop.taskStatuses.value[2], 'RUNNING') + h.loop.dispose() +}) + +test('test_5xx_no_status_wipe', async () => { + setupWindow() + const h = await makeLoop(async () => { throw httpError(500) }) + // 先正常落地一个状态,再出现 5xx + const loop2 = h.loop + const original = loop2.taskStatuses + original.value = { 1: 'RUNNING' } + await loop2.refreshOnce() + assert.equal(loop2.taskStatuses.value[1], 'RUNNING', '5xx 不清空已有状态') + loop2.dispose() +})