Files
ai/backend/src/novels/novels.service.spec.ts
T

322 lines
9.7 KiB
TypeScript

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> = {}): 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> = {}): 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> = {}): 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> = {}): 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> = {}): 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<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
copyrightRecord: {
create: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
novelSource: {
create: ReturnType<typeof vi.fn>;
findUnique: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
novelChapter: {
findUnique: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
asset: { findUnique: ReturnType<typeof vi.fn> };
$transaction: ReturnType<typeof vi.fn>;
};
let tx: {
project: { update: ReturnType<typeof vi.fn> };
novelSource: { create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
novelChapter: {
deleteMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
};
};
let storage: Pick<StorageService, 'readPrivateFile'>;
let parser: Pick<NovelParserService, 'countWords' | 'extractText' | 'parseText'>;
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');
});
});