Initial AI manga platform
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
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,
|
||||
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: [
|
||||
{
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user