feat: PDF 转换前端(epub 上传/进度轮询/下载/用户软删,复用 zTools2 API)
便携式 Vue3 前端:上传 epub 转 PDF,显示上传与转换进度,完成后下载; 用户可软删任务(不再对自己展示,管理员仍可见)。凭 cookie 标识用户。 复用 zTools2 后端 /api/pdf/*(同源),独立 i18n(中英懒加载), 可独立运行或经 mainPage iframe 同源集成。含 vitest 单测 20 项。
This commit is contained in:
193
test/usePdfApi.test.js
Normal file
193
test/usePdfApi.test.js
Normal file
@@ -0,0 +1,193 @@
|
||||
// usePdfApi 单元测试:验证状态机、校验、轮询、格式化逻辑(与 DOM 解耦)。
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
createPdfApi,
|
||||
isTerminal,
|
||||
fmtBytes,
|
||||
validateFile,
|
||||
uploadFile,
|
||||
} from '../src/composables/usePdfApi.js'
|
||||
|
||||
describe('isTerminal', () => {
|
||||
it('done 与 failed 为终态', () => {
|
||||
expect(isTerminal({ status: 'done' })).toBe(true)
|
||||
expect(isTerminal({ status: 'failed' })).toBe(true)
|
||||
})
|
||||
it('pending / converting 非终态', () => {
|
||||
expect(isTerminal({ status: 'pending' })).toBe(false)
|
||||
expect(isTerminal({ status: 'converting' })).toBe(false)
|
||||
})
|
||||
it('空值安全', () => {
|
||||
expect(isTerminal(null)).toBe(false)
|
||||
expect(isTerminal(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fmtBytes', () => {
|
||||
it('字节级显示 B', () => {
|
||||
expect(fmtBytes(500)).toBe('500 B')
|
||||
})
|
||||
it('KB/MB 进位', () => {
|
||||
expect(fmtBytes(1500)).toBe('1.5 KB')
|
||||
expect(fmtBytes(5 * 1024 * 1024)).toBe('5.2 MB')
|
||||
})
|
||||
it('空/非法值返回 -', () => {
|
||||
expect(fmtBytes(null)).toBe('-')
|
||||
expect(fmtBytes(NaN)).toBe('-')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateFile', () => {
|
||||
const mk = (name, size) => ({ name, size })
|
||||
it('epub 且未超限通过', () => {
|
||||
expect(validateFile(mk('a.epub', 1000))).toBeNull()
|
||||
expect(validateFile(mk('A.EPUB', 1000))).toBeNull()
|
||||
})
|
||||
it('非 epub 拒绝', () => {
|
||||
expect(validateFile(mk('a.txt', 1000))).toBe('upload.requireEpub')
|
||||
expect(validateFile(mk('a.pdf', 1000))).toBe('upload.requireEpub')
|
||||
})
|
||||
it('超 250MB 拒绝', () => {
|
||||
expect(validateFile(mk('a.epub', 250 * 1024 * 1024 + 1))).toBe('upload.oversize')
|
||||
})
|
||||
it('自定义上限', () => {
|
||||
expect(validateFile(mk('a.epub', 200), 100)).toBe('upload.oversize')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPdfApi', () => {
|
||||
it('load 拉取列表并归一化', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ total: 2, items: [{ id: 1 }, { id: 2 }] }),
|
||||
})
|
||||
const api = createPdfApi({ fetch: fetcher })
|
||||
const r = await api.load()
|
||||
expect(fetcher).toHaveBeenCalledWith('/api/pdf/jobs', { credentials: 'same-origin' })
|
||||
expect(r.total).toBe(2)
|
||||
expect(r.items).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('load 处理空响应', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
||||
const api = createPdfApi({ fetch: fetcher })
|
||||
const r = await api.load()
|
||||
expect(r.total).toBe(0)
|
||||
expect(r.items).toEqual([])
|
||||
})
|
||||
|
||||
it('load HTTP 错误抛异常', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({ ok: false, status: 500 })
|
||||
const api = createPdfApi({ fetch: fetcher })
|
||||
await expect(api.load()).rejects.toThrow('HTTP 500')
|
||||
})
|
||||
|
||||
it('remove 发 DELETE', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ deleted: true }) })
|
||||
const api = createPdfApi({ fetch: fetcher })
|
||||
const r = await api.remove(7)
|
||||
expect(fetcher).toHaveBeenCalledWith('/api/pdf/jobs/7', {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
expect(r.deleted).toBe(true)
|
||||
})
|
||||
|
||||
it('downloadUrl 生成路径', () => {
|
||||
const api = createPdfApi({ fetch: vi.fn() })
|
||||
expect(api.downloadUrl(3)).toBe('/api/pdf/jobs/3/download')
|
||||
})
|
||||
|
||||
it('pollPending 无未完成任务立即 onDone', () => {
|
||||
const api = createPdfApi({ fetch: vi.fn() })
|
||||
const onDone = vi.fn()
|
||||
const onUpdate = vi.fn()
|
||||
const stop = api.pollPending(
|
||||
[{ id: 1, status: 'done' }],
|
||||
onUpdate,
|
||||
onDone
|
||||
)
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
stop()
|
||||
})
|
||||
|
||||
it('pollPending 轮询未完成项直至终态', async () => {
|
||||
vi.useFakeTimers()
|
||||
// getJob 第 1 次返回 converting,第 2 次返回 done
|
||||
const states = [{ status: 'converting', progress: 50 }, { status: 'done', progress: 100 }]
|
||||
let call = 0
|
||||
const fetcher = vi.fn().mockImplementation(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ id: 1, ...states[Math.min(call++, states.length - 1)] }),
|
||||
}))
|
||||
const api = createPdfApi({ fetch: fetcher })
|
||||
const updated = []
|
||||
const onDone = vi.fn()
|
||||
const stop = api.pollPending(
|
||||
[{ id: 1, status: 'pending', progress: 0 }],
|
||||
(fresh) => updated.push(fresh),
|
||||
onDone
|
||||
)
|
||||
// 第 1 次轮询:converting
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(updated[0][0].status).toBe('converting')
|
||||
// 第 2 次轮询:done -> 触发 onDone
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
expect(updated[1][0].status).toBe('done')
|
||||
stop()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('pollPending stop 提前停止', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fetcher = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 1, status: 'converting', progress: 30 }),
|
||||
})
|
||||
const api = createPdfApi({ fetch: fetcher })
|
||||
const onUpdate = vi.fn()
|
||||
const stop = api.pollPending([{ id: 1, status: 'pending' }], onUpdate)
|
||||
stop()
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('成功解析响应', async () => {
|
||||
const fakeXhr = vi.fn().mockImplementation(() => {
|
||||
const x = {
|
||||
open: vi.fn(),
|
||||
send: vi.fn(),
|
||||
upload: {},
|
||||
status: 200,
|
||||
responseText: JSON.stringify({ job: { id: 9 } }),
|
||||
}
|
||||
// 模拟 send 后异步触发 onload
|
||||
setTimeout(() => x.onload && x.onload(), 0)
|
||||
return x
|
||||
})
|
||||
const res = await uploadFile({ name: 'a.epub', size: 1 }, () => {}, { xhr: fakeXhr })
|
||||
expect(res.job.id).toBe(9)
|
||||
})
|
||||
|
||||
it('HTTP 错误抛含 detail', async () => {
|
||||
const fakeXhr = vi.fn().mockImplementation(() => {
|
||||
const x = {
|
||||
open: vi.fn(),
|
||||
send: vi.fn(),
|
||||
upload: {},
|
||||
status: 413,
|
||||
responseText: JSON.stringify({ detail: 'too big' }),
|
||||
}
|
||||
setTimeout(() => x.onload && x.onload(), 0)
|
||||
return x
|
||||
})
|
||||
await expect(
|
||||
uploadFile({ name: 'a.epub', size: 1 }, () => {}, { xhr: fakeXhr })
|
||||
).rejects.toThrow('too big')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user