Files
lexgo/learner/e2e/upload.spec.ts
T
ila 61d3f63c69 feat: 上传 TXT 文件,校验编码后导入本人书库 (#9)
- 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
2026-09-11 23:37:13 +08:00

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)
})