task-26: 模块 API 快照契约测试基建
新增集中式 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。
This commit is contained in:
@@ -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<string, unknown>).window = {
|
||||
localStorage: { getItem: () => '42' },
|
||||
location: { origin: 'http://localhost' },
|
||||
}
|
||||
}
|
||||
|
||||
function mockRequest(
|
||||
t: Parameters<typeof test>[1] extends (t: infer T) => unknown ? T : never,
|
||||
impl: (config: { url?: string; method?: string; params?: Record<string, unknown>; data?: unknown }) => Promise<unknown>,
|
||||
) {
|
||||
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<string, unknown>; 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'), '快照变更应报告超集字段')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
export interface ApiCallSnapshot {
|
||||
url?: string
|
||||
method?: string
|
||||
params?: Record<string, unknown>
|
||||
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<string, unknown>
|
||||
for (const key of Object.keys(actualData)) {
|
||||
assert.ok(
|
||||
key in expected.data,
|
||||
`data 出现白名单外字段: ${key}。${context}`,
|
||||
)
|
||||
}
|
||||
assert.deepEqual(actualData, expected.data, `data 快照不匹配。${context}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user