/** * chapter-read 端点单元测试 * * 通过直接调用 handler(不启 HTTP server)覆盖: * - format 三态语义 * - 有/无合法音频分支 * - text 模式分页 * - 非法 format / 非法 chapter id * - 鉴权失败(assertBookAccess=false)→ 404 * - on-demand TTS 失败 → 502 * - /uploads/ 路径视为无效 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Context } from 'koa'; // === 用 vi.hoisted 把 mock 引用提升到顶部 === const mocks = vi.hoisted(() => ({ findUnique: vi.fn(), assertBookAccess: vi.fn(), getChapterNav: vi.fn(), onDemandChapterTts: vi.fn(), stripMarkdown: vi.fn((t: string) => (t == null ? '' : t)), })); // mock prisma vi.mock('@/models', () => ({ prisma: { bookChapter: { findUnique: mocks.findUnique, }, }, })); // mock middleware/auth (避免依赖真实 jwt) vi.mock('@/middleware/auth', () => ({ optionalAuth: (_ctx: any, next: any) => next(), requireAuth: (_ctx: any, next: any) => next(), })); // mock access-control vi.mock('@/modules/book-generator/access-control', () => ({ assertBookAccess: mocks.assertBookAccess, })); // mock chapter-nav vi.mock('@/modules/book-generator/chapter-nav', () => ({ getChapterNav: mocks.getChapterNav, })); // mock chapter-tts (on-demand helper) vi.mock('@/modules/book-generator/chapter-tts', () => ({ onDemandChapterTts: mocks.onDemandChapterTts, })); // mock tts.service (stripMarkdown) vi.mock('@/modules/tts/tts.service', () => ({ stripMarkdown: mocks.stripMarkdown, })); import { prisma } from '@/models'; import { assertBookAccess } from '@/modules/book-generator/access-control'; import { getChapterNav } from '@/modules/book-generator/chapter-nav'; import { onDemandChapterTts } from '@/modules/book-generator/chapter-tts'; import { readChapter } from '@/modules/book-generator/chapter-read.controller'; /** 构造最小 Koa-like ctx */ function makeCtx(params: any = {}, query: any = {}, user?: any): Context { return { params, query, state: { user: user ?? { userId: '1' } }, status: 200, body: undefined as any, } as unknown as Context; } const baseChapter = { id: 100, bookId: 1, title: '第一回', content: '# 标题\n\n这是第一段内容。' + '啊'.repeat(60) + '\n\n第二段开始', audioUrl: null as string | null, audioDuration: 0, audioSource: null as string | null, wordCount: 100, }; const navMock = { chapter_index: 1, total_chapters: 3, prev_chapter_id: null, next_chapter_id: 101, }; describe('readChapter handler', () => { beforeEach(() => { mocks.findUnique.mockReset().mockResolvedValue({ ...baseChapter }); mocks.assertBookAccess.mockReset().mockResolvedValue(true); mocks.getChapterNav.mockReset().mockResolvedValue(navMock); mocks.onDemandChapterTts.mockReset().mockResolvedValue({ audioUrl: 'https://oss.example.com/sync.mp3', duration: 30, }); mocks.stripMarkdown.mockReset().mockImplementation((t: string) => (t == null ? '' : t)); }); it('format=text:返回 stripMarkdown 后的文本', async () => { const ctx = makeCtx({ id: '100' }, { format: 'text', max_chars: '50' }); await readChapter(ctx); expect(ctx.status).toBe(200); expect(ctx.body.code).toBe(0); expect(ctx.body.data.mode).toBe('text'); expect(ctx.body.data.text.length).toBe(50); expect(ctx.body.data.audio_url).toBeUndefined(); expect(onDemandChapterTts).not.toHaveBeenCalled(); }); it('format=text + offset:返回分页后段', async () => { const ctx = makeCtx({ id: '100' }, { format: 'text', offset: '10', max_chars: '5' }); await readChapter(ctx); expect(ctx.body.data.text).toBe(ctx.body.data.text.slice(0, 5)); expect(ctx.body.data.has_more).toBe(true); expect(ctx.body.data.next_offset).toBe(15); }); it('format=text + max_chars 超 4000 → 截断到 4000', async () => { const longContent = 'A'.repeat(10000); (prisma.bookChapter.findUnique as any).mockResolvedValue({ ...baseChapter, content: longContent }); const ctx = makeCtx({ id: '100' }, { format: 'text', max_chars: '99999' }); await readChapter(ctx); expect(ctx.body.data.text.length).toBe(4000); }); it('format=auto + 有合法音频 → mode=audio', async () => { (prisma.bookChapter.findUnique as any).mockResolvedValue({ ...baseChapter, audioUrl: 'https://oss.example.com/full.mp3', audioSource: 'full', audioDuration: 300, }); const ctx = makeCtx({ id: '100' }, { format: 'auto' }); await readChapter(ctx); expect(ctx.body.data.mode).toBe('audio'); expect(ctx.body.data.audio_url).toBe('https://oss.example.com/full.mp3'); expect(ctx.body.data.audio_source).toBe('full'); expect(onDemandChapterTts).not.toHaveBeenCalled(); }); it('format=auto + 无音频 → mode=text(不调 on-demand)', async () => { const ctx = makeCtx({ id: '100' }, { format: 'auto' }); await readChapter(ctx); expect(ctx.body.data.mode).toBe('text'); expect(onDemandChapterTts).not.toHaveBeenCalled(); }); it('format=audio + 无音频 → 调 on-demand TTS', async () => { const ctx = makeCtx({ id: '100' }, { format: 'audio' }); await readChapter(ctx); expect(ctx.body.data.mode).toBe('audio'); expect(ctx.body.data.audio_url).toBe('https://oss.example.com/sync.mp3'); expect(ctx.body.data.audio_source).toBe('on_demand'); expect(onDemandChapterTts).toHaveBeenCalledWith(baseChapter, expect.any(Object)); }); it('format=audio + /uploads/ 路径视为无效,走 on-demand', async () => { (prisma.bookChapter.findUnique as any).mockResolvedValue({ ...baseChapter, audioUrl: '/uploads/audio/abc.mp3', }); const ctx = makeCtx({ id: '100' }, { format: 'audio' }); await readChapter(ctx); expect(ctx.body.data.mode).toBe('audio'); expect(ctx.body.data.audio_source).toBe('on_demand'); }); it('format=audio + on-demand TTS 抛错 → 502 + code=2002', async () => { (onDemandChapterTts as any).mockRejectedValue(new Error('Provider 全失败')); const ctx = makeCtx({ id: '100' }, { format: 'audio' }); await readChapter(ctx); expect(ctx.status).toBe(502); expect(ctx.body.code).toBe(2002); expect(ctx.body.message).toContain('Provider 全失败'); }); it('format=audio + voice_id/speed 透传给 on-demand', async () => { const ctx = makeCtx({ id: '100' }, { format: 'audio', voice_id: 'male-yujie', speed: '1.5' }); await readChapter(ctx); expect(onDemandChapterTts).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ voiceId: 'male-yujie', speed: 1.5 }), ); }); it('非法 format → 400 + code=2004', async () => { const ctx = makeCtx({ id: '100' }, { format: 'xml' }); await readChapter(ctx); expect(ctx.status).toBe(400); expect(ctx.body.code).toBe(2004); }); it('非法 chapter id(非数字)→ 400', async () => { const ctx = makeCtx({ id: 'abc' }, {}); await readChapter(ctx); expect(ctx.status).toBe(400); }); it('章节不存在 → 404 + code=2001', async () => { (prisma.bookChapter.findUnique as any).mockResolvedValue(null); const ctx = makeCtx({ id: '999' }, {}); await readChapter(ctx); expect(ctx.status).toBe(404); expect(ctx.body.code).toBe(2001); }); it('无权限访问章节 → 404(不暴露存在性)', async () => { (assertBookAccess as any).mockResolvedValue(false); const ctx = makeCtx({ id: '100' }, {}); await readChapter(ctx); expect(ctx.status).toBe(404); expect(ctx.body.code).toBe(2001); }); it('未登录用户:userId 走 TEST_USER_ID 兜底', async () => { const ctx = makeCtx({ id: '100' }, { format: 'text' }); ctx.state.user = undefined; await readChapter(ctx); expect(ctx.status).toBe(200); expect(assertBookAccess).toHaveBeenCalledWith(expect.any(Number), '1', 'read'); }); it('响应里包含 nav 信息', async () => { const ctx = makeCtx({ id: '100' }, { format: 'text' }); await readChapter(ctx); expect(ctx.body.data.nav).toEqual(navMock); }); });