From e896e0bef4070da14b99a96e2cb222dc5ebe824b 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:05:53 +0800 Subject: [PATCH] =?UTF-8?q?task-26:=20=E6=A8=A1=E5=9D=97=20API=20=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=A5=91=E7=BA=A6=E6=B5=8B=E8=AF=95=E5=9F=BA=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增集中式 assertApiCall() helper(tests/helpers/api-snapshot.ts): 断言 url/method/params/data 快照,params/data 白名单外字段报错, 失败信息含实际与期望 URL 上下文;isApiModule() 覆盖 16 个已拆模块。 8 个测试:helper url/method/params/body、all_modules_covered、 fail_message、allowlist、regression_guard。 --- frontend-vue/tests/api-snapshot.test.ts | 127 +++++++++++++++++++++ frontend-vue/tests/helpers/api-snapshot.ts | 58 ++++++++++ 2 files changed, 185 insertions(+) create mode 100644 frontend-vue/tests/api-snapshot.test.ts create mode 100644 frontend-vue/tests/helpers/api-snapshot.ts diff --git a/frontend-vue/tests/api-snapshot.test.ts b/frontend-vue/tests/api-snapshot.test.ts new file mode 100644 index 00000000..9fe65679 --- /dev/null +++ b/frontend-vue/tests/api-snapshot.test.ts @@ -0,0 +1,127 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { http } from '../src/shared/api/http.ts' +import { assertApiCall, isApiModule } from '../tests/helpers/api-snapshot.ts' + +function setupWindow() { + ;(globalThis as Record).window = { + localStorage: { getItem: () => '42' }, + location: { origin: 'http://localhost' }, + } +} + +function mockRequest( + t: Parameters[1] extends (t: infer T) => unknown ? T : never, + impl: (config: { url?: string; method?: string; params?: Record; data?: unknown }) => Promise, +) { + t.mock.method(http, 'request', impl as never) +} + +const okResponse = (data: unknown) => Promise.resolve({ data: { success: true, message: 'ok', data } }) + +const withdrawCalls: Array<{ url?: string; method?: string; params?: Record; data?: unknown }> = [] + +test('test_snapshot_helper_url', async (t) => { + setupWindow() + mockRequest(t, (config) => { + withdrawCalls.push(config) + return okResponse({ items: [] }) + }) + const { listWithdrawCandidates } = await import('../src/shared/api/types/modules/withdraw.ts') + await listWithdrawCandidates() + assertApiCall(withdrawCalls, 0, { url: '/newApi/api/withdraw/candidates' }) +}) + +test('test_snapshot_helper_method', async (t) => { + setupWindow() + mockRequest(t, (config) => { + withdrawCalls.push(config) + return okResponse({}) + }) + const { clearWithdrawCandidates } = await import('../src/shared/api/types/modules/withdraw.ts') + await clearWithdrawCandidates(['店铺A']) + assertApiCall(withdrawCalls, 1, { url: '/newApi/api/withdraw/candidates/clear', method: 'POST' }) +}) + +test('test_snapshot_helper_params', async (t) => { + setupWindow() + mockRequest(t, (config) => { + withdrawCalls.push(config) + return okResponse({ items: [] }) + }) + const { getWithdrawHistory } = await import('../src/shared/api/types/modules/withdraw.ts') + await getWithdrawHistory() + assertApiCall(withdrawCalls, 2, { + url: '/newApi/api/withdraw/history', + params: { user_id: 42 }, + }) +}) + +test('test_snapshot_helper_body', async (t) => { + setupWindow() + mockRequest(t, (config) => { + withdrawCalls.push(config) + return okResponse({}) + }) + const { addWithdrawCandidate } = await import('../src/shared/api/types/modules/withdraw.ts') + await addWithdrawCandidate('店铺A') + assertApiCall(withdrawCalls, 3, { + url: '/newApi/api/withdraw/candidates', + method: 'POST', + data: { user_id: 42, shop_name: '店铺A' }, + }) +}) + +test('test_snapshot_all_modules_covered', () => { + setupWindow() + const modules = [ + 'dedupe', 'split', 'convert', 'publish', 'similar-asin', 'appearance-patent', + 'delete-brand', 'price-track', 'shop-match', 'query-asin', 'withdraw', + 'collect-data', 'image-video', 'brand', 'permission', 'digital-human', + ] + for (const name of modules) { + assert.ok(isApiModule(name), `${name} 应注册为 API 快照模块`) + } +}) + +test('test_snapshot_fail_message', async () => { + const calls = [ + { url: '/newApi/api/withdraw/candidates/9', method: 'DELETE', params: { user_id: 42 } }, + ] + try { + assertApiCall(calls, 0, { url: '/newApi/api/withdraw/candidates' }) + assert.fail('应抛出断言错误') + } catch (err) { + const message = (err as Error).message + assert.ok(message.includes('/newApi/api/withdraw/candidates/9'), '失败信息应含实际 URL') + assert.ok(message.includes('/newApi/api/withdraw/candidates'), '失败信息应含期望 URL') + } +}) + +test('test_snapshot_allowlist', async () => { + const calls = [ + { url: '/newApi/api/withdraw/history', method: 'GET', params: { user_id: 42, page: 1 } }, + ] + try { + assertApiCall(calls, 0, { url: '/newApi/api/withdraw/history', params: { user_id: 42 } }) + assert.fail('应抛出断言错误') + } catch (err) { + assert.ok((err as Error).message.includes('page'), '应报告白名单外字段 page') + } +}) + +test('test_snapshot_regression_guard', async () => { + const calls = [ + { url: '/newApi/api/withdraw/candidates', method: 'POST', data: { user_id: 42, shop_name: 'X', owner: 'y' } }, + ] + try { + assertApiCall(calls, 0, { + url: '/newApi/api/withdraw/candidates', + method: 'POST', + data: { user_id: 42, shop_name: 'X' }, + }) + assert.fail('应抛出断言错误') + } catch (err) { + assert.ok((err as Error).message.includes('owner'), '快照变更应报告超集字段') + } +}) diff --git a/frontend-vue/tests/helpers/api-snapshot.ts b/frontend-vue/tests/helpers/api-snapshot.ts new file mode 100644 index 00000000..bc77a40d --- /dev/null +++ b/frontend-vue/tests/helpers/api-snapshot.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' + +export interface ApiCallSnapshot { + url?: string + method?: string + params?: Record + data?: unknown +} + +const ALLOWED_SECTIONS = ['url', 'method', 'params', 'data'] as const + +const apiModules = [ + 'dedupe', 'split', 'convert', 'publish', 'similar-asin', 'appearance-patent', + 'delete-brand', 'price-track', 'shop-match', 'query-asin', 'withdraw', + 'collect-data', 'image-video', 'brand', 'permission', 'digital-human', +] as const + +export function isApiModule(name: string): boolean { + return (apiModules as readonly string[]).includes(name) +} + +export function assertApiCall(calls: ApiCallSnapshot[], index: number, expected: ApiCallSnapshot) { + const actual = calls[index] + assert.ok(actual, `第 ${index} 次请求不存在(共 ${calls.length} 次)`) + const context = `第 ${index} 次请求实际=${JSON.stringify(actual)} 期望=${JSON.stringify(expected)}` + for (const section of ALLOWED_SECTIONS) { + assert.ok( + !(section in expected) || section in actual, + `快照缺少 section: ${section}。${context}`, + ) + } + if (expected.url !== undefined) { + assert.equal(actual.url, expected.url, `URL 快照不匹配。${context}`) + } + if (expected.method !== undefined) { + assert.equal(actual.method, expected.method, `method 快照不匹配。${context}`) + } + if (expected.params !== undefined) { + const actualParams = actual.params ?? {} + for (const key of Object.keys(actualParams)) { + assert.ok( + key in expected.params, + `params 出现白名单外字段: ${key}。${context}`, + ) + } + assert.deepEqual(actualParams, expected.params, `params 快照不匹配。${context}`) + } + if (expected.data !== undefined) { + const actualData = (actual.data ?? {}) as Record + for (const key of Object.keys(actualData)) { + assert.ok( + key in expected.data, + `data 出现白名单外字段: ${key}。${context}`, + ) + } + assert.deepEqual(actualData, expected.data, `data 快照不匹配。${context}`) + } +}