/** * on-demand 章节 TTS 单元测试 * * 覆盖: * 1. content 第一段 ≥50 字 → 取第一段 * 2. content 第一段 <50 字 → 取前 500 字 * 3. content < 5 字 → 抛 CHAPTER_CONTENT_EMPTY * 4. 标题前缀拼接 * 5. prefix + body 整体超 500 → 截断到 500 * 6. 二次校验:fresh.audioSource='full' → 直接返回,不调 TTS * 7. 写回 updateMany 调用参数正确(条件 audioSource=null) * 8. 写回失败不影响响应返回 * 9. synthesizeSync 抛异常 → 外层捕获抛给 controller */ import { describe, it, expect, vi, beforeEach } from 'vitest'; // === 用 vi.hoisted 把 mock 引用提升到顶部,让 vi.mock 工厂能拿到 === const mocks = vi.hoisted(() => ({ findUnique: vi.fn(), updateMany: vi.fn(), stripMarkdown: vi.fn((t: string) => (t == null ? '' : t)), synthesizeSync: vi.fn(), })); vi.mock('@/models', () => ({ prisma: { bookChapter: { findUnique: mocks.findUnique, updateMany: mocks.updateMany, }, }, })); vi.mock('@/modules/tts/tts.service', () => ({ stripMarkdown: mocks.stripMarkdown, synthesizeSync: mocks.synthesizeSync, })); import { onDemandChapterTts } from '@/modules/book-generator/chapter-tts'; describe('onDemandChapterTts', () => { beforeEach(() => { // 注意:不能用 vi.clearAllMocks(),会清掉 mock implementation mocks.findUnique.mockReset().mockResolvedValue(null); mocks.updateMany.mockReset().mockResolvedValue({ count: 1 }); mocks.synthesizeSync.mockReset().mockResolvedValue({ audioId: 'aid-1', audioUrl: 'https://oss.example.com/a.mp3', duration: 30, }); // mockReset 会清掉默认 implementation,需要重新设置 mocks.stripMarkdown.mockReset().mockImplementation((t: string) => (t == null ? '' : t)); }); it('第一段 ≥50 字 → 取第一段', async () => { const content = '第一段很长很长的内容。' + '啊'.repeat(60) + '\n\n第二段内容'; const result = await onDemandChapterTts({ id: 1, content, title: '标题' }); expect(mocks.synthesizeSync).toHaveBeenCalledTimes(1); const calledText = mocks.synthesizeSync.mock.calls[0][0]; expect(calledText.startsWith('标题。第一段很长很长的内容')).toBe(true); expect(calledText.length).toBeLessThanOrEqual(500); expect(result.audioUrl).toContain('https://'); }); it('第一段 <50 字 → 取前 500 字(含第二段)', async () => { const content = '短段\n\n' + '中'.repeat(200); await onDemandChapterTts({ id: 1, content, title: 'T' }); const calledText = mocks.synthesizeSync.mock.calls[0][0]; expect(calledText.includes('短段')).toBe(true); expect(calledText.includes('中')).toBe(true); }); it('content < 5 字 → 抛 CHAPTER_CONTENT_EMPTY', async () => { await expect(onDemandChapterTts({ id: 1, content: '短', title: '' })).rejects.toThrow('CHAPTER_CONTENT_EMPTY'); await expect(onDemandChapterTts({ id: 1, content: '', title: '' })).rejects.toThrow('CHAPTER_CONTENT_EMPTY'); expect(mocks.synthesizeSync).not.toHaveBeenCalled(); }); it('标题前缀拼接正确', async () => { await onDemandChapterTts({ id: 1, content: '内容内容内容内容内容内容', title: '第一回' }); const calledText = mocks.synthesizeSync.mock.calls[0][0]; expect(calledText.startsWith('第一回。')).toBe(true); }); it('prefix + body 整体超 500 → 截断到 500', async () => { const longTitle = 'X'.repeat(100); const content = 'A'.repeat(600); await onDemandChapterTts({ id: 1, content, title: longTitle }); const calledText = mocks.synthesizeSync.mock.calls[0][0]; expect(calledText.length).toBe(500); }); it('二次校验:fresh.audioSource=full → 直接返回,不调 TTS', async () => { mocks.findUnique.mockResolvedValue({ id: 1, audioUrl: 'https://oss/full.mp3', audioSource: 'full', audioDuration: 999, }); const result = await onDemandChapterTts({ id: 1, content: '随便', title: 'T' }); expect(mocks.synthesizeSync).not.toHaveBeenCalled(); expect(result.audioUrl).toBe('https://oss/full.mp3'); expect(result.duration).toBe(999); }); it('写回调用 updateMany,条件 audioSource=null', async () => { await onDemandChapterTts({ id: 42, content: '内容内容内容内容内容内容内容内容', title: 'T' }); expect(mocks.updateMany).toHaveBeenCalledWith({ where: { id: 42, audioSource: null }, data: expect.objectContaining({ audioSource: 'on_demand', audioUrl: expect.any(String), audioDuration: expect.any(Number), }), }); }); it('写回失败不影响响应返回(fire-and-forget)', async () => { mocks.updateMany.mockRejectedValue(new Error('write fail')); const result = await onDemandChapterTts({ id: 1, content: '内容内容内容内容内容内容', title: 'T' }); expect(result.audioUrl).toBeDefined(); }); it('synthesizeSync 抛异常 → 外层向上抛', async () => { mocks.synthesizeSync.mockRejectedValue(new Error('所有 Provider 都已尝试')); await expect( onDemandChapterTts({ id: 1, content: '内容内容内容内容内容内容', title: 'T' }), ).rejects.toThrow('所有 Provider'); }); it('自定义 voiceId 与 speed 透传', async () => { await onDemandChapterTts( { id: 1, content: '内容内容内容内容内容内容', title: 'T' }, { voiceId: 'male-yujie', speed: 1.5 }, ); expect(mocks.synthesizeSync).toHaveBeenCalledWith(expect.any(String), 'male-yujie', { speed: 1.5 }); }); it('audioUrl 是 /uploads/ 路径时二次校验不通过,继续走 TTS', async () => { mocks.findUnique.mockResolvedValue({ id: 1, audioUrl: '/uploads/audio/abc.mp3', audioSource: 'full', audioDuration: 999, }); const result = await onDemandChapterTts({ id: 1, content: '内容内容内容内容内容内容', title: 'T' }); expect(mocks.synthesizeSync).toHaveBeenCalled(); expect(result.audioUrl).toBe('https://oss.example.com/a.mp3'); }); });