Files
dodo/frontend/e2e/backup-roundtrip.spec.ts
T
bboysoul 6c234d7d82
ci / gitleaks (push) Successful in 1m19s
ci / docker (push) Successful in 5m48s
feat: strengthen backup and mobile workflows
2026-09-16 21:12:52 +08:00

116 lines
7.8 KiB
TypeScript

import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import type { APIRequestContext, Page } from '@playwright/test'
import { expect, test } from './fixtures'
import { unzipSync } from 'fflate'
function bottomTab(page: Page, name: string) {
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
}
async function csrf(request: APIRequestContext, baseURL: string) {
const state = await request.storageState()
return state.cookies.find(cookie => cookie.name === 'dodo_csrf' && baseURL.includes(cookie.domain))?.value
?? state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value
?? ''
}
async function mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters<APIRequestContext['fetch']>[1]) {
const token = await csrf(request, baseURL)
return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': token, origin: baseURL } })
}
test('complete ZIP backup preflights and replace-restores task, habit history, countdown, and attachment bytes', async ({ page, request, baseURL }, testInfo) => {
const suffix = testInfo.project.name
const taskTitle = `E2E 备份任务 ${suffix}`
const habitName = `E2E 备份习惯 ${suffix}`
const countdownTitle = `E2E 备份倒数日 ${suffix}`
const bootstrap = await request.get('/api/v1/bootstrap')
expect(bootstrap.ok()).toBeTruthy()
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
expect(inbox).toBeTruthy()
const task = await mutate(request, baseURL!, '/api/v1/tasks', { method: 'POST', data: { title: taskTitle, list_id: inbox.id } })
expect(task.ok()).toBeTruthy()
const taskData = await task.json() as { id: string; version: number }
const attachmentName = `原始附件-${suffix}.txt`
const attachmentBytes = Buffer.from([0, 1, 2, 3, 10, 13, 127, 128, 254, 255])
const attachmentResponse = await mutate(request, baseURL!, `/api/v1/tasks/${taskData.id}/attachments`, {
method: 'POST', multipart: { file: { name: attachmentName, mimeType: 'text/plain', buffer: attachmentBytes } },
})
expect(attachmentResponse.ok()).toBeTruthy()
const attachment = await attachmentResponse.json() as { id: string; filename: string; size: number; mime_type: string }
expect(attachment).toMatchObject({ filename: attachmentName, size: attachmentBytes.length, mime_type: 'text/plain' })
const habitResponse = await mutate(request, baseURL!, '/api/v1/habits', { method: 'POST', data: { name: habitName, kind: 'numeric', target: 2, max_value: 3, schedule_type: 'daily' } })
expect(habitResponse.ok()).toBeTruthy()
const habit = await habitResponse.json()
const day = new Date().toLocaleDateString('sv-SE')
expect((await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${day}`, { method: 'PUT', data: { value: 2 } })).ok()).toBeTruthy()
const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', { method: 'POST', data: { title: countdownTitle, event_date: day, kind: 'countdown', repeat_rule: 'none', calendar_mode: 'solar', ignore_year: false } })
expect(countdownResponse.ok()).toBeTruthy()
await page.goto('/')
await bottomTab(page, '设置').click()
const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: '导出 ZIP' }).click()
const download = await downloadPromise
const zipPath = await download.path()
expect(zipPath).not.toBeNull()
const zipBytes = new Uint8Array(await readFile(zipPath!))
const files = unzipSync(zipBytes)
const manifest = JSON.parse(new TextDecoder().decode(files['manifest.json'])) as { format: string; version: number; entities: Record<string, number>; checksums: Record<string, string> }
expect(manifest.format).toBe('dodo-backup')
expect(manifest.version).toBe(2)
expect(manifest.entities.tasks).toBeGreaterThan(0)
expect(manifest.entities.habit_logs).toBeGreaterThan(0)
expect(manifest.entities.attachments).toBeGreaterThan(0)
const attachmentRows = JSON.parse(new TextDecoder().decode(files['data/attachments.json'])) as Array<{ id: string; task_id: string; filename: string; mime_type: string; size: number; archive_path: string }>
const archivedAttachment = attachmentRows.find(item => item.id === attachment.id)
expect(archivedAttachment).toMatchObject({ task_id: taskData.id, filename: attachmentName, mime_type: 'text/plain', size: attachmentBytes.length })
expect(Buffer.from(files[archivedAttachment!.archive_path])).toEqual(attachmentBytes)
expect(manifest.checksums[archivedAttachment!.archive_path]).toBe(createHash('sha256').update(attachmentBytes).digest('hex'))
for (const [entry, digest] of Object.entries(manifest.checksums)) {
expect(files[entry], `declared ZIP entry ${entry}`).toBeTruthy()
expect(createHash('sha256').update(files[entry]).digest('hex')).toBe(digest)
}
expect((await mutate(request, baseURL!, `/api/v1/tasks/${taskData.id}`, { method: 'PATCH', data: { title: `${taskTitle} 已破坏`, version: taskData.version } })).ok()).toBeTruthy()
expect((await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${day}`, { method: 'PUT', data: { value: 0 } })).ok()).toBeTruthy()
expect((await mutate(request, baseURL!, `/api/v1/countdowns/${(await countdownResponse.json()).id}`, { method: 'DELETE' })).ok()).toBeTruthy()
expect((await mutate(request, baseURL!, `/api/v1/attachments/${attachment.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
expect(await (await request.get(`/api/v1/tasks/${taskData.id}/attachments`)).json()).toEqual([])
const chooser = page.locator('input[type=file]')
await chooser.setInputFiles({ name: 'dodo-backup-v2.zip', mimeType: 'application/zip', buffer: Buffer.from(zipBytes) })
await page.getByLabel('恢复方式').selectOption('replace')
await page.getByRole('button', { name: '开始预检' }).click()
const preflight = page.locator('.backup-preflight')
await expect(preflight).toContainText('预检通过')
await expect(preflight).toContainText('附件')
await page.getByRole('button', { name: '替换并恢复' }).click()
const confirm = page.getByRole('dialog', { name: '确认替换全部数据?' })
await confirm.getByRole('button', { name: '确认', exact: true }).click()
await expect(page.getByRole('status')).toContainText('数据已恢复')
const restoredTasksResponse = await request.get(`/api/v1/tasks?q=${encodeURIComponent(taskTitle)}&limit=100`)
expect(restoredTasksResponse.ok()).toBeTruthy()
const restoredTasks = (await restoredTasksResponse.json()).items as Array<{ id: string; title: string }>
expect(restoredTasks.filter(item => item.title === taskTitle)).toHaveLength(1)
const restoredTask = restoredTasks.find(item => item.title === taskTitle)!
const restoredAttachmentsResponse = await request.get(`/api/v1/tasks/${restoredTask.id}/attachments`)
expect(restoredAttachmentsResponse.ok()).toBeTruthy()
const restoredAttachments = await restoredAttachmentsResponse.json() as Array<{ id: string; filename: string; mime_type: string; size: number }>
expect(restoredAttachments).toHaveLength(1)
expect(restoredAttachments[0]).toMatchObject({ filename: attachmentName, mime_type: 'text/plain', size: attachmentBytes.length })
const restoredBlob = await request.get(`/api/v1/attachments/${restoredAttachments[0].id}`)
expect(restoredBlob.ok()).toBeTruthy()
expect(Buffer.from(await restoredBlob.body())).toEqual(attachmentBytes)
await bottomTab(page, '习惯').click()
const habitRow = page.locator('.habit-row').filter({ hasText: habitName })
await habitRow.getByRole('button', { name: `查看习惯详情:${habitName}` }).click()
await expect(page.locator('.habit-history__row')).toContainText('2 / 2')
await page.getByRole('button', { name: '关闭习惯详情' }).click()
await bottomTab(page, '倒数日').click()
await expect(page.getByText(countdownTitle, { exact: true })).toHaveCount(1)
})