import { BadRequestException } from '@nestjs/common'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Asset, CopyrightRecord, NovelChapter, NovelSource, Project } from '@prisma/client'; import type { AuthRequestUser } from '../auth/auth.types'; import type { PrismaService } from '../prisma/prisma.service'; import type { StorageService } from '../assets/storage.service'; import type { NovelParserService, ParsedNovelText } from './novel-parser.service'; import { NovelsService } from './novels.service'; const user: AuthRequestUser = { id: '1', email: 'user@example.com', role: 'user' }; function createProject(overrides: Partial = {}): Project { return { id: 10n, user_id: 1n, title: '上传小说项目', input_mode: 'upload', genre: 'urban_rebirth', style_code: 'korean_comic', output_type: 'short_video', output_mode: 'image_manga', visual_mode: 'korean_manga', video_generation_level: 'standard', target_episode_count: 1, episode_duration: 60, status: 'source_selecting', copyright_status: 'confirmed', payment_status: 'unpaid', quality_level: 'mvp', is_long_series: false, created_at: new Date('2026-05-31T00:00:00.000Z'), updated_at: new Date('2026-05-31T00:00:00.000Z'), completed_at: null, ...overrides }; } function createSource(overrides: Partial = {}): NovelSource { return { id: 20n, project_id: 10n, source_type: 'paste', title: '上传小说项目', author_name: null, raw_asset_id: null, raw_text: '第1章 重生\n她醒来后开始反击。', clean_text: null, word_count: 12, chapter_count: null, parse_status: 'pending', parse_report: null, created_at: new Date('2026-05-31T00:00:00.000Z'), ...overrides }; } function createChapter(overrides: Partial = {}): NovelChapter { return { id: 30n, project_id: 10n, novel_source_id: 20n, volume_no: null, volume_title: null, chapter_no: 1, title: '第1章 重生', content: '她醒来后开始反击。', summary: '她醒来后开始反击。', visual_summary: '她醒来后开始反击。', word_count: 9, status: 'parsed', created_at: new Date('2026-05-31T00:00:00.000Z'), ...overrides }; } function createRecord(overrides: Partial = {}): CopyrightRecord { return { id: 40n, project_id: 10n, user_id: 1n, authorization_type: 'author_self', statement_text: '我确认拥有该小说的合法改编权。', ip: '127.0.0.1', user_agent: 'vitest', confirmed_at: new Date('2026-05-31T00:00:00.000Z'), ...overrides }; } function createAsset(overrides: Partial = {}): Asset { return { id: 50n, user_id: 1n, project_id: 10n, asset_type: 'novel_text', file_path: 'local://novels/test.txt', file_url: null, mime_type: 'text/plain', width: null, height: null, duration: null, size: 100n, hash: 'hash', visibility: 'private', status: 'active', created_at: new Date('2026-05-31T00:00:00.000Z'), ...overrides }; } const parsedText: ParsedNovelText = { clean_text: '第1章 重生\n她醒来后开始反击。', word_count: 9, chapter_count: 1, chapters: [ { volume_no: null, volume_title: null, chapter_no: 1, title: '第1章 重生', content: '她醒来后开始反击。', summary: '她醒来后开始反击。', visual_summary: '她醒来后开始反击。', word_count: 9 } ], parse_report: { strategy: 'heading', removed_line_count: 0, warnings: [] } }; describe('NovelsService', () => { let prisma: { project: { findUnique: ReturnType; update: ReturnType }; copyrightRecord: { create: ReturnType; findMany: ReturnType; count: ReturnType; }; novelSource: { create: ReturnType; findUnique: ReturnType; findFirst: ReturnType; update: ReturnType; }; novelChapter: { findUnique: ReturnType; findMany: ReturnType; update: ReturnType; }; asset: { findUnique: ReturnType }; $transaction: ReturnType; }; let tx: { project: { update: ReturnType }; novelSource: { create: ReturnType; update: ReturnType }; novelChapter: { deleteMany: ReturnType; createMany: ReturnType; findMany: ReturnType; }; }; let storage: Pick; let parser: Pick; let service: NovelsService; beforeEach(() => { tx = { project: { update: vi.fn().mockResolvedValue(createProject({ status: 'novel_uploaded' })) }, novelSource: { create: vi.fn().mockResolvedValue(createSource({ parse_status: 'parsed' })), update: vi.fn().mockResolvedValue(createSource({ parse_status: 'parsed' })) }, novelChapter: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }), createMany: vi.fn().mockResolvedValue({ count: 1 }), findMany: vi.fn().mockResolvedValue([createChapter()]) } }; prisma = { project: { findUnique: vi.fn().mockResolvedValue(createProject()), update: vi.fn().mockResolvedValue(createProject()) }, copyrightRecord: { create: vi.fn().mockResolvedValue(createRecord()), findMany: vi.fn().mockResolvedValue([createRecord()]), count: vi.fn().mockResolvedValue(1) }, novelSource: { create: vi.fn().mockResolvedValue(createSource()), findUnique: vi.fn().mockResolvedValue(createSource()), findFirst: vi.fn().mockResolvedValue(createSource()), update: vi.fn() }, novelChapter: { findUnique: vi.fn().mockResolvedValue(createChapter()), findMany: vi.fn().mockResolvedValue([createChapter()]), update: vi.fn().mockResolvedValue(createChapter({ status: 'edited' })) }, asset: { findUnique: vi.fn().mockResolvedValue(createAsset()) }, $transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx)) }; storage = { readPrivateFile: vi.fn().mockResolvedValue(Buffer.from('第1章 重生\n她醒来后开始反击。')) }; parser = { countWords: vi.fn((text: string) => text.length), extractText: vi.fn().mockResolvedValue({ text: '第1章 重生\n她醒来后开始反击。', extractor: 'plain_text', warnings: [] }), parseText: vi.fn().mockReturnValue(parsedText) }; service = new NovelsService( prisma as unknown as PrismaService, storage as StorageService, parser as NovelParserService ); }); it('confirms copyright and updates the project', async () => { const result = await service.confirmCopyright( user, '10', { authorization_type: 'author_self', statement_text: '我确认拥有该小说的合法改编权。' }, { ip: '127.0.0.1', headers: { 'user-agent': 'vitest' } } ); expect(prisma.copyrightRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ project_id: 10n, authorization_type: 'author_self' }) }); expect(prisma.project.update).toHaveBeenCalledWith({ where: { id: 10n }, data: expect.objectContaining({ copyright_status: 'confirmed', status: 'copyright_confirmed' }) }); expect(result.next_step).toBe('novel_parse'); }); it('parses a pasted source into chapters', async () => { const result = await service.parseNovel(user, '10', { source_id: '20' }); expect(parser.parseText).toHaveBeenCalledWith( '第1章 重生\n她醒来后开始反击。', [] ); expect(tx.novelChapter.createMany).toHaveBeenCalledWith({ data: [ expect.objectContaining({ project_id: 10n, novel_source_id: 20n, chapter_no: 1, status: 'parsed' }) ] }); expect(tx.project.update).toHaveBeenCalledWith({ where: { id: 10n }, data: { status: 'novel_uploaded' } }); expect(result.chapters).toHaveLength(1); }); it('parses an uploaded asset into a new source', async () => { const result = await service.parseNovel(user, '10', { asset_id: '50' }); expect(storage.readPrivateFile).toHaveBeenCalledWith('local://novels/test.txt'); expect(parser.extractText).toHaveBeenCalled(); expect(tx.novelSource.create).toHaveBeenCalledWith({ data: expect.objectContaining({ raw_asset_id: 50n, parse_status: 'parsed' }) }); expect(result.source.raw_asset_id).toBeNull(); }); it('rejects parsing before copyright is confirmed', async () => { prisma.project.findUnique.mockResolvedValue( createProject({ copyright_status: 'pending' }) ); prisma.copyrightRecord.count.mockResolvedValue(0); await expect(service.parseNovel(user, '10', { source_id: '20' })).rejects.toBeInstanceOf( BadRequestException ); }); it('updates an owned chapter manually', async () => { const result = await service.updateChapter(user, '30', { content: '她拿出证据,完成第一场反击。' }); expect(prisma.novelChapter.update).toHaveBeenCalledWith({ where: { id: 30n }, data: expect.objectContaining({ content: '她拿出证据,完成第一场反击。', status: 'edited' }) }); expect(result.status).toBe('edited'); }); });