- POST /api/v1/books/upload 与 /api/v1/books/:id/chapters/upload:multipart 上传, 字段白名单、未知或重复字段拒绝、非 multipart 拒绝、单槽并发门忙时 429 - 只接受 UTF-8(可选 BOM 剥离且不进入原文),UTF-16 按 BOM 识别并给出针对性提示, 非法字节整体拒绝、不使用替换字符;2 MiB 字节上限之后仍套用单章 100000 码点上限 - 文件只在内存中解码,不创建临时文件;客户端文件名不参与任何路径也不入库 - 解码后交给现有 PasteBook/PasteChapter,分章、任务幂等与崩溃恢复与粘贴完全一致 - 学习端导入页新增「粘贴文本 / TXT 文件」来源切换与客户端预检,session.request 支持 FormData - gofmt 整理 #8 引入的 import 顺序与空行 - 同步 Architecture-and-Code-Map、Business-Rules-and-Glossary、 Local-Development-and-Verification、Product-Requirements-Overview 与 Home
77 lines
4.8 KiB
TypeScript
77 lines
4.8 KiB
TypeScript
import { expect, test } from '@playwright/test'
|
|
|
|
// The TXT upload path against a mocked API: pre-checks, multipart body and the hand-off to
|
|
// the same processing screen the paste path uses.
|
|
test('upload a UTF-8 TXT file and open the created book', async ({ page }) => {
|
|
const user = { id: 42, username: 'fictional-uploader', role: 'learner' }
|
|
const book = { id: 1, title: 'Studio Notes', language: 'en' }
|
|
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
|
|
const pasted = 'Mira opened the workshop.\r\n\r\n\tThe sign read “A small step…”\n'
|
|
let uploaded = false
|
|
|
|
await page.route('**/api/v1/**', async route => {
|
|
const path = new URL(route.request().url()).pathname
|
|
const method = route.request().method()
|
|
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: uploaded ? [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, ...timestamps }] : [] }
|
|
else if (path === '/api/v1/books/upload') {
|
|
// The upload must arrive as multipart: the fields and the file are inspected directly.
|
|
expect(route.request().headers()['content-type']).toContain('multipart/form-data')
|
|
const raw = route.request().postData() ?? ''
|
|
expect(raw).toContain('name="requestId"')
|
|
expect(raw).toContain('name="language"')
|
|
expect(raw).toContain('name="title"')
|
|
expect(raw).toContain('Studio Notes')
|
|
expect(raw).toContain('filename="notes.txt"')
|
|
expect(raw).toContain('Mira opened the workshop.')
|
|
uploaded = true
|
|
status = 201
|
|
data = {
|
|
book,
|
|
chapter: { id: 9, bookId: 1, ordinal: 1, title: 'Studio Notes', status: 'pending', charCount: [...pasted].length, errorReason: '', errorMessage: '', jobId: 5, ...timestamps },
|
|
job: { id: 5, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps },
|
|
duplicate: false,
|
|
}
|
|
} else if (path === '/api/v1/books/1') data = { book, chapters: [{ id: 9, bookId: 1, ordinal: 1, title: 'Studio Notes', status: 'ready', charCount: [...pasted].length, errorReason: '', errorMessage: '', jobId: 5, ...timestamps }] }
|
|
else if (path === '/api/v1/chapters/9') data = { book, chapter: { id: 9, bookId: 1, ordinal: 1, title: 'Studio Notes', status: 'ready', charCount: [...pasted].length, errorReason: '', errorMessage: '', jobId: 5, contentSha256: 'fictional-sha', originalText: pasted, ...timestamps }, navigation: { previousChapterId: null, nextChapterId: null } }
|
|
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 expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
|
|
|
await page.getByRole('button', { name: '导入内容' }).click()
|
|
// Element Plus hides the native radio behind a styled span, so the label is what a person
|
|
// clicks; the input still carries the checked state.
|
|
await page.locator('label.el-radio', { hasText: 'TXT 文件' }).click()
|
|
await expect(page.getByRole('radio', { name: 'TXT 文件' })).toBeChecked()
|
|
await expect(page.locator('textarea#text')).toHaveCount(0)
|
|
await expect(page.getByText('仅支持 UTF-8')).toBeVisible()
|
|
|
|
// An unusable file is refused in the browser, before any request is made.
|
|
await page.setInputFiles('[data-testid="file-input"]', { name: 'notes.md', mimeType: 'text/markdown', buffer: Buffer.from('# heading\n') })
|
|
await expect(page.getByText('请选择 .txt 文件。')).toBeVisible()
|
|
|
|
// A UTF-8 file is accepted and its metadata is shown; the title comes from the file name.
|
|
await page.setInputFiles('[data-testid="file-input"]', { name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from(pasted, 'utf8') })
|
|
await expect(page.getByTestId('file-info')).toContainText('notes.txt · UTF-8')
|
|
await expect(page.getByLabel('标题')).toHaveValue('notes')
|
|
await page.getByLabel('标题').fill('Studio Notes')
|
|
|
|
await page.getByRole('button', { name: '上传并处理' }).click()
|
|
await expect(page).toHaveURL(/\/books\/1$/)
|
|
await expect(page.getByText('已就绪')).toBeVisible()
|
|
await page.getByRole('link', { name: 'Studio Notes' }).click()
|
|
const readerText = page.locator('.reader-text')
|
|
await expect(readerText).toBeVisible()
|
|
// The uploaded bytes reached the reader unchanged.
|
|
expect(await readerText.evaluate(element => element.textContent)).toBe(pasted)
|
|
})
|