feat: strengthen backup and mobile workflows
ci / gitleaks (push) Successful in 1m19s
ci / docker (push) Successful in 5m48s

This commit is contained in:
2026-09-16 21:12:52 +08:00
parent 6f38190c92
commit 6c234d7d82
64 changed files with 5059 additions and 635 deletions
+70
View File
@@ -0,0 +1,70 @@
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' }))
})
})