| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- /**
- * 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);
- });
- });
|