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[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; checksums: Record } 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?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) })