fix(P0-00): HTTP client 必须传递 method/body,仅 body 存在时设 Content-Type
request() 此前忽略 options.method(默认 GET)与 options.body,导致 POST/PUT/DELETE 全部以 GET 空 body 发出;且无条件设置 Content-Type 误污染无 body 请求。 修复: 显式传递 method(默认 GET) 与 body; 仅当 body 非空时加 Content-Type。 测试 frontend/src/lib/http.test.ts(5/5):GET/POST/PUT/DELETE 的 method/body/headers。 运行: node --experimental-transform-types frontend/src/lib/http.test.ts
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* P0-00: HTTP client method/body/headers 可信度测试。
|
||||
* 运行: node --experimental-strip-types frontend/src/lib/http.test.ts
|
||||
*
|
||||
* 最小环境 polyfill:Node 22 自带 fetch/AbortController,本测试不触发 401 路径,
|
||||
* 故 window.dispatchEvent 不会被调用,无需完整 DOM。
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
// 最小浏览器环境 polyfill(仅覆盖 http.ts 在 happy path 用到的全局)
|
||||
const store: Record<string, string> = {}
|
||||
// @ts-expect-error 测试用最小 window stub
|
||||
globalThis.window = {
|
||||
dispatchEvent: () => false,
|
||||
localStorage: {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => { store[k] = v },
|
||||
removeItem: (k: string) => { delete store[k] },
|
||||
},
|
||||
}
|
||||
|
||||
// 捕获每次 fetch 的入参供断言
|
||||
let lastInit: RequestInit | undefined
|
||||
globalThis.fetch = async (_url: string, init?: RequestInit) => {
|
||||
lastInit = init
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||
}
|
||||
|
||||
const { http } = await import('./http.ts')
|
||||
|
||||
test('GET: method=GET, 无 body, 无 Content-Type', async () => {
|
||||
await http.get('/api/v1/matches')
|
||||
assert.equal(lastInit?.method, 'GET')
|
||||
assert.equal(lastInit?.body, undefined)
|
||||
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], undefined)
|
||||
})
|
||||
|
||||
test('POST: method=POST, 序列化 body, 有 Content-Type', async () => {
|
||||
await http.post('/api/v1/matches', { a: 1 })
|
||||
assert.equal(lastInit?.method, 'POST')
|
||||
assert.equal(lastInit?.body, JSON.stringify({ a: 1 }))
|
||||
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], 'application/json')
|
||||
})
|
||||
|
||||
test('POST 空 body: 不设 Content-Type', async () => {
|
||||
await http.post('/api/v1/matches', undefined)
|
||||
assert.equal(lastInit?.method, 'POST')
|
||||
assert.equal(lastInit?.body, undefined)
|
||||
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], undefined)
|
||||
})
|
||||
|
||||
test('PUT: method=PUT, 有 body 与 Content-Type', async () => {
|
||||
await http.put('/api/v1/x', { b: 2 })
|
||||
assert.equal(lastInit?.method, 'PUT')
|
||||
assert.equal(lastInit?.body, JSON.stringify({ b: 2 }))
|
||||
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], 'application/json')
|
||||
})
|
||||
|
||||
test('DELETE: method=DELETE, 无 body, 无 Content-Type', async () => {
|
||||
await http.delete('/api/v1/x/1')
|
||||
assert.equal(lastInit?.method, 'DELETE')
|
||||
assert.equal(lastInit?.body, undefined)
|
||||
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], undefined)
|
||||
})
|
||||
@@ -49,10 +49,11 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const method = (options.method ?? 'GET').toUpperCase()
|
||||
const body = options.body
|
||||
// 仅当有 body 时设置 Content-Type,避免 GET/DELETE 等无 body 请求被误标
|
||||
const headers: Record<string, string> = body ? { 'Content-Type': 'application/json' } : {}
|
||||
const res = await fetch(url, { signal: controller.signal, method, body, headers })
|
||||
|
||||
if (!res.ok) {
|
||||
const rawText = await res.text()
|
||||
|
||||
Reference in New Issue
Block a user