fix(api): harden response contracts

This commit is contained in:
2026-07-21 16:44:16 +08:00
parent c56ada3708
commit 2275d388a7
11 changed files with 191 additions and 26 deletions

View File

@@ -0,0 +1,138 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import ts from 'typescript'
const clientPath = new URL('../src/api/client.ts', import.meta.url)
const source = (await readFile(clientPath, 'utf8')).replaceAll('import.meta.env', '{}')
const output = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2023,
},
fileName: 'client.ts',
}).outputText
const client = await import(`data:text/javascript;base64,${Buffer.from(output).toString('base64')}`)
const { ApiError, apiRequest, normalizeBaseUrl, setApiBaseUrl } = client
const originalFetch = globalThis.fetch
test.beforeEach(() => {
setApiBaseUrl('http://api.test')
})
test.after(() => {
globalThis.fetch = originalFetch
})
test('empty 200 response rejects with a stable invalid_response ApiError', async () => {
globalThis.fetch = async () => new Response('', { status: 200 })
await assert.rejects(apiRequest('/empty'), invalidResponseError)
})
test('non-JSON 200 response rejects with a stable invalid_response ApiError', async () => {
globalThis.fetch = async () => new Response('<html>bad gateway</html>', { status: 200 })
await assert.rejects(apiRequest('/html'), invalidResponseError)
})
test('interrupted successful response body rejects with a stable invalid_response ApiError', async () => {
globalThis.fetch = async () => ({
ok: true,
status: 200,
text: async () => {
throw new Error('read interrupted')
},
})
await assert.rejects(apiRequest('/interrupted'), invalidResponseError)
})
test('204 response returns undefined without reading a body', async () => {
globalThis.fetch = async () => new Response(null, { status: 204 })
assert.equal(await apiRequest('/no-content'), undefined)
})
test('explicit void response returns undefined for a non-204 response', async () => {
globalThis.fetch = async () => new Response('ignored', { status: 200 })
assert.equal(await apiRequest('/accepted', { responseType: 'void' }), undefined)
})
test('network failure rejects with network_error ApiError', async () => {
globalThis.fetch = async () => {
throw new Error('socket closed')
}
await assert.rejects(apiRequest('/network'), (error) => {
assert.equal(error instanceof ApiError, true)
assert.equal(error.status, 0)
assert.equal(error.code, 'network_error')
return true
})
})
test('standard error envelope preserves status, code, and Chinese message', async () => {
globalThis.fetch = async () => Response.json(
{ error: { code: 'invalid_request', message: '请求参数无效' } },
{ status: 400 },
)
await assert.rejects(apiRequest('/standard-error'), (error) => {
assert.equal(error instanceof ApiError, true)
assert.equal(error.status, 400)
assert.equal(error.code, 'invalid_request')
assert.equal(error.message, '请求参数无效')
return true
})
})
test('GET without a body does not send Content-Type', async () => {
let requestInit
globalThis.fetch = async (_url, init) => {
requestInit = init
return Response.json({ ok: true })
}
await apiRequest('/read')
assert.equal(requestInit.headers['Content-Type'], undefined)
})
test('JSON body sends application/json Content-Type', async () => {
let requestInit
globalThis.fetch = async (_url, init) => {
requestInit = init
return Response.json({ ok: true })
}
await apiRequest('/write', { method: 'POST', body: { title: '测试' } })
assert.equal(requestInit.headers['Content-Type'], 'application/json')
})
test('FormData body leaves Content-Type unset so fetch provides the boundary', async () => {
let requestInit
globalThis.fetch = async (_url, init) => {
requestInit = init
return Response.json({ ok: true })
}
await apiRequest('/upload', { method: 'POST', body: new FormData() })
assert.equal(requestInit.headers['Content-Type'], undefined)
})
test('base URL normalizer trims whitespace and all trailing slashes', () => {
assert.equal(normalizeBaseUrl(' https://host.example/// '), 'https://host.example')
})
function invalidResponseError(error) {
assert.equal(error instanceof ApiError, true)
assert.equal(error.status, 200)
assert.equal(error.code, 'invalid_response')
assert.equal(error.message, '服务器返回了无法识别的数据')
return true
}

View File

@@ -23,6 +23,7 @@ const requiredFiles = [
'src/api/mappers.tsx',
'src/api/search.ts',
'src/api/inbox.ts',
'scripts/api-client.test.mjs',
]
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
@@ -77,9 +78,17 @@ for (const match of apiSource.matchAll(/export type (\w+DTO)\s*=\s*\{([\s\S]*?)\
if (/^\s*[A-Z]\w*\s*:/m.test(body)) failures.push(`${name} fields must use camelCase`)
if (/^\s*(?:id|\w+Id)\s*:\s*number\b/im.test(body)) failures.push(`${name} identities must be strings`)
}
if (/\|\s*undefined/.test(projectsSource)) failures.push('project DTOs must not use undefined fields')
const searchSource = readFileSync('src/api/search.ts', 'utf8')
if (!searchSource.includes("'/api/v1/search'")) failures.push('search API must use /api/v1/search')
for (const unsupportedType of ["'source'", "'inbox'", "'ai_session'"]) {
if (searchSource.includes(unsupportedType)) failures.push(`search DTO contains unsupported result type ${unsupportedType}`)
}
const loginSource = readFileSync('src/pages/login.tsx', 'utf8')
if (!loginSource.includes("import { normalizeBaseUrl } from '../api/client'")) failures.push('login must share the API base URL normalizer')
if (loginSource.includes('function normalizeBaseUrl')) failures.push('login must not define a second base URL normalizer')
const inboxSource = readFileSync('src/api/inbox.ts', 'utf8')
for (const path of ['/api/v1/projects/', '/api/v1/inbox/']) {