1166 lines
39 KiB
TypeScript
1166 lines
39 KiB
TypeScript
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { AuthRequestUser } from '../auth/auth.types';
|
|
import type { PrismaService } from '../prisma/prisma.service';
|
|
import { AdminService } from './admin.service';
|
|
|
|
const admin: AuthRequestUser = {
|
|
id: '1',
|
|
email: 'admin@example.com',
|
|
role: 'admin'
|
|
};
|
|
|
|
const user: AuthRequestUser = {
|
|
id: '2',
|
|
email: 'user@example.com',
|
|
role: 'user'
|
|
};
|
|
|
|
const auditor: AuthRequestUser = {
|
|
id: '3',
|
|
email: 'auditor@example.com',
|
|
role: 'auditor'
|
|
};
|
|
|
|
const now = new Date('2026-05-31T00:00:00.000Z');
|
|
|
|
function createUser(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 1n,
|
|
email: 'admin@example.com',
|
|
phone: null,
|
|
password_hash: 'hash',
|
|
nickname: 'Admin',
|
|
avatar_url: null,
|
|
role: 'admin',
|
|
status: 'active',
|
|
wechat_openid: null,
|
|
last_login_at: null,
|
|
created_at: now,
|
|
updated_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createProject(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 10n,
|
|
user_id: 1n,
|
|
title: '后台测试项目',
|
|
input_mode: 'ai_original',
|
|
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: 3,
|
|
episode_duration: 60,
|
|
status: 'video_rendered',
|
|
copyright_status: 'ai_original',
|
|
payment_status: 'unpaid',
|
|
quality_level: 'mvp',
|
|
is_long_series: false,
|
|
created_at: now,
|
|
updated_at: now,
|
|
completed_at: null,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createEpisode(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 11n,
|
|
project_id: 10n,
|
|
episode_no: 1,
|
|
source_chapter_ids: ['1'],
|
|
title: '第1集',
|
|
summary: '女主反击。',
|
|
opening_hook: '会议室录音曝光。',
|
|
middle_conflict: '男主施压。',
|
|
ending_hook: '幕后车辆出现。',
|
|
target_duration: 60,
|
|
status: 'confirmed',
|
|
created_at: now,
|
|
updated_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createStoryboardShot(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 12n,
|
|
project_id: 10n,
|
|
episode_id: 11n,
|
|
shot_no: 3,
|
|
scene_name: '会议室反击',
|
|
location_desc: '高层会议室',
|
|
characters_json: [],
|
|
visual_desc: '女主播放录音。',
|
|
action_desc: '女主反击。',
|
|
dialogue_text: '这一回,我不会再退。',
|
|
narration_text: null,
|
|
camera_motion: 'zoom_in',
|
|
effect_type: null,
|
|
duration: new Prisma.Decimal(4),
|
|
scene_type: 'dialog',
|
|
importance_score: 8,
|
|
emotion_score: 7,
|
|
action_score: 3,
|
|
route_tier: 'premium',
|
|
prompt_text: '真人短剧会议室反击',
|
|
negative_prompt: null,
|
|
live_action_desc: '真人短剧风格会议室反击。',
|
|
actor_action: '播放录音证据。',
|
|
camera_instruction: 'medium close-up',
|
|
performance_instruction: '冷静克制',
|
|
video_prompt: 'photorealistic Chinese vertical short drama',
|
|
keyframe_asset_id: 30n,
|
|
video_clip_asset_id: 31n,
|
|
video_status: 'video_clip_generated',
|
|
status: 'confirmed',
|
|
created_at: now,
|
|
updated_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createProviderConfig(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 13n,
|
|
provider_type: 'VideoProvider',
|
|
provider_code: 'kling-image-to-video',
|
|
display_name: 'Kling',
|
|
mode: 'mock',
|
|
model_name: 'kling-v1',
|
|
config_json: {},
|
|
fallback_provider_id: null,
|
|
is_enabled: true,
|
|
priority: 10,
|
|
rate_limit_json: {},
|
|
cost_rule_json: {},
|
|
created_at: now,
|
|
updated_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createProviderLog(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 60n,
|
|
provider_id: 13n,
|
|
task_id: 15n,
|
|
project_id: 10n,
|
|
provider_type: 'VideoProvider',
|
|
provider_code: 'kling-image-to-video',
|
|
model_name: 'kling-v1',
|
|
request_json: {
|
|
purpose: 'live-action-video-clip-12-1',
|
|
input_json: {
|
|
prompt: 'photorealistic Chinese vertical short drama'
|
|
}
|
|
},
|
|
response_json: {
|
|
provider_request_id: 'mock-request'
|
|
},
|
|
input_size: 100,
|
|
output_size: 200,
|
|
cost_estimate: new Prisma.Decimal(0.3),
|
|
cost_actual: new Prisma.Decimal(0.35),
|
|
status: 'success',
|
|
error_code: null,
|
|
error_message: null,
|
|
started_at: now,
|
|
finished_at: now,
|
|
created_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createVideoClip(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 14n,
|
|
project_id: 10n,
|
|
episode_id: 11n,
|
|
shot_id: 12n,
|
|
provider_id: 13n,
|
|
input_asset_id: 30n,
|
|
output_asset_id: 31n,
|
|
duration: new Prisma.Decimal(4),
|
|
prompt_text: 'photorealistic Chinese vertical short drama',
|
|
status: 'generated',
|
|
cost_actual: new Prisma.Decimal(0.35),
|
|
retry_count: 2,
|
|
quality_status: 'manual_required',
|
|
quality_score: new Prisma.Decimal(72),
|
|
quality_issues: ['face drift', 'AUTO_REPAIR_LIMIT_REACHED'],
|
|
created_at: now,
|
|
updated_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createTask(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 20n,
|
|
project_id: 10n,
|
|
episode_id: null,
|
|
shot_id: null,
|
|
task_type: 'video_render',
|
|
provider_id: null,
|
|
status: 'success',
|
|
input_json: {},
|
|
input_hash: 'hash',
|
|
idempotency_key: 'idem',
|
|
output_asset_id: null,
|
|
provider_request_id: null,
|
|
retry_count: 0,
|
|
max_retry: 2,
|
|
cost_estimate: null,
|
|
cost_actual: null,
|
|
error_code: null,
|
|
error_message: null,
|
|
created_at: now,
|
|
started_at: null,
|
|
finished_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createAsset(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 30n,
|
|
user_id: 1n,
|
|
project_id: 10n,
|
|
asset_type: 'video',
|
|
file_path: 'local://rendered-videos/test.mp4',
|
|
file_url: null,
|
|
mime_type: 'video/mp4',
|
|
width: 1080,
|
|
height: 1920,
|
|
duration: new Prisma.Decimal(4),
|
|
size: 1024n,
|
|
hash: 'hash',
|
|
visibility: 'private',
|
|
status: 'active',
|
|
created_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createQuotaAccount(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 40n,
|
|
user_id: 2n,
|
|
total_quota: new Prisma.Decimal(300),
|
|
available_quota: new Prisma.Decimal(220),
|
|
frozen_quota: new Prisma.Decimal(20),
|
|
used_quota: new Prisma.Decimal(60),
|
|
status: 'active',
|
|
created_at: now,
|
|
updated_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createQuotaLog(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 41n,
|
|
user_id: 2n,
|
|
project_id: 10n,
|
|
task_id: null,
|
|
change_type: 'admin_grant',
|
|
amount: new Prisma.Decimal(100),
|
|
balance_after: new Prisma.Decimal(220),
|
|
reason: '后台人工加余额',
|
|
metadata_json: {},
|
|
created_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createOrder(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 42n,
|
|
user_id: 2n,
|
|
project_id: 10n,
|
|
order_no: 'ORDER-TEST',
|
|
package_code: 'standard_3ep',
|
|
amount: new Prisma.Decimal(199),
|
|
currency: 'CNY',
|
|
payment_method: 'mock_pay',
|
|
payment_status: 'paid',
|
|
paid_at: now,
|
|
created_at: now,
|
|
updated_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createAnalyticsEvent(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 43n,
|
|
project_id: 10n,
|
|
episode_id: null,
|
|
event_type: 'publish_metrics',
|
|
platform: 'douyin',
|
|
metric_json: {
|
|
play_count: 25000,
|
|
like_count: 1200,
|
|
completion_rate: 0.72,
|
|
revenue: 20
|
|
},
|
|
created_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createOperationLog(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 90n,
|
|
user_id: 1n,
|
|
operator_role: 'admin',
|
|
action: 'admin_update_project_status',
|
|
target_type: 'project',
|
|
target_id: 10n,
|
|
ip: null,
|
|
user_agent: null,
|
|
metadata_json: {},
|
|
created_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createHitAnalysisCase(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 100n,
|
|
title: '退婚现场女主反击',
|
|
source_platform: 'hongguo',
|
|
source_url: 'https://example.com/hit-case',
|
|
content_type: 'short_drama',
|
|
genre: 'urban_revenge',
|
|
language: 'zh-CN',
|
|
target_audience: '女性 25-40',
|
|
duration_seconds: 60,
|
|
episode_count: 1,
|
|
tags_json: ['退婚', '打脸'],
|
|
metrics_json: { likes: 120000, completion_rate: 0.72 },
|
|
transcript_text: '退婚现场,男主逼女主签字。女主冷笑拿出录音证据。众人没想到她才是真正的继承人。结尾豪车停在门口。',
|
|
summary_text: '退婚现场女主用证据反击,身份反转。',
|
|
analysis_json: null,
|
|
diagnosis_score: null,
|
|
status: 'draft',
|
|
created_by_user_id: 1n,
|
|
created_at: now,
|
|
updated_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createHitAnalysisSegment(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 101n,
|
|
case_id: 100n,
|
|
segment_no: 1,
|
|
start_second: 0,
|
|
end_second: 8,
|
|
scene_type: 'dialog',
|
|
hook_type: 'relationship_break',
|
|
emotion: 'anger',
|
|
conflict_type: 'romance_conflict',
|
|
plot_function: 'opening_hook',
|
|
visual_strategy: 'ceremony_high_contrast',
|
|
dialogue_pattern: 'direct_confrontation',
|
|
camera_notes: '双人对峙构图,关键台词前推镜。',
|
|
importance_score: 9,
|
|
emotion_score: 7,
|
|
action_score: 2,
|
|
tags_json: ['dialog', 'relationship_break'],
|
|
summary_text: '退婚现场,男主逼女主签字。',
|
|
prompt_seed: '竖版短剧镜头,退婚现场',
|
|
created_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createCreativePattern(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 102n,
|
|
source_case_id: 100n,
|
|
pattern_type: 'opening_hook',
|
|
title: '都市复仇开场钩子:退婚',
|
|
genre: 'urban_revenge',
|
|
language: 'zh-CN',
|
|
description: '前8秒建立退婚压迫和证据反击。',
|
|
structure_json: { required_elements: ['身份信息差'] },
|
|
prompt_template: '写一个退婚现场开场钩子。',
|
|
negative_prompt: '拖慢铺垫',
|
|
tags_json: ['opening', '退婚'],
|
|
usage_count: 0,
|
|
effectiveness_score: new Prisma.Decimal(82),
|
|
status: 'active',
|
|
created_by_user_id: 1n,
|
|
created_at: now,
|
|
updated_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createProjectCreativePattern(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 103n,
|
|
project_id: 10n,
|
|
creative_pattern_id: 102n,
|
|
source: 'user_selected',
|
|
snapshot_json: {},
|
|
sort_order: 1,
|
|
created_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
describe('AdminService', () => {
|
|
let prisma: any;
|
|
let service: AdminService;
|
|
|
|
beforeEach(() => {
|
|
prisma = {
|
|
user: {
|
|
count: vi.fn().mockResolvedValue(2),
|
|
findUnique: vi.fn().mockResolvedValue(createUser()),
|
|
findMany: vi.fn().mockResolvedValue([createUser()]),
|
|
update: vi.fn(async ({ where, data }: { where: { id: bigint }; data: Record<string, unknown> }) =>
|
|
createUser({ id: where.id, ...data })
|
|
)
|
|
},
|
|
project: {
|
|
count: vi.fn().mockResolvedValue(3),
|
|
groupBy: vi.fn().mockResolvedValue([{ status: 'video_rendered', _count: { _all: 1 } }]),
|
|
findMany: vi.fn().mockResolvedValue([createProject()]),
|
|
findUnique: vi.fn().mockResolvedValue(createProject()),
|
|
update: vi.fn().mockResolvedValue(createProject({ status: 'cancelled' }))
|
|
},
|
|
episode: {
|
|
count: vi.fn().mockResolvedValue(1),
|
|
findUnique: vi.fn().mockResolvedValue(createEpisode()),
|
|
findMany: vi.fn().mockResolvedValue([])
|
|
},
|
|
asset: {
|
|
count: vi.fn().mockResolvedValue(1),
|
|
findMany: vi.fn().mockResolvedValue([createAsset()])
|
|
},
|
|
renderTask: {
|
|
count: vi.fn().mockResolvedValue(1),
|
|
groupBy: vi.fn().mockResolvedValue([{ status: 'success', _count: { _all: 1 } }]),
|
|
findFirst: vi.fn().mockResolvedValue(createTask()),
|
|
findMany: vi.fn().mockResolvedValue([createTask()])
|
|
},
|
|
providerConfig: {
|
|
findMany: vi.fn().mockResolvedValue([])
|
|
},
|
|
videoClip: {
|
|
findUnique: vi.fn().mockResolvedValue(createVideoClip()),
|
|
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) =>
|
|
createVideoClip(data)
|
|
),
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
count: vi.fn().mockResolvedValue(0)
|
|
},
|
|
contentReview: {
|
|
count: vi.fn().mockResolvedValue(0)
|
|
},
|
|
providerLog: {
|
|
groupBy: vi.fn().mockResolvedValue([
|
|
{
|
|
provider_type: 'VideoProvider',
|
|
status: 'success',
|
|
_count: { _all: 1 },
|
|
_sum: { cost_actual: new Prisma.Decimal(0) }
|
|
}
|
|
]),
|
|
aggregate: vi.fn().mockResolvedValue({
|
|
_sum: { cost_actual: new Prisma.Decimal(0) },
|
|
_count: { _all: 1 }
|
|
}),
|
|
findMany: vi.fn().mockResolvedValue([])
|
|
},
|
|
novelSource: {
|
|
findMany: vi.fn().mockResolvedValue([])
|
|
},
|
|
novelChapter: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
count: vi.fn().mockResolvedValue(0)
|
|
},
|
|
storyBible: {
|
|
findFirst: vi.fn().mockResolvedValue(null)
|
|
},
|
|
character: {
|
|
findMany: vi.fn().mockResolvedValue([])
|
|
},
|
|
storyboardShot: {
|
|
count: vi.fn().mockResolvedValue(0),
|
|
findUnique: vi.fn().mockResolvedValue(createStoryboardShot()),
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
update: vi.fn().mockResolvedValue(createStoryboardShot())
|
|
},
|
|
copyrightRecord: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
count: vi.fn().mockResolvedValue(0)
|
|
},
|
|
operationLog: {
|
|
create: vi.fn().mockResolvedValue(createOperationLog()),
|
|
findMany: vi.fn().mockResolvedValue([createOperationLog()]),
|
|
count: vi.fn().mockResolvedValue(1)
|
|
},
|
|
hitAnalysisCase: {
|
|
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) =>
|
|
createHitAnalysisCase({ ...data })
|
|
),
|
|
findUnique: vi.fn().mockResolvedValue(createHitAnalysisCase()),
|
|
findMany: vi.fn().mockResolvedValue([createHitAnalysisCase()]),
|
|
count: vi.fn().mockResolvedValue(1),
|
|
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) =>
|
|
createHitAnalysisCase({ ...data, status: data.status ?? 'analyzed' })
|
|
)
|
|
},
|
|
hitAnalysisSegment: {
|
|
findMany: vi.fn().mockResolvedValue([createHitAnalysisSegment()]),
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
|
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) =>
|
|
createHitAnalysisSegment({ ...data })
|
|
)
|
|
},
|
|
creativePattern: {
|
|
findUnique: vi.fn().mockResolvedValue(createCreativePattern()),
|
|
findMany: vi.fn().mockResolvedValue([createCreativePattern()]),
|
|
count: vi.fn().mockResolvedValue(1),
|
|
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) =>
|
|
createCreativePattern({ ...data })
|
|
),
|
|
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) =>
|
|
createCreativePattern({ ...data })
|
|
)
|
|
},
|
|
projectCreativePattern: {
|
|
findMany: vi.fn().mockResolvedValue([])
|
|
},
|
|
analyticsEvent: {
|
|
findMany: vi.fn().mockResolvedValue([])
|
|
},
|
|
quotaAccount: {
|
|
findUnique: vi.fn().mockResolvedValue(createQuotaAccount())
|
|
},
|
|
quotaLog: {
|
|
findMany: vi.fn().mockResolvedValue([createQuotaLog()]),
|
|
count: vi.fn().mockResolvedValue(1)
|
|
},
|
|
order: {
|
|
findMany: vi.fn().mockResolvedValue([createOrder()]),
|
|
count: vi.fn().mockResolvedValue(1)
|
|
}
|
|
};
|
|
service = new AdminService(prisma as PrismaService);
|
|
});
|
|
|
|
it('rejects non-admin users', async () => {
|
|
await expect(service.getDashboard(user)).rejects.toBeInstanceOf(ForbiddenException);
|
|
});
|
|
|
|
it('returns dashboard metrics for admins', async () => {
|
|
const result = await service.getDashboard(admin);
|
|
|
|
expect(result.metrics.total_users).toBe(2);
|
|
expect(result.metrics.total_projects).toBe(3);
|
|
expect(result.project_status_counts[0]).toEqual({ key: 'video_rendered', count: 1 });
|
|
});
|
|
|
|
it('returns RBAC permissions for audit roles', async () => {
|
|
const result = await service.getRbacProfile(auditor);
|
|
|
|
expect(result.permissions).toContain('audit:export');
|
|
await expect(service.updateUserStatus(auditor, '2', { status: 'disabled' })).rejects.toBeInstanceOf(
|
|
ForbiddenException
|
|
);
|
|
});
|
|
|
|
it('lists projects with owner and counters', async () => {
|
|
const result = await service.listProjects(admin, { limit: '10' });
|
|
|
|
expect(result.projects[0].project.id).toBe('10');
|
|
expect(result.projects[0].owner?.email).toBe('admin@example.com');
|
|
expect(result.projects[0].latest_task?.task_type).toBe('video_render');
|
|
});
|
|
|
|
it('lists router quality audit rows with routing, repair and cost details', async () => {
|
|
prisma.project.findMany.mockResolvedValue([createProject({ output_mode: 'live_action_ai' })]);
|
|
prisma.episode.findMany.mockResolvedValue([createEpisode()]);
|
|
prisma.storyboardShot.findMany.mockResolvedValue([createStoryboardShot()]);
|
|
prisma.providerConfig.findMany.mockResolvedValue([createProviderConfig()]);
|
|
prisma.videoClip.findMany
|
|
.mockResolvedValueOnce([createVideoClip()])
|
|
.mockResolvedValueOnce([createVideoClip({ id: 9n, cost_actual: new Prisma.Decimal(0.2) })]);
|
|
prisma.videoClip.count.mockResolvedValue(1);
|
|
prisma.renderTask.findMany
|
|
.mockResolvedValueOnce([
|
|
createTask({
|
|
id: 15n,
|
|
episode_id: 11n,
|
|
shot_id: 12n,
|
|
task_type: 'live_action_video_clip_generate',
|
|
output_asset_id: 31n,
|
|
cost_estimate: new Prisma.Decimal(0.3),
|
|
cost_actual: new Prisma.Decimal(0.35),
|
|
input_json: {
|
|
provider: 'kling-image-to-video',
|
|
router_decision: {
|
|
config_key: 'ai.router.v1',
|
|
provider_code: 'kling-image-to-video',
|
|
provider_mode: 'mock',
|
|
route_tier: 'premium',
|
|
decision_reason: 'auto_premium_route',
|
|
estimated_cost: 0.3,
|
|
fallback_chain: ['hailuo', 'kling-image-to-video', 'mock-video'],
|
|
candidates: [{ provider_code: 'kling-image-to-video', status: 'selected' }]
|
|
},
|
|
repair_context: {
|
|
source_clip_id: '9',
|
|
action: 'switch_provider',
|
|
provider_code: 'kling-image-to-video',
|
|
previous_quality_status: 'passed',
|
|
previous_quality_score: 68,
|
|
min_quality_score: 80,
|
|
fallback_chain: ['hailuo', 'kling-image-to-video', 'mock-video']
|
|
}
|
|
}
|
|
})
|
|
])
|
|
.mockResolvedValueOnce([
|
|
createTask({
|
|
id: 16n,
|
|
episode_id: 11n,
|
|
shot_id: null,
|
|
task_type: 'live_action_video_render',
|
|
output_asset_id: 32n,
|
|
input_json: {
|
|
clip_normalization: [
|
|
{
|
|
shot_id: '12',
|
|
shot_no: 3,
|
|
target_duration: 4,
|
|
source_duration: 5.875,
|
|
final_duration: 4,
|
|
trimmed: true,
|
|
trim_strategy: 'center',
|
|
trim_start: 0.938,
|
|
trim_tolerance: 0.3
|
|
}
|
|
]
|
|
}
|
|
})
|
|
]);
|
|
|
|
const result = await service.listRouterAudits(admin, { limit: '10' });
|
|
const row = result.audits[0];
|
|
|
|
expect(result.summary.clip_count).toBe(1);
|
|
expect(result.summary.manual_required_count).toBe(1);
|
|
expect(result.summary.switched_provider_count).toBe(1);
|
|
expect(row.provider.provider_code).toBe('kling-image-to-video');
|
|
expect(row.router.fallback_chain).toEqual(['hailuo', 'kling-image-to-video', 'mock-video']);
|
|
expect(row.repair.action).toBe('switch_provider');
|
|
expect(row.quality.manual_reason).toBe('AUTO_REPAIR_LIMIT_REACHED');
|
|
expect(row.cost.cost_delta).toBe(0.05);
|
|
expect(row.cost.repair_added_cost).toBe(0.35);
|
|
expect(result.summary.normalized_clip_count).toBe(1);
|
|
expect(result.summary.trimmed_clip_count).toBe(1);
|
|
expect(result.summary.trimmed_seconds_total).toBe(1.875);
|
|
expect(row.render_normalization).toEqual(
|
|
expect.objectContaining({
|
|
task_id: '16',
|
|
output_asset_id: '32',
|
|
shot_id: '12',
|
|
target_duration: 4,
|
|
source_duration: 5.875,
|
|
final_duration: 4,
|
|
trimmed: true,
|
|
trim_strategy: 'center',
|
|
trim_start: 0.938
|
|
})
|
|
);
|
|
});
|
|
|
|
it('creates a hit analysis case and auto-generates diagnosis segments', async () => {
|
|
prisma.hitAnalysisSegment.create
|
|
.mockResolvedValueOnce(createHitAnalysisSegment({ segment_no: 1, hook_type: 'relationship_break' }))
|
|
.mockResolvedValueOnce(createHitAnalysisSegment({ id: 103n, segment_no: 2, hook_type: 'evidence_reveal' }))
|
|
.mockResolvedValueOnce(createHitAnalysisSegment({ id: 104n, segment_no: 3, hook_type: 'identity_gap' }));
|
|
|
|
const result = await service.createHitAnalysisCase(admin, {
|
|
title: '退婚现场女主反击',
|
|
source_platform: 'hongguo',
|
|
genre: 'urban_revenge',
|
|
transcript_text: '退婚现场,男主逼女主签字。女主冷笑拿出录音证据。众人没想到她才是真正的继承人。',
|
|
auto_analyze: true
|
|
});
|
|
|
|
expect(prisma.hitAnalysisCase.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
title: '退婚现场女主反击',
|
|
source_platform: 'hongguo',
|
|
genre: 'urban_revenge'
|
|
})
|
|
});
|
|
expect(prisma.hitAnalysisSegment.deleteMany).toHaveBeenCalledWith({ where: { case_id: 100n } });
|
|
expect(prisma.hitAnalysisCase.update).toHaveBeenCalledWith({
|
|
where: { id: 100n },
|
|
data: expect.objectContaining({
|
|
status: 'analyzed',
|
|
diagnosis_score: expect.any(Number),
|
|
analysis_json: expect.objectContaining({
|
|
version: 'hit_analysis_v1_rule_mock',
|
|
scores: expect.objectContaining({
|
|
total: expect.any(Number)
|
|
})
|
|
})
|
|
})
|
|
});
|
|
expect(result.case.status).toBe('analyzed');
|
|
expect(result.segments.length).toBeGreaterThanOrEqual(3);
|
|
expect(result.analysis?.key_takeaways.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('promotes analyzed hit case into reusable creative patterns', async () => {
|
|
prisma.hitAnalysisCase.findUnique.mockResolvedValue(createHitAnalysisCase({
|
|
status: 'analyzed',
|
|
diagnosis_score: new Prisma.Decimal(84),
|
|
analysis_json: {
|
|
scores: { total: 84 },
|
|
key_takeaways: ['开场强钩子']
|
|
}
|
|
}));
|
|
prisma.hitAnalysisSegment.findMany.mockResolvedValue([
|
|
createHitAnalysisSegment({ segment_no: 1, hook_type: 'relationship_break' }),
|
|
createHitAnalysisSegment({ id: 103n, segment_no: 2, plot_function: 'reversal', hook_type: 'identity_gap' })
|
|
]);
|
|
|
|
const result = await service.promoteHitCasePatterns(admin, '100', {
|
|
pattern_types: ['opening_hook', 'visual_prompt']
|
|
});
|
|
|
|
expect(prisma.creativePattern.create).toHaveBeenCalledTimes(2);
|
|
expect(prisma.creativePattern.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
source_case_id: 100n,
|
|
pattern_type: 'opening_hook',
|
|
status: 'active',
|
|
prompt_template: expect.stringContaining('短剧开场')
|
|
})
|
|
});
|
|
expect(result.patterns.map((pattern) => pattern.pattern_type)).toEqual(['opening_hook', 'visual_prompt']);
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'admin_promote_hit_analysis_patterns',
|
|
target_type: 'hit_analysis_case',
|
|
target_id: 100n
|
|
})
|
|
});
|
|
});
|
|
|
|
it('lists creative patterns with usage and ROI metrics', async () => {
|
|
prisma.projectCreativePattern.findMany.mockResolvedValue([createProjectCreativePattern()]);
|
|
prisma.project.findMany.mockResolvedValue([createProject({ status: 'completed' })]);
|
|
prisma.providerLog.findMany.mockResolvedValue([createProviderLog({ cost_actual: new Prisma.Decimal(0.35) })]);
|
|
prisma.renderTask.findMany.mockResolvedValue([createTask({ cost_actual: new Prisma.Decimal(0.5) })]);
|
|
prisma.videoClip.findMany.mockResolvedValue([createVideoClip({ quality_score: new Prisma.Decimal(88) })]);
|
|
prisma.asset.findMany.mockResolvedValue([createAsset()]);
|
|
prisma.analyticsEvent.findMany.mockResolvedValue([createAnalyticsEvent()]);
|
|
prisma.order.findMany.mockResolvedValue([createOrder({ amount: new Prisma.Decimal(199) })]);
|
|
|
|
const result = await service.listCreativePatterns(admin, { limit: '10' });
|
|
|
|
expect(result.patterns[0].metrics.bound_project_count).toBe(1);
|
|
expect(result.patterns[0].metrics.completed_project_count).toBe(1);
|
|
expect(result.patterns[0].metrics.total_cost_actual).toBe(0.35);
|
|
expect(result.patterns[0].metrics.total_revenue_estimate).toBe(219);
|
|
expect(result.summary.bound_project_count).toBe(1);
|
|
expect(result.summary.roi_estimate).toBe(218.65);
|
|
});
|
|
|
|
it('updates creative pattern content and status with operation logs', async () => {
|
|
const updated = await service.updateCreativePattern(admin, '102', {
|
|
title: '升级后的开场钩子',
|
|
pattern_type: 'opening_hook',
|
|
genre: 'urban_revenge',
|
|
language: 'zh-CN',
|
|
prompt_template: '前 8 秒退婚,随后证据反杀。',
|
|
negative_prompt: '拖慢铺垫',
|
|
tags: '退婚, 证据, 打脸',
|
|
effectiveness_score: '88',
|
|
reason: '人工复盘后优化'
|
|
});
|
|
|
|
expect(prisma.creativePattern.update).toHaveBeenCalledWith({
|
|
where: { id: 102n },
|
|
data: expect.objectContaining({
|
|
title: '升级后的开场钩子',
|
|
pattern_type: 'opening_hook',
|
|
tags_json: ['退婚', '证据', '打脸'],
|
|
effectiveness_score: 88
|
|
})
|
|
});
|
|
expect(updated.pattern.title).toBe('升级后的开场钩子');
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'admin_update_creative_pattern',
|
|
target_type: 'creative_pattern',
|
|
target_id: 102n
|
|
})
|
|
});
|
|
|
|
await service.updateCreativePatternStatus(admin, '102', {
|
|
status: 'disabled',
|
|
reason: 'ROI 偏低,先停用'
|
|
});
|
|
|
|
expect(prisma.creativePattern.update).toHaveBeenLastCalledWith({
|
|
where: { id: 102n },
|
|
data: { status: 'disabled' }
|
|
});
|
|
expect(prisma.operationLog.create).toHaveBeenLastCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'admin_update_creative_pattern_status',
|
|
metadata_json: expect.objectContaining({
|
|
from_status: 'active',
|
|
to_status: 'disabled'
|
|
})
|
|
})
|
|
});
|
|
});
|
|
|
|
it('refreshes creative pattern metrics back into effectiveness score', async () => {
|
|
prisma.projectCreativePattern.findMany.mockResolvedValue([createProjectCreativePattern()]);
|
|
prisma.project.findMany.mockResolvedValue([createProject({ status: 'completed' })]);
|
|
prisma.providerLog.findMany.mockResolvedValue([createProviderLog({ cost_actual: new Prisma.Decimal(0.35) })]);
|
|
prisma.renderTask.findMany.mockResolvedValue([createTask({ cost_actual: new Prisma.Decimal(0.5) })]);
|
|
prisma.videoClip.findMany.mockResolvedValue([createVideoClip({ quality_score: new Prisma.Decimal(92) })]);
|
|
prisma.asset.findMany.mockResolvedValue([createAsset()]);
|
|
prisma.analyticsEvent.findMany.mockResolvedValue([createAnalyticsEvent()]);
|
|
prisma.order.findMany.mockResolvedValue([createOrder({ amount: new Prisma.Decimal(199) })]);
|
|
|
|
const result = await service.refreshCreativePatternMetrics(admin, '102');
|
|
|
|
expect(result.metrics.bound_project_count).toBe(1);
|
|
expect(result.metrics.roi_estimate).toBe(218.65);
|
|
expect(result.pattern.effectiveness_score).toBeGreaterThan(70);
|
|
expect(result.project_metrics[0].project.id).toBe('10');
|
|
expect(prisma.creativePattern.update).toHaveBeenCalledWith({
|
|
where: { id: 102n },
|
|
data: { effectiveness_score: expect.any(Number) }
|
|
});
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'admin_refresh_creative_pattern_metrics',
|
|
target_type: 'creative_pattern',
|
|
target_id: 102n
|
|
})
|
|
});
|
|
});
|
|
|
|
it('returns router audit timeline with route, repair, provider and manual operation events', async () => {
|
|
prisma.project.findUnique.mockResolvedValue(createProject({ output_mode: 'live_action_ai' }));
|
|
prisma.episode.findUnique.mockResolvedValue(createEpisode());
|
|
prisma.storyboardShot.findUnique.mockResolvedValue(createStoryboardShot());
|
|
prisma.videoClip.findUnique.mockResolvedValue(createVideoClip());
|
|
prisma.videoClip.findMany.mockResolvedValue([
|
|
createVideoClip({
|
|
id: 9n,
|
|
retry_count: 0,
|
|
quality_status: 'needs_retry',
|
|
quality_score: new Prisma.Decimal(68),
|
|
cost_actual: new Prisma.Decimal(0.2)
|
|
}),
|
|
createVideoClip()
|
|
]);
|
|
prisma.renderTask.findMany.mockResolvedValue([
|
|
createTask({
|
|
id: 15n,
|
|
episode_id: 11n,
|
|
shot_id: 12n,
|
|
task_type: 'live_action_video_clip_generate',
|
|
output_asset_id: 31n,
|
|
cost_estimate: new Prisma.Decimal(0.3),
|
|
cost_actual: new Prisma.Decimal(0.35),
|
|
input_json: {
|
|
provider: 'kling-image-to-video',
|
|
router_decision: {
|
|
provider_code: 'kling-image-to-video',
|
|
route_tier: 'premium',
|
|
decision_reason: 'auto_premium_route',
|
|
estimated_cost: 0.3,
|
|
fallback_chain: ['hailuo', 'kling-image-to-video', 'mock-video']
|
|
},
|
|
repair_context: {
|
|
source_clip_id: '9',
|
|
action: 'switch_provider',
|
|
provider_code: 'kling-image-to-video',
|
|
previous_quality_status: 'needs_retry',
|
|
previous_quality_score: 68,
|
|
min_quality_score: 80,
|
|
fallback_chain: ['hailuo', 'kling-image-to-video', 'mock-video']
|
|
}
|
|
}
|
|
}),
|
|
createTask({
|
|
id: 16n,
|
|
episode_id: 11n,
|
|
shot_id: null,
|
|
task_type: 'live_action_video_render',
|
|
output_asset_id: 32n,
|
|
input_json: {
|
|
clip_normalization: [
|
|
{
|
|
shot_id: '12',
|
|
shot_no: 3,
|
|
target_duration: 4,
|
|
source_duration: 5.875,
|
|
final_duration: 4,
|
|
trimmed: true,
|
|
trim_strategy: 'center',
|
|
trim_start: 0.938,
|
|
trim_tolerance: 0.3
|
|
}
|
|
]
|
|
}
|
|
})
|
|
]);
|
|
prisma.providerConfig.findMany.mockResolvedValue([createProviderConfig()]);
|
|
prisma.providerLog.findMany.mockResolvedValue([
|
|
createProviderLog(),
|
|
createProviderLog({
|
|
id: 61n,
|
|
task_id: null,
|
|
provider_type: 'QualityCheckProvider',
|
|
provider_code: 'mock-qc',
|
|
model_name: 'mock-qc',
|
|
request_json: {
|
|
purpose: 'live-action-video-clip-qc-14',
|
|
input_json: { prompt: 'clip_id=14\nstatus=mock' }
|
|
},
|
|
response_json: {
|
|
result_status: 'manual_required',
|
|
quality_score: 72
|
|
},
|
|
cost_actual: new Prisma.Decimal(0)
|
|
})
|
|
]);
|
|
prisma.operationLog.findMany.mockResolvedValue([
|
|
createOperationLog({
|
|
action: 'admin_update_router_audit_quality',
|
|
target_type: 'video_clip',
|
|
target_id: 14n,
|
|
metadata_json: {
|
|
from_quality_status: 'manual_required',
|
|
to_quality_status: 'passed',
|
|
reason: '人工确认可用'
|
|
}
|
|
})
|
|
]);
|
|
|
|
const result = await service.getRouterAuditClipTimeline(admin, '14');
|
|
const kinds = result.timeline.map((item) => item.kind);
|
|
|
|
expect(result.audit.clip.id).toBe('14');
|
|
expect(result.summary.related_clip_count).toBe(2);
|
|
expect(result.summary.provider_call_count).toBe(2);
|
|
expect(kinds).toContain('route_decision');
|
|
expect(kinds).toContain('repair');
|
|
expect(kinds).toContain('provider_call');
|
|
expect(kinds).toContain('quality_check');
|
|
expect(kinds).toContain('manual_operation');
|
|
expect(kinds).toContain('clip_normalization');
|
|
expect(result.audit.render_normalization).toEqual(
|
|
expect.objectContaining({
|
|
task_id: '16',
|
|
trimmed: true,
|
|
trim_strategy: 'center'
|
|
})
|
|
);
|
|
expect(result.timeline.some((item) => item.title === '合成片段自动裁切')).toBe(true);
|
|
expect(result.timeline.some((item) => item.title === '后台人工质检处理')).toBe(true);
|
|
});
|
|
|
|
it('manually updates router audit quality and records an operation log', async () => {
|
|
prisma.videoClip.findUnique.mockResolvedValue(createVideoClip({
|
|
quality_status: 'manual_required',
|
|
quality_score: new Prisma.Decimal(72)
|
|
}));
|
|
|
|
const result = await service.updateRouterAuditClipQuality(admin, '14', {
|
|
result_status: 'passed',
|
|
reason: '画面可接受,人工通过'
|
|
});
|
|
|
|
expect(result.video_clip.quality_status).toBe('passed');
|
|
expect(result.video_clip.quality_score).toBe(80);
|
|
expect(prisma.videoClip.update).toHaveBeenCalledWith({
|
|
where: { id: 14n },
|
|
data: expect.objectContaining({
|
|
quality_status: 'passed',
|
|
quality_score: 80,
|
|
quality_issues: expect.arrayContaining(['MANUAL_PASS: 画面可接受,人工通过'])
|
|
})
|
|
});
|
|
expect(prisma.storyboardShot.update).toHaveBeenCalledWith({
|
|
where: { id: 12n },
|
|
data: { video_status: 'quality_passed' }
|
|
});
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'admin_update_router_audit_quality',
|
|
target_type: 'video_clip',
|
|
target_id: 14n,
|
|
metadata_json: expect.objectContaining({
|
|
from_quality_status: 'manual_required',
|
|
to_quality_status: 'passed',
|
|
reason: '画面可接受,人工通过'
|
|
})
|
|
})
|
|
});
|
|
});
|
|
|
|
it('updates project status and writes operation log', async () => {
|
|
const result = await service.updateProjectStatus(admin, '10', {
|
|
status: 'cancelled',
|
|
reason: 'operator stop'
|
|
});
|
|
|
|
expect(prisma.project.update).toHaveBeenCalledWith({
|
|
where: { id: 10n },
|
|
data: { status: 'cancelled' }
|
|
});
|
|
expect(prisma.operationLog.create).toHaveBeenCalled();
|
|
expect(result.project.status).toBe('cancelled');
|
|
});
|
|
|
|
it('returns user detail with quota, orders, projects, assets and operations', async () => {
|
|
prisma.user.findUnique.mockResolvedValue(createUser({ id: 2n, email: 'user@example.com', role: 'user' }));
|
|
prisma.project.findMany.mockResolvedValue([createProject({ user_id: 2n })]);
|
|
prisma.project.count.mockResolvedValue(1);
|
|
prisma.asset.findMany.mockResolvedValue([createAsset({ user_id: 2n })]);
|
|
prisma.asset.count.mockResolvedValue(1);
|
|
|
|
const result = await service.getUserDetail(admin, '2');
|
|
|
|
expect(result.user.email).toBe('user@example.com');
|
|
expect(result.quota_account?.available_quota).toBe(220);
|
|
expect(result.quota_logs[0].change_type).toBe('admin_grant');
|
|
expect(result.orders[0].order_no).toBe('ORDER-TEST');
|
|
expect(result.projects[0].id).toBe('10');
|
|
expect(result.assets[0].asset.id).toBe('30');
|
|
expect(result.operation_logs[0].action).toBe('admin_update_project_status');
|
|
});
|
|
|
|
it('updates user status and writes operation log', async () => {
|
|
prisma.user.findUnique.mockResolvedValue(createUser({ id: 2n, email: 'user@example.com', role: 'user' }));
|
|
|
|
const result = await service.updateUserStatus(admin, '2', {
|
|
status: 'disabled',
|
|
reason: 'risk control'
|
|
});
|
|
|
|
expect(prisma.user.update).toHaveBeenCalledWith({
|
|
where: { id: 2n },
|
|
data: { status: 'disabled' }
|
|
});
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'admin_update_user_status',
|
|
target_type: 'user',
|
|
target_id: 2n,
|
|
metadata_json: expect.objectContaining({
|
|
from_status: 'active',
|
|
to_status: 'disabled',
|
|
reason: 'risk control'
|
|
})
|
|
})
|
|
});
|
|
expect(result.user.status).toBe('disabled');
|
|
});
|
|
|
|
it('prevents current admin from disabling self', async () => {
|
|
prisma.user.findUnique.mockResolvedValue(createUser({ id: 1n, role: 'admin' }));
|
|
|
|
await expect(
|
|
service.updateUserStatus(admin, '1', { status: 'disabled' })
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('updates user role but prevents self demotion', async () => {
|
|
prisma.user.findUnique.mockResolvedValueOnce(createUser({ id: 2n, role: 'user' }));
|
|
|
|
const result = await service.updateUserRole(admin, '2', {
|
|
role: 'admin',
|
|
reason: 'operator promotion'
|
|
});
|
|
|
|
expect(result.user.role).toBe('admin');
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'admin_update_user_role',
|
|
metadata_json: expect.objectContaining({
|
|
from_role: 'user',
|
|
to_role: 'admin'
|
|
})
|
|
})
|
|
});
|
|
|
|
prisma.user.findUnique.mockResolvedValueOnce(createUser({ id: 1n, role: 'admin' }));
|
|
await expect(service.updateUserRole(admin, '1', { role: 'user' })).rejects.toBeInstanceOf(
|
|
BadRequestException
|
|
);
|
|
});
|
|
|
|
it('resets user password without storing raw password in operation metadata', async () => {
|
|
prisma.user.findUnique.mockResolvedValue(createUser({ id: 2n, email: 'user@example.com', role: 'user' }));
|
|
|
|
const result = await service.resetUserPassword(admin, '2', {
|
|
new_password: 'Reset123456!',
|
|
reason: 'support reset'
|
|
});
|
|
const updateCall = prisma.user.update.mock.calls[0][0];
|
|
const operationCall = prisma.operationLog.create.mock.calls.at(-1)?.[0];
|
|
|
|
expect(updateCall.data.password_hash).not.toBe('Reset123456!');
|
|
expect(result.temporary_password).toBeNull();
|
|
expect(operationCall.data.action).toBe('admin_reset_user_password');
|
|
expect(JSON.stringify(operationCall.data.metadata_json)).not.toContain('Reset123456!');
|
|
});
|
|
|
|
it('generates a temporary password when reset password body is empty', async () => {
|
|
prisma.user.findUnique.mockResolvedValue(createUser({ id: 2n, email: 'user@example.com', role: 'user' }));
|
|
|
|
const result = await service.resetUserPassword(admin, '2', {
|
|
reason: 'support reset'
|
|
});
|
|
|
|
expect(result.temporary_password).toMatch(/^Tmp-/);
|
|
expect(result.temporary_password?.length).toBeGreaterThanOrEqual(12);
|
|
});
|
|
|
|
it('lists and exports operation logs for auditors', async () => {
|
|
const list = await service.listOperationLogs(auditor, { action: 'admin_update_project_status' });
|
|
const exported = await service.exportOperationLogs(auditor, { limit: '100' });
|
|
|
|
expect(list.logs[0].action).toBe('admin_update_project_status');
|
|
expect(exported.filename).toContain('operation-logs-');
|
|
expect(exported.content).toContain('admin_update_project_status');
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'admin_export_operation_logs',
|
|
operator_role: 'auditor'
|
|
})
|
|
});
|
|
});
|
|
});
|