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:
2026-09-08 17:32:57 +08:00
parent 3105b0415c
commit b94e9b1721
10 changed files with 136 additions and 10 deletions
+67
View File
@@ -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()
})
})