R1 追加契约:学习端把新建与追加拆成两个请求体,追加不再发送 language(后端严格 解码会拒绝未知字段,此前真实追加返回 400);补充断言真实请求格式的回归测试。 R2 运行期恢复:启动恢复与运行期清扫合并为一处,worker 每秒把停留超过 15 秒的 processing 任务重新入队,超过 5 次尝试的任务置为 failed(attempts_exhausted); 人工重试重置尝试次数;日志如实区分“已入队”与“等待下一次清扫”。 R3 离页作废在途请求:closeBook/closeChapter 推进请求序号并清理 loading,导入页 在卸载后的成功响应不再触发跳转。 R4 重试自愈:重试被接受后先应用返回的 pending 状态并继续轮询,静默刷新失败不再 让页面停在处理失败。 测试:Go 20 个顶层用例通过(新增 2 项);学习端 38 项通过,其中 7 项在整改前代码 上实际失败;真实联调验证追加可用、被中断任务约 0.5 秒内在运行中自动恢复、重试在 首次刷新失败后自动显示最终结果。 文档:Wiki 先写后回读(架构、业务规则、本地验证),导出核心镜像。
446 lines
20 KiB
TypeScript
446 lines
20 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import {
|
|
NOT_FOUND_MESSAGE,
|
|
POLL_INTERVAL_MS,
|
|
TEXT_MAX_CODE_POINTS,
|
|
canRetry,
|
|
statusSummary,
|
|
useLibraryStore,
|
|
type ChapterDetail,
|
|
} from '../stores/library'
|
|
import { useSessionStore } from '../stores/session'
|
|
|
|
// All accounts, books and texts in these tests are deliberately fictitious.
|
|
const user = { id: 7, username: 'fictional-reader', role: 'learner' as const }
|
|
const book = { id: 1, title: '虚构样例书', language: 'en' }
|
|
const navigation = { previousChapterId: null, nextChapterId: null }
|
|
|
|
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 200 })
|
|
const created = (data: unknown) => new Response(JSON.stringify({ code: 200, data }), { status: 201 })
|
|
const httpError = (status: number, msg: string) => new Response(JSON.stringify({ code: status, msg }), { status })
|
|
|
|
const chapter = (overrides: Partial<ChapterDetail> = {}): ChapterDetail => ({
|
|
id: 55,
|
|
bookId: 1,
|
|
ordinal: 1,
|
|
title: '第一篇',
|
|
status: 'pending',
|
|
charCount: 120,
|
|
errorReason: '',
|
|
errorMessage: '',
|
|
contentSha256: 'fictional-sha256',
|
|
jobId: 7,
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
updatedAt: '2026-01-01T00:00:00Z',
|
|
...overrides,
|
|
})
|
|
|
|
const summary = (overrides: Partial<Record<string, number | string>> = {}) => ({
|
|
...book,
|
|
chapterCount: 0,
|
|
pendingCount: 0,
|
|
processingCount: 0,
|
|
readyCount: 0,
|
|
failedCount: 0,
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
updatedAt: '2026-01-01T00:00:00Z',
|
|
...overrides,
|
|
})
|
|
|
|
const job = { id: 7, bookId: 1, chapterId: 55, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
|
|
|
|
const fetchMock = () => vi.mocked(globalThis.fetch)
|
|
const paths = () => fetchMock().mock.calls.map(([input]) => String(input))
|
|
const bodyOf = (index: number): Record<string, unknown> => JSON.parse(String(fetchMock().mock.calls[index]?.[1]?.body)) as Record<string, unknown>
|
|
|
|
async function signIn() {
|
|
fetchMock().mockResolvedValueOnce(ok({ token: 'fictional-token', expiresAt: '2030-01-01', user }))
|
|
const session = useSessionStore()
|
|
await session.login(user.username, 'fictional-password')
|
|
return session
|
|
}
|
|
|
|
describe('learner library store', () => {
|
|
beforeEach(() => {
|
|
sessionStorage.clear()
|
|
setActivePinia(createPinia())
|
|
vi.restoreAllMocks()
|
|
// Any request a test did not expect fails loudly instead of hanging.
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(input => {
|
|
throw new Error(`unexpected request: ${String(input)}`)
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
// Never let a polling timer outlive its test.
|
|
useLibraryStore().stopPolling()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('loads the library and summarises the count fields the API reports', async () => {
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
fetchMock().mockResolvedValueOnce(ok({ items: [summary({ chapterCount: 5, readyCount: 2, processingCount: 1, pendingCount: 1, failedCount: 1 })] }))
|
|
|
|
await library.loadBooks()
|
|
|
|
expect(library.books).toHaveLength(1)
|
|
expect(library.booksLoading).toBe(false)
|
|
expect(library.booksError).toBe('')
|
|
expect(statusSummary(library.books[0]!)).toBe('已就绪 2 · 处理中 1 · 待处理 1 · 失败 1')
|
|
expect(paths()).toEqual(['/api/v1/login', '/api/v1/books'])
|
|
expect(fetchMock().mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: 'Bearer fictional-token' })
|
|
})
|
|
|
|
it('shows pending and processing separately instead of deriving one from a total', async () => {
|
|
// processingCount is strictly "processing" now, so pendingCount must be read as given.
|
|
const queued = summary({ chapterCount: 3, readyCount: 1, processingCount: 0, pendingCount: 2, failedCount: 0 })
|
|
expect(statusSummary(queued)).toBe('已就绪 1 · 待处理 2')
|
|
expect(statusSummary(queued)).not.toContain('处理中')
|
|
const done = summary({ chapterCount: 1, readyCount: 1 })
|
|
expect(statusSummary(done)).toBe('已就绪 1')
|
|
expect(statusSummary(summary({ chapterCount: 0 }))).toBe('')
|
|
})
|
|
|
|
it('loads a book detail whose chapters carry the job id used for retry', async () => {
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'failed', jobId: 7 }), chapter({ id: 56, status: 'failed', jobId: null })] }))
|
|
|
|
await library.loadBook(1)
|
|
|
|
expect(library.book?.title).toBe('虚构样例书')
|
|
expect(library.chapters).toHaveLength(2)
|
|
expect(paths()[1]).toBe('/api/v1/books/1')
|
|
// The chapter itself carries the job id, even for a freshly loaded book.
|
|
expect(library.chapters[0]?.jobId).toBe(7)
|
|
expect(canRetry(library.chapters[0]!)).toBe(true)
|
|
// A null job id means the chapter has nothing to retry yet.
|
|
expect(canRetry(library.chapters[1]!)).toBe(false)
|
|
})
|
|
|
|
it('retries a failed chapter loaded fresh from the book detail, without any submit in this session', async () => {
|
|
vi.useFakeTimers()
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
// No submit() call: this is a plain reload, the old workaround would hide retry here.
|
|
fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'failed', errorMessage: '无法解析正文。', jobId: 7 })] }))
|
|
await library.loadBook(1)
|
|
|
|
fetchMock()
|
|
.mockResolvedValueOnce(ok({ job: { ...job, status: 'pending', attempts: 1 }, chapter: { id: 55, bookId: 1, jobId: 7 } }))
|
|
.mockResolvedValueOnce(ok({ book, chapters: [chapter({ jobId: 7 })] }))
|
|
await library.retryChapter(55)
|
|
|
|
expect(paths()).toContain('/api/v1/jobs/7/retry')
|
|
expect(library.retryingChapterId).toBeNull()
|
|
expect(library.chapters[0]?.status).toBe('pending')
|
|
})
|
|
|
|
it('pastes a new book and reads the job id from the created chapter', async () => {
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
fetchMock().mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
|
|
|
|
const bookId = await library.submit({ title: ' 第一篇 ', text: 'Hello world.\nSecond line.', target: { mode: 'new' } })
|
|
|
|
expect(bookId).toBe(1)
|
|
expect(library.submitting).toBe(false)
|
|
expect(library.submitError).toBe('')
|
|
expect(paths()[1]).toBe('/api/v1/books')
|
|
expect(bodyOf(1)).toEqual({ requestId: expect.any(String), title: '第一篇', text: 'Hello world.\nSecond line.', language: 'en' })
|
|
})
|
|
|
|
it('appends to an existing book through the chapter endpoint', async () => {
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
fetchMock().mockResolvedValueOnce(created({ chapter: chapter(), job, duplicate: false }))
|
|
|
|
const bookId = await library.submit({ title: '第二篇', text: 'Another text.', target: { mode: 'append', bookId: 1 } })
|
|
|
|
expect(bookId).toBe(1)
|
|
expect(paths()[1]).toBe('/api/v1/books/1/chapters')
|
|
})
|
|
|
|
it('reuses one requestId while the same unsent content keeps failing', async () => {
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
const input = { title: '第一篇', text: 'Hello world.', target: { mode: 'new' as const } }
|
|
fetchMock()
|
|
.mockResolvedValueOnce(httpError(500, '服务器开小差了'))
|
|
.mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
|
|
|
|
await expect(library.submit(input)).rejects.toThrow('服务器开小差了')
|
|
expect(library.submitError).toBe('服务器开小差了')
|
|
await library.submit(input)
|
|
|
|
// One chapter, not two: the retry of unchanged content reuses the requestId.
|
|
expect(bodyOf(2).requestId).toBe(bodyOf(1).requestId)
|
|
})
|
|
|
|
it('uses a fresh requestId after a successful submit and after the content changes', async () => {
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
fetchMock()
|
|
.mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
|
|
.mockResolvedValueOnce(created({ book, chapter: chapter({ id: 56 }), job, duplicate: false }))
|
|
.mockResolvedValueOnce(created({ book, chapter: chapter({ id: 57 }), job, duplicate: false }))
|
|
|
|
await library.submit({ title: '第一篇', text: 'Hello world.', target: { mode: 'new' } })
|
|
await library.submit({ title: '第一篇', text: 'Hello world.', target: { mode: 'new' } })
|
|
await library.submit({ title: '第一篇', text: 'Hello world changed.', target: { mode: 'new' } })
|
|
|
|
expect(bodyOf(2).requestId).not.toBe(bodyOf(1).requestId)
|
|
expect(bodyOf(3).requestId).not.toBe(bodyOf(2).requestId)
|
|
})
|
|
|
|
it('rejects invalid input before sending anything', async () => {
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
const sent = paths().length
|
|
|
|
await expect(library.submit({ title: ' ', text: 'Hello.', target: { mode: 'new' } })).rejects.toThrow('请填写标题。')
|
|
await expect(library.submit({ title: '标题', text: ' \n\t ', target: { mode: 'new' } })).rejects.toThrow('请粘贴要导入的英文正文。')
|
|
await expect(library.submit({ title: 'x'.repeat(121), text: 'Hello.', target: { mode: 'new' } })).rejects.toThrow('标题不能超过 120 个字符。')
|
|
await expect(library.submit({ title: '标题', text: 'a'.repeat(TEXT_MAX_CODE_POINTS + 1), target: { mode: 'new' } })).rejects.toThrow(`正文不能超过 ${TEXT_MAX_CODE_POINTS} 个字符。`)
|
|
|
|
expect(paths().length).toBe(sent)
|
|
expect(library.submitError).toBe(`正文不能超过 ${TEXT_MAX_CODE_POINTS} 个字符。`)
|
|
})
|
|
|
|
it('measures the text limit in Unicode code points', async () => {
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
// 100000 astral characters are 200000 UTF-16 units but still within the limit.
|
|
fetchMock().mockResolvedValueOnce(created({ book, chapter: chapter(), job, duplicate: false }))
|
|
|
|
await expect(library.submit({ title: '标题', text: '😀'.repeat(TEXT_MAX_CODE_POINTS), target: { mode: 'new' } })).resolves.toBe(1)
|
|
expect([...('😀'.repeat(TEXT_MAX_CODE_POINTS))].length).toBe(TEXT_MAX_CODE_POINTS)
|
|
})
|
|
|
|
it('polls a pending chapter until it is ready and then stops', async () => {
|
|
vi.useFakeTimers()
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter(), navigation }))
|
|
|
|
await library.loadChapter(55)
|
|
expect(library.chapter?.status).toBe('pending')
|
|
expect(library.readerText).toBe('')
|
|
|
|
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Hello\nworld.' }), navigation }))
|
|
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
|
|
|
|
expect(library.chapter?.status).toBe('ready')
|
|
expect(library.readerText).toBe('Hello\nworld.')
|
|
|
|
const settled = paths().length
|
|
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3)
|
|
expect(paths().length).toBe(settled)
|
|
})
|
|
|
|
it('retries a failed chapter through the chapter job id and resumes polling', async () => {
|
|
vi.useFakeTimers()
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'failed', errorReason: 'decode_error', errorMessage: '无法解析正文。', jobId: 7 }), navigation }))
|
|
|
|
await library.loadChapter(55)
|
|
expect(library.chapter?.status).toBe('failed')
|
|
expect(canRetry(library.chapter!)).toBe(true)
|
|
|
|
fetchMock()
|
|
.mockResolvedValueOnce(ok({ job: { ...job, status: 'pending', attempts: 1 }, chapter: { id: 55, bookId: 1, jobId: 7 } }))
|
|
.mockResolvedValueOnce(ok({ book, chapter: chapter(), navigation }))
|
|
await library.retryChapter(55)
|
|
|
|
expect(paths()).toContain('/api/v1/jobs/7/retry')
|
|
expect(library.retryingChapterId).toBeNull()
|
|
expect(library.chapter?.status).toBe('pending')
|
|
|
|
// The retry restarts polling for the chapter it re-queued.
|
|
const before = paths().length
|
|
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Hello world.' }), navigation }))
|
|
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
|
|
expect(paths().length).toBe(before + 1)
|
|
expect(library.readerText).toBe('Hello world.')
|
|
})
|
|
|
|
it('refuses to retry a chapter whose job id is null', async () => {
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'failed', jobId: null }), navigation }))
|
|
await library.loadChapter(55)
|
|
|
|
expect(canRetry(library.chapter!)).toBe(false)
|
|
await expect(library.retryChapter(55)).rejects.toThrow('这一章暂时没有可重试的任务编号。')
|
|
expect(paths()).toEqual(['/api/v1/login', '/api/v1/chapters/55'])
|
|
})
|
|
|
|
it('reports another account id as 内容不存在 and stops polling for it', async () => {
|
|
vi.useFakeTimers()
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
fetchMock().mockResolvedValueOnce(httpError(404, 'chapter not found'))
|
|
|
|
await library.loadChapter(99)
|
|
expect(library.chapter).toBeNull()
|
|
expect(library.chapterError).toBe(NOT_FOUND_MESSAGE)
|
|
|
|
fetchMock().mockResolvedValueOnce(httpError(404, 'book not found'))
|
|
await library.loadBook(99)
|
|
expect(library.book).toBeNull()
|
|
expect(library.bookError).toBe(NOT_FOUND_MESSAGE)
|
|
|
|
const settled = paths().length
|
|
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
|
|
expect(paths().length).toBe(settled)
|
|
})
|
|
|
|
it('stops polling and ignores a late response once the session is cleared', async () => {
|
|
vi.useFakeTimers()
|
|
const session = await signIn()
|
|
const library = useLibraryStore()
|
|
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter(), navigation }))
|
|
await library.loadChapter(55)
|
|
expect(library.chapter).not.toBeNull()
|
|
|
|
let finish!: (response: Response) => void
|
|
fetchMock().mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
|
|
fetchMock().mockResolvedValueOnce(ok(null))
|
|
const late = library.loadChapter(55, { silent: true })
|
|
const logout = session.logout()
|
|
|
|
finish(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Late text.' }), navigation }))
|
|
await late
|
|
await logout
|
|
|
|
expect(session.user).toBeNull()
|
|
expect(library.chapter).toBeNull()
|
|
expect(library.readerText).toBe('')
|
|
|
|
const settled = paths().length
|
|
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
|
|
expect(paths().length).toBe(settled)
|
|
})
|
|
|
|
it('keeps newer state when an older request answers later', async () => {
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
let finish!: (response: Response) => void
|
|
fetchMock().mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
|
|
fetchMock().mockResolvedValueOnce(ok({ items: [summary({ title: '较新的标题' })] }))
|
|
|
|
const stale = library.loadBooks()
|
|
await library.loadBooks()
|
|
finish(ok({ items: [summary({ title: '过期的标题' })] }))
|
|
await stale
|
|
|
|
expect(library.books).toHaveLength(1)
|
|
expect(library.books[0]?.title).toBe('较新的标题')
|
|
})
|
|
|
|
// Regression R1: the append contract has no language field and the server rejects unknown
|
|
// fields, so a client that sent one could never append.
|
|
it('sends the language only when creating a book, never when appending', async () => {
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
fetchMock().mockResolvedValueOnce(created({ book, chapter: chapter({ id: 54 }) }))
|
|
await library.submit({ title: ' 新书 ', text: 'New book text.', target: { mode: 'new' } })
|
|
expect(bodyOf(1)).toEqual({ requestId: expect.any(String), title: '新书', text: 'New book text.', language: 'en' })
|
|
|
|
fetchMock().mockResolvedValueOnce(created({ chapter: chapter({ id: 55, ordinal: 2 }) }))
|
|
await library.submit({ title: '第二篇', text: 'Appended text.', target: { mode: 'append', bookId: 1 } })
|
|
expect(String(fetchMock().mock.calls[2]?.[0])).toBe('/api/v1/books/1/chapters')
|
|
expect(bodyOf(2)).toEqual({ requestId: expect.any(String), title: '第二篇', text: 'Appended text.' })
|
|
expect(bodyOf(2)).not.toHaveProperty('language')
|
|
})
|
|
|
|
// Regression R3: leaving a view must invalidate its in-flight request.
|
|
it('ignores a book response that arrives after the book view was closed', async () => {
|
|
vi.useFakeTimers()
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
let finish!: (response: Response) => void
|
|
fetchMock().mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
|
|
|
|
const pending = library.loadBook(1)
|
|
library.closeBook()
|
|
finish(ok({ book, chapters: [chapter()] }))
|
|
await pending
|
|
|
|
expect(library.book).toBeNull()
|
|
expect(library.chapters).toEqual([])
|
|
expect(library.bookLoading).toBe(false)
|
|
const settled = paths().length
|
|
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3)
|
|
expect(paths().length).toBe(settled)
|
|
})
|
|
|
|
it('ignores a chapter response that arrives after the reader was closed', async () => {
|
|
vi.useFakeTimers()
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
let finish!: (response: Response) => void
|
|
fetchMock().mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve }))
|
|
|
|
const pending = library.loadChapter(55)
|
|
library.closeChapter()
|
|
finish(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Late text.' }), navigation }))
|
|
await pending
|
|
|
|
expect(library.chapter).toBeNull()
|
|
expect(library.readerText).toBe('')
|
|
expect(library.chapterLoading).toBe(false)
|
|
const settled = paths().length
|
|
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3)
|
|
expect(paths().length).toBe(settled)
|
|
})
|
|
|
|
// Regression R4: an accepted retry must be visible and tracked even if the refresh fails.
|
|
it('keeps tracking a retried chapter when the first refresh fails', async () => {
|
|
vi.useFakeTimers()
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'failed', errorReason: 'content_changed', errorMessage: '内容在处理前发生变化。', jobId: 7 }), navigation }))
|
|
await library.loadChapter(55)
|
|
expect(library.chapter?.status).toBe('failed')
|
|
|
|
fetchMock()
|
|
.mockResolvedValueOnce(ok({ job: { ...job, status: 'pending' }, chapter: chapter({ status: 'pending' }) }))
|
|
.mockRejectedValueOnce(new Error('network down'))
|
|
await library.retryChapter(55)
|
|
|
|
expect(paths()).toContain('/api/v1/jobs/7/retry')
|
|
expect(library.chapter?.status).toBe('pending')
|
|
expect(library.readerText).toBe('')
|
|
|
|
// The next poll still tracks the queued chapter and shows the final result.
|
|
fetchMock().mockResolvedValueOnce(ok({ book, chapter: chapter({ status: 'ready', originalText: 'Recovered text.' }), navigation }))
|
|
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
|
|
expect(library.chapter?.status).toBe('ready')
|
|
expect(library.readerText).toBe('Recovered text.')
|
|
})
|
|
|
|
it('keeps tracking a retried chapter from the book page when the first refresh fails', async () => {
|
|
vi.useFakeTimers()
|
|
await signIn()
|
|
const library = useLibraryStore()
|
|
fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'failed', errorReason: 'content_changed', errorMessage: '内容在处理前发生变化。' })] }))
|
|
await library.loadBook(1)
|
|
expect(library.chapters[0]?.status).toBe('failed')
|
|
|
|
fetchMock()
|
|
.mockResolvedValueOnce(ok({ job: { ...job, status: 'pending' }, chapter: chapter({ status: 'pending' }) }))
|
|
.mockRejectedValueOnce(new Error('network down'))
|
|
await library.retryChapter(55)
|
|
|
|
expect(library.chapters[0]?.status).toBe('pending')
|
|
|
|
fetchMock().mockResolvedValueOnce(ok({ book, chapters: [chapter({ status: 'ready' })] }))
|
|
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
|
|
expect(library.chapters[0]?.status).toBe('ready')
|
|
})
|
|
})
|