152 lines
4.6 KiB
JavaScript
152 lines
4.6 KiB
JavaScript
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('request forwards its AbortSignal to fetch', async () => {
|
|
let requestInit
|
|
const controller = new AbortController()
|
|
globalThis.fetch = async (_url, init) => {
|
|
requestInit = init
|
|
return Response.json({ ok: true })
|
|
}
|
|
|
|
await apiRequest('/search', { signal: controller.signal })
|
|
|
|
assert.equal(requestInit.signal, controller.signal)
|
|
})
|
|
|
|
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
|
|
}
|