fix(web): 修复图片上传、自动保存竞态、登录竞态并统一失效公共缓存
FormData 不强设 Content-Type(P1-2),补 api 客户端回归测试; 自动保存成功仅失效列表与公共键,防详情 refetch 回滚输入(P1-4); login/logout 以请求序号丢弃迟到 /api/me 响应,401 统一回调跳登录(P2-15); 首页不硬编码 page_size,写操作后统一 invalidatePublic,TagPage 补 error 分支(P2-15)。
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
// api 客户端单测(评审 round2 P1-2 / P2-15 的回归守护):
|
||||
// 1. FormData 请求不得强设 Content-Type(multipart boundary 由浏览器生成);
|
||||
// 2. 401 触发全局未授权回调(AuthProvider 据此跳登录页)。
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { api, ApiError, setUnauthorizedHandler } from '../src/lib/api'
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('api 客户端', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
setUnauthorizedHandler(null)
|
||||
})
|
||||
|
||||
it('FormData 请求不强设 Content-Type(保留浏览器 multipart boundary)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(201, { data: { id: 1, url: '/api/images/1' } }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob(['x'], { type: 'image/png' }), 'a.png')
|
||||
await api<{ id: number }>('/api/admin/images', { method: 'POST', body: form })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
const headers = init.headers as Headers
|
||||
expect(headers.has('Content-Type')).toBe(false)
|
||||
})
|
||||
|
||||
it('JSON 请求默认补 application/json 并携带 CSRF 头', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { data: { ok: true } }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const { setCsrfToken } = await import('../src/lib/api')
|
||||
setCsrfToken('token-abc')
|
||||
|
||||
await api('/api/admin/notes/1', { method: 'PUT', body: JSON.stringify({ title: 'x' }) })
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
const headers = init.headers as Headers
|
||||
expect(headers.get('Content-Type')).toBe('application/json')
|
||||
expect(headers.get('X-CSRF-Token')).toBe('token-abc')
|
||||
setCsrfToken(null)
|
||||
})
|
||||
|
||||
it('401 触发全局未授权回调并抛出 ApiError', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse(401, { error: { code: 'unauthorized', message: '未登录或会话已过期' } }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const handler = vi.fn()
|
||||
setUnauthorizedHandler(handler)
|
||||
|
||||
try {
|
||||
await api('/api/admin/notes', { method: 'DELETE' })
|
||||
expect.unreachable('应抛出 ApiError')
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ApiError)
|
||||
expect((e as ApiError).status).toBe(401)
|
||||
expect((e as ApiError).code).toBe('unauthorized')
|
||||
}
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user