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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 = {}) { 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 }) => 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 }) => 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 }) => createHitAnalysisCase({ ...data }) ), findUnique: vi.fn().mockResolvedValue(createHitAnalysisCase()), findMany: vi.fn().mockResolvedValue([createHitAnalysisCase()]), count: vi.fn().mockResolvedValue(1), update: vi.fn(async ({ data }: { data: Record }) => 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 }) => 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 }) => createCreativePattern({ ...data }) ), create: vi.fn(async ({ data }: { data: Record }) => 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' }) }); }); });