322 lines
9.7 KiB
TypeScript
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');
|
|
});
|
|
});
|