import { afterEach, expect, test, vi } from 'vitest'; import { downloadAdminProjectSnapshot, listAdminProjectSnapshots, } from './adminApiClient'; afterEach(() => { vi.unstubAllGlobals(); }); test('项目列表携带分页与后台授权,解析标准响应', async () => { const payload = { items: [], nextCursor: 'next' }; const fetchMock = vi .fn() .mockResolvedValue( new Response(JSON.stringify({ ok: true, data: payload })), ); vi.stubGlobal('fetch', fetchMock); const controller = new AbortController(); expect( await listAdminProjectSnapshots( 'admin-token', { cursor: 'user/a+项目', limit: 20 }, controller.signal, ), ).toEqual(payload); expect(fetchMock).toHaveBeenCalledWith( '/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }), signal: controller.signal, }), ); }); test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response('PK\u0003\u0004', { headers: { 'content-type': 'application/zip', 'content-disposition': "attachment; filename=project.zip; filename*=UTF-8''%E4%B8%89%E6%B6%88-r2.zip", }, }), ); vi.stubGlobal('fetch', fetchMock); const controller = new AbortController(); const archive = await downloadAdminProjectSnapshot( 'admin-token', 'user/a', 'project/b', controller.signal, ); expect(archive.filename).toBe('三消-r2.zip'); expect(archive.blob.type).toBe('application/zip'); expect(fetchMock).toHaveBeenCalledWith( '/admin/api/project-snapshots/user%2Fa/project%2Fb/download', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer admin-token', Accept: 'application/zip', }), signal: controller.signal, }), ); }); test.each([ ['attachment; filename="工程.zip"; filename*=UTF-8\'\'%broken', '工程.zip'], ['attachment; filename="../secret.zip"', '.._secret.zip'], ["attachment; filename*=UTF-8''unsafe%00%1F%7F.zip", 'unsafe___.zip'], [null, 'project.zip'], ])('ZIP 附件名兼容安全回退 %s', async (header, expected) => { const headers: Record = { 'content-type': 'application/zip' }; // Response 的 Headers 只接受 Latin-1;真实 UTF-8 文件名使用 filename*。 if (header) headers['content-disposition'] = header.replace('工程', 'project'); vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(new Response('PK', { headers })), ); expect( (await downloadAdminProjectSnapshot('token', 'user', 'project')).filename, ).toBe(expected.replace('工程', 'project')); }); test.each([401, 403, 409, 500])( '下载 HTTP %s 保留后台错误,不返回 ZIP', async (status) => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( new Response( JSON.stringify({ ok: false, error: { code: 'SNAPSHOT_FAILURE', message: '工程尚未同步完成' }, }), { status, headers: { 'content-type': 'application/json' }, }, ), ), ); await expect( downloadAdminProjectSnapshot('token', 'user', 'project'), ).rejects.toMatchObject({ status, code: 'SNAPSHOT_FAILURE', message: '工程尚未同步完成', }); }, ); test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( new Response('{"ok":false}', { headers: { 'content-type': 'application/json' }, }), ), ); await expect( downloadAdminProjectSnapshot('token', 'user', 'project'), ).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' }); });