chapter-read.test.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. /**
  2. * chapter-read 端点单元测试
  3. *
  4. * 通过直接调用 handler(不启 HTTP server)覆盖:
  5. * - format 三态语义
  6. * - 有/无合法音频分支
  7. * - text 模式分页
  8. * - 非法 format / 非法 chapter id
  9. * - 鉴权失败(assertBookAccess=false)→ 404
  10. * - on-demand TTS 失败 → 502
  11. * - /uploads/ 路径视为无效
  12. */
  13. import { describe, it, expect, vi, beforeEach } from 'vitest';
  14. import type { Context } from 'koa';
  15. // === 用 vi.hoisted 把 mock 引用提升到顶部 ===
  16. const mocks = vi.hoisted(() => ({
  17. findUnique: vi.fn(),
  18. assertBookAccess: vi.fn(),
  19. getChapterNav: vi.fn(),
  20. onDemandChapterTts: vi.fn(),
  21. stripMarkdown: vi.fn((t: string) => (t == null ? '' : t)),
  22. }));
  23. // mock prisma
  24. vi.mock('@/models', () => ({
  25. prisma: {
  26. bookChapter: {
  27. findUnique: mocks.findUnique,
  28. },
  29. },
  30. }));
  31. // mock middleware/auth (避免依赖真实 jwt)
  32. vi.mock('@/middleware/auth', () => ({
  33. optionalAuth: (_ctx: any, next: any) => next(),
  34. requireAuth: (_ctx: any, next: any) => next(),
  35. }));
  36. // mock access-control
  37. vi.mock('@/modules/book-generator/access-control', () => ({
  38. assertBookAccess: mocks.assertBookAccess,
  39. }));
  40. // mock chapter-nav
  41. vi.mock('@/modules/book-generator/chapter-nav', () => ({
  42. getChapterNav: mocks.getChapterNav,
  43. }));
  44. // mock chapter-tts (on-demand helper)
  45. vi.mock('@/modules/book-generator/chapter-tts', () => ({
  46. onDemandChapterTts: mocks.onDemandChapterTts,
  47. }));
  48. // mock tts.service (stripMarkdown)
  49. vi.mock('@/modules/tts/tts.service', () => ({
  50. stripMarkdown: mocks.stripMarkdown,
  51. }));
  52. import { prisma } from '@/models';
  53. import { assertBookAccess } from '@/modules/book-generator/access-control';
  54. import { getChapterNav } from '@/modules/book-generator/chapter-nav';
  55. import { onDemandChapterTts } from '@/modules/book-generator/chapter-tts';
  56. import { readChapter } from '@/modules/book-generator/chapter-read.controller';
  57. /** 构造最小 Koa-like ctx */
  58. function makeCtx(params: any = {}, query: any = {}, user?: any): Context {
  59. return {
  60. params,
  61. query,
  62. state: { user: user ?? { userId: '1' } },
  63. status: 200,
  64. body: undefined as any,
  65. } as unknown as Context;
  66. }
  67. const baseChapter = {
  68. id: 100,
  69. bookId: 1,
  70. title: '第一回',
  71. content: '# 标题\n\n这是第一段内容。' + '啊'.repeat(60) + '\n\n第二段开始',
  72. audioUrl: null as string | null,
  73. audioDuration: 0,
  74. audioSource: null as string | null,
  75. wordCount: 100,
  76. };
  77. const navMock = {
  78. chapter_index: 1,
  79. total_chapters: 3,
  80. prev_chapter_id: null,
  81. next_chapter_id: 101,
  82. };
  83. describe('readChapter handler', () => {
  84. beforeEach(() => {
  85. mocks.findUnique.mockReset().mockResolvedValue({ ...baseChapter });
  86. mocks.assertBookAccess.mockReset().mockResolvedValue(true);
  87. mocks.getChapterNav.mockReset().mockResolvedValue(navMock);
  88. mocks.onDemandChapterTts.mockReset().mockResolvedValue({
  89. audioUrl: 'https://oss.example.com/sync.mp3',
  90. duration: 30,
  91. });
  92. mocks.stripMarkdown.mockReset().mockImplementation((t: string) => (t == null ? '' : t));
  93. });
  94. it('format=text:返回 stripMarkdown 后的文本', async () => {
  95. const ctx = makeCtx({ id: '100' }, { format: 'text', max_chars: '50' });
  96. await readChapter(ctx);
  97. expect(ctx.status).toBe(200);
  98. expect(ctx.body.code).toBe(0);
  99. expect(ctx.body.data.mode).toBe('text');
  100. expect(ctx.body.data.text.length).toBe(50);
  101. expect(ctx.body.data.audio_url).toBeUndefined();
  102. expect(onDemandChapterTts).not.toHaveBeenCalled();
  103. });
  104. it('format=text + offset:返回分页后段', async () => {
  105. const ctx = makeCtx({ id: '100' }, { format: 'text', offset: '10', max_chars: '5' });
  106. await readChapter(ctx);
  107. expect(ctx.body.data.text).toBe(ctx.body.data.text.slice(0, 5));
  108. expect(ctx.body.data.has_more).toBe(true);
  109. expect(ctx.body.data.next_offset).toBe(15);
  110. });
  111. it('format=text + max_chars 超 4000 → 截断到 4000', async () => {
  112. const longContent = 'A'.repeat(10000);
  113. (prisma.bookChapter.findUnique as any).mockResolvedValue({ ...baseChapter, content: longContent });
  114. const ctx = makeCtx({ id: '100' }, { format: 'text', max_chars: '99999' });
  115. await readChapter(ctx);
  116. expect(ctx.body.data.text.length).toBe(4000);
  117. });
  118. it('format=auto + 有合法音频 → mode=audio', async () => {
  119. (prisma.bookChapter.findUnique as any).mockResolvedValue({
  120. ...baseChapter, audioUrl: 'https://oss.example.com/full.mp3', audioSource: 'full', audioDuration: 300,
  121. });
  122. const ctx = makeCtx({ id: '100' }, { format: 'auto' });
  123. await readChapter(ctx);
  124. expect(ctx.body.data.mode).toBe('audio');
  125. expect(ctx.body.data.audio_url).toBe('https://oss.example.com/full.mp3');
  126. expect(ctx.body.data.audio_source).toBe('full');
  127. expect(onDemandChapterTts).not.toHaveBeenCalled();
  128. });
  129. it('format=auto + 无音频 → mode=text(不调 on-demand)', async () => {
  130. const ctx = makeCtx({ id: '100' }, { format: 'auto' });
  131. await readChapter(ctx);
  132. expect(ctx.body.data.mode).toBe('text');
  133. expect(onDemandChapterTts).not.toHaveBeenCalled();
  134. });
  135. it('format=audio + 无音频 → 调 on-demand TTS', async () => {
  136. const ctx = makeCtx({ id: '100' }, { format: 'audio' });
  137. await readChapter(ctx);
  138. expect(ctx.body.data.mode).toBe('audio');
  139. expect(ctx.body.data.audio_url).toBe('https://oss.example.com/sync.mp3');
  140. expect(ctx.body.data.audio_source).toBe('on_demand');
  141. expect(onDemandChapterTts).toHaveBeenCalledWith(baseChapter, expect.any(Object));
  142. });
  143. it('format=audio + /uploads/ 路径视为无效,走 on-demand', async () => {
  144. (prisma.bookChapter.findUnique as any).mockResolvedValue({
  145. ...baseChapter, audioUrl: '/uploads/audio/abc.mp3',
  146. });
  147. const ctx = makeCtx({ id: '100' }, { format: 'audio' });
  148. await readChapter(ctx);
  149. expect(ctx.body.data.mode).toBe('audio');
  150. expect(ctx.body.data.audio_source).toBe('on_demand');
  151. });
  152. it('format=audio + on-demand TTS 抛错 → 502 + code=2002', async () => {
  153. (onDemandChapterTts as any).mockRejectedValue(new Error('Provider 全失败'));
  154. const ctx = makeCtx({ id: '100' }, { format: 'audio' });
  155. await readChapter(ctx);
  156. expect(ctx.status).toBe(502);
  157. expect(ctx.body.code).toBe(2002);
  158. expect(ctx.body.message).toContain('Provider 全失败');
  159. });
  160. it('format=audio + voice_id/speed 透传给 on-demand', async () => {
  161. const ctx = makeCtx({ id: '100' }, { format: 'audio', voice_id: 'male-yujie', speed: '1.5' });
  162. await readChapter(ctx);
  163. expect(onDemandChapterTts).toHaveBeenCalledWith(
  164. expect.any(Object),
  165. expect.objectContaining({ voiceId: 'male-yujie', speed: 1.5 }),
  166. );
  167. });
  168. it('非法 format → 400 + code=2004', async () => {
  169. const ctx = makeCtx({ id: '100' }, { format: 'xml' });
  170. await readChapter(ctx);
  171. expect(ctx.status).toBe(400);
  172. expect(ctx.body.code).toBe(2004);
  173. });
  174. it('非法 chapter id(非数字)→ 400', async () => {
  175. const ctx = makeCtx({ id: 'abc' }, {});
  176. await readChapter(ctx);
  177. expect(ctx.status).toBe(400);
  178. });
  179. it('章节不存在 → 404 + code=2001', async () => {
  180. (prisma.bookChapter.findUnique as any).mockResolvedValue(null);
  181. const ctx = makeCtx({ id: '999' }, {});
  182. await readChapter(ctx);
  183. expect(ctx.status).toBe(404);
  184. expect(ctx.body.code).toBe(2001);
  185. });
  186. it('无权限访问章节 → 404(不暴露存在性)', async () => {
  187. (assertBookAccess as any).mockResolvedValue(false);
  188. const ctx = makeCtx({ id: '100' }, {});
  189. await readChapter(ctx);
  190. expect(ctx.status).toBe(404);
  191. expect(ctx.body.code).toBe(2001);
  192. });
  193. it('未登录用户:userId 走 TEST_USER_ID 兜底', async () => {
  194. const ctx = makeCtx({ id: '100' }, { format: 'text' });
  195. ctx.state.user = undefined;
  196. await readChapter(ctx);
  197. expect(ctx.status).toBe(200);
  198. expect(assertBookAccess).toHaveBeenCalledWith(expect.any(Number), '1', 'read');
  199. });
  200. it('响应里包含 nav 信息', async () => {
  201. const ctx = makeCtx({ id: '100' }, { format: 'text' });
  202. await readChapter(ctx);
  203. expect(ctx.body.data.nav).toEqual(navMock);
  204. });
  205. });