import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiError, requestBlob, requestJson, uploadJson } from './http' import { downloadFullBackup, preflightBackup, restoreBackup } from './backups' afterEach(() => vi.restoreAllMocks()) function response(body: BodyInit | null, init: ResponseInit = {}) { return new Response(body, { status: 200, ...init }) } describe('typed HTTP client', () => { it('sends credentials, CSRF, JSON and AbortSignal consistently', async () => { document.cookie = 'dodo_csrf=csrf-token' const signal = new AbortController().signal const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response('{"ok":true}', { headers: { 'content-type': 'application/json' } })) await requestJson<{ ok: boolean }>('/example', { method: 'POST', body: { value: 1 }, signal }) expect(fetchMock).toHaveBeenCalledWith('/api/v1/example', expect.objectContaining({ credentials: 'include', signal, method: 'POST', body: '{"value":1}' })) const headers = new Headers(fetchMock.mock.calls[0][1]?.headers) expect(headers.get('content-type')).toBe('application/json') expect(headers.get('x-csrf-token')).toBe('csrf-token') }) it('normalizes FastAPI validation details without losing the status or code', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue(response(JSON.stringify({ detail: [{ loc: ['body', 'name'], msg: '必填', type: 'missing' }], code: 'invalid_backup' }), { status: 422, headers: { 'content-type': 'application/json' } })) const error = await requestJson('/bad').catch((reason) => reason) expect(error).toBeInstanceOf(ApiError) const apiError = error as ApiError expect(apiError).toMatchObject({ status: 422, code: 'invalid_backup' }) expect(apiError.message).toContain('name') expect(apiError.message).toContain('必填') }) it('keeps blob and multipart requests typed without forcing JSON content type', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch') .mockResolvedValueOnce(response('zip-data', { headers: { 'content-type': 'application/zip' } })) .mockResolvedValueOnce(response('{"valid":true}', { headers: { 'content-type': 'application/json' } })) const blob = await requestBlob('/backup/export.zip') expect(blob.size).toBe(8) expect(blob.type).toBe('application/zip') await uploadJson('/backup/preflight?mode=merge', new File(['zip'], 'backup.zip')) const uploadHeaders = new Headers(fetchMock.mock.calls[1][1]?.headers) expect(uploadHeaders.has('content-type')).toBe(false) expect(fetchMock.mock.calls[1][1]?.body).toBeInstanceOf(FormData) }) }) describe('backup API', () => { it('uses the versioned ZIP export, preflight and restore endpoints', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch') .mockResolvedValueOnce(response('zip-data', { headers: { 'content-type': 'application/zip' } })) .mockResolvedValueOnce(response(JSON.stringify({ valid: true, preflight_token: 'token', backup_id: 'backup', archive_sha256: 'sha', entities: { tasks: 2, attachments: 1 } }), { headers: { 'content-type': 'application/json' } })) .mockResolvedValueOnce(response(JSON.stringify({ restored: 3, mode: 'replace' }), { headers: { 'content-type': 'application/json' } })) await downloadFullBackup() const preview = await preflightBackup(new File(['zip'], 'dodo.zip'), 'replace') await restoreBackup(preview.preflight_token!, 'replace') expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ '/api/v1/backup/export.zip', '/api/v1/backup/preflight?mode=replace', '/api/v1/backup/restore', ]) expect(preview.entities.tasks).toBe(2) expect(fetchMock.mock.calls[2][1]?.body).toBe(JSON.stringify({ preflight_token: 'token', mode: 'replace' })) }) })