- PATCH /books/:id 改名、GET /chapters/:id/source 读取编辑用原文(任意状态)、 PATCH /chapters/:id 改标题与正文、DELETE /books/:id 与 DELETE /chapters/:id - 版本门控:任务只在 job.content_sha256 与章节版本一致时才能影响章节;过期版本任务 被标为 superseded 且完全不触碰章节,认领与恢复扫描跳过并作废它们,重试旧版本任务 409 - 只有正文变化才重新处理:重复保存或改回原内容不新建任务;只改标题不改状态 - 删除在事务内硬删除并沿用外键级联,章节删除后重排序号;个人词条、复习排期与作答记录保留 - 处理中删除章节后,在途任务不再发布也不报错;并发删除同一章由书籍行锁序列化 - 学习端:书名与章节编辑对话框、删除确认弹窗、章节行编辑入口、书库删除提示 - Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
107 lines
6.7 KiB
TypeScript
107 lines
6.7 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
|
|
|
// Renaming, editing and deleting through the real dialogs of the accepted prototype, against
|
|
// a mocked API.
|
|
test('rename the book, edit a chapter into a new version and delete both', async ({ page }) => {
|
|
const user = { id: 42, username: 'fictional-editor', role: 'learner' }
|
|
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
|
|
const first = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', status: 'ready', charCount: 24, errorReason: '', errorMessage: '', jobId: 5, ...timestamps }
|
|
const second = { id: 10, bookId: 1, ordinal: 2, title: 'Second chapter', status: 'ready', charCount: 12, errorReason: '', errorMessage: '', jobId: 6, ...timestamps }
|
|
let bookTitle = 'A small step'
|
|
let chapters = [first, second]
|
|
let firstText = 'Mira opened the workshop.\n'
|
|
let processing = false
|
|
const requests: string[] = []
|
|
|
|
const listBook = () => ({
|
|
book: { id: 1, title: bookTitle, language: 'en' },
|
|
chapters: chapters.map(item => (item.id === 9 && processing ? { ...item, status: 'processing' } : item)),
|
|
})
|
|
|
|
await page.route('**/api/v1/**', async route => {
|
|
const path = new URL(route.request().url()).pathname
|
|
const method = route.request().method()
|
|
requests.push(`${method} ${path}`)
|
|
let data: unknown = null
|
|
let status = 200
|
|
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
|
|
else if (path === '/api/v1/me') data = user
|
|
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
|
|
else if (path === '/api/v1/books' && method === 'GET') {
|
|
data = { items: bookTitle === '' ? [] : [{ id: 1, title: bookTitle, language: 'en', chapterCount: chapters.length, pendingCount: 0, processingCount: 0, readyCount: chapters.length, failedCount: 0, ...timestamps }] }
|
|
} else if (path === '/api/v1/books/1' && method === 'PATCH') {
|
|
bookTitle = (route.request().postDataJSON() as { title: string }).title
|
|
data = { book: { id: 1, title: bookTitle, language: 'en' } }
|
|
} else if (path === '/api/v1/books/1' && method === 'DELETE') {
|
|
chapters = []
|
|
bookTitle = ''
|
|
data = { deleted: { bookId: 1, chapters: 2, remaining: 0 } }
|
|
} else if (path === '/api/v1/books/1') data = listBook()
|
|
else if (path === '/api/v1/chapters/9/source') data = { source: { id: 9, bookId: 1, ordinal: 1, title: first.title, text: firstText, status: 'ready', contentSha256: 'sha-a', charCount: [...firstText].length } }
|
|
else if (path === '/api/v1/chapters/9' && method === 'PATCH') {
|
|
const body = route.request().postDataJSON() as { title?: string; text?: string }
|
|
const changed = body.text !== undefined && body.text !== firstText
|
|
if (body.title !== undefined) first.title = body.title
|
|
if (changed) {
|
|
firstText = body.text as string
|
|
processing = true
|
|
first.status = 'pending'
|
|
setTimeout(() => { processing = false; first.status = 'ready' }, 400)
|
|
}
|
|
status = 200
|
|
data = { chapter: first, job: changed ? { id: 7, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps } : null, versionChanged: changed }
|
|
} else if (path === '/api/v1/chapters/9' && method === 'DELETE') {
|
|
chapters = chapters.filter(item => item.id !== 9).map((item, index) => ({ ...item, ordinal: index + 1 }))
|
|
data = { deleted: { chapterId: 9, bookId: 1, remaining: chapters.length } }
|
|
}
|
|
await route.fulfill({ status, json: { code: 200, data } })
|
|
})
|
|
|
|
await page.goto('/')
|
|
await page.getByLabel('账号').fill(user.username)
|
|
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
|
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
|
await page.getByRole('link', { name: 'A small step' }).click()
|
|
await expect(page.getByRole('heading', { name: 'A small step' })).toBeVisible()
|
|
await expect(page.getByText('封面使用系统默认样式')).toBeVisible()
|
|
|
|
// Rename the book through the dialog.
|
|
await page.getByTestId('edit-book').click()
|
|
await expect(page.getByLabel('书名', { exact: true })).toHaveValue('A small step')
|
|
await page.getByLabel('书名', { exact: true }).fill('A long step')
|
|
await page.getByTestId('save-book').click()
|
|
await expect(page.getByTestId('book-notice')).toContainText('书名已更新')
|
|
await expect(page.getByRole('heading', { name: 'A long step' })).toBeVisible()
|
|
|
|
// Edit the chapter text: the new version re-processes and later becomes ready again.
|
|
await page.getByTestId('edit-chapter-9').click()
|
|
await expect(page.getByLabel('章节标题', { exact: true })).toHaveValue('First chapter')
|
|
await expect(page.getByLabel('正文', { exact: true })).toHaveValue(firstText)
|
|
await page.getByLabel('正文', { exact: true }).fill('A replacement body.\n')
|
|
await page.getByTestId('save-chapter').click()
|
|
await expect(page.getByTestId('book-notice')).toContainText('已保存为新版本,正在重新处理')
|
|
// The saved version is queued first and becomes readable again when the worker finishes.
|
|
await expect(page.locator('.chapter-row').first()).toContainText('待处理')
|
|
await expect(page.locator('.chapter-row').first()).toContainText('已就绪', { timeout: 10000 })
|
|
|
|
// Deleting a chapter asks first, then reports the remaining count.
|
|
await page.getByTestId('edit-chapter-9').click()
|
|
await page.getByTestId('delete-chapter').click()
|
|
await expect(page.locator('.el-message-box__message').last()).toContainText('本章正文将被删除,已保存词条保留')
|
|
await page.locator('.el-message-box').last().getByRole('button', { name: '取消' }).click()
|
|
expect(requests.filter(entry => entry === 'DELETE /api/v1/chapters/9')).toHaveLength(0)
|
|
await page.getByTestId('delete-chapter').click()
|
|
await page.locator('.el-message-box').last().getByRole('button', { name: '确认删除章节' }).click()
|
|
await expect(page.getByTestId('book-notice')).toContainText('章节已删除 · 剩余 1 章')
|
|
await expect(page.locator('.chapter-row')).toHaveCount(1)
|
|
|
|
// Deleting the book asks first and returns to the library without it.
|
|
await page.getByTestId('delete-book').click()
|
|
await expect(page.locator('.el-message-box__message').last()).toContainText('已保存的生词和短语将保留')
|
|
await page.locator('.el-message-box').last().getByRole('button', { name: '确认删除' }).click()
|
|
await expect(page).toHaveURL(/\?deleted=\d+$/)
|
|
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
|
await expect(page.getByTestId('library-notice')).toContainText('书籍已删除 · 已保存的生词和短语仍保留在生词本')
|
|
await expect(page.getByRole('link', { name: 'A long step' })).toHaveCount(0)
|
|
})
|