feat: strengthen backup and mobile workflows
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user