Files
ai/backend/src/admin/admin.service.spec.ts
T
2026-06-15 17:45:28 +08:00

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'
})
});
});
});