import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { UserAiService } from './user-ai.service'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { CredentialEncryptionService } from './credential-encryption.service'; import { SnapshotBuilderService } from './snapshot-builder.service'; import { PriorityRulesService } from './priority-rules.service'; import { UserAiQuotaService } from './user-ai-quota.service'; import { PlatformBudgetService } from './platform-budget.service'; describe('UserAiService.createAnalysisJob', () => { let service: UserAiService; let prisma: any; let snapshotBuilder: any; let priorityRules: any; let quota: any; let budget: any; const mockSnapshot = { id: 'snap-1', snapshotVersion: 'ai_snapshot_v1' }; beforeEach(async () => { prisma = { userAiSettings: { findUnique: jest.fn(), create: jest.fn() }, userModelCredential: { findFirst: jest.fn() }, aiRuntimeJob: { findUnique: jest.fn(), create: jest.fn() }, questionGenerationPlan: { create: jest.fn() }, flashcardGenerationPlan: { create: jest.fn() }, userLearningProfile: { findUnique: jest.fn() }, }; snapshotBuilder = { buildSnapshot: jest.fn().mockResolvedValue(mockSnapshot) }; priorityRules = { computeJobPriority: jest.fn().mockReturnValue(50) }; quota = { checkAndReserve: jest.fn().mockResolvedValue(undefined), incrementJobCount: jest.fn().mockResolvedValue(undefined) }; budget = { checkPlatformBudget: jest.fn().mockResolvedValue(undefined) }; const crypto = { encrypt: jest.fn(), decrypt: jest.fn(), hash: jest.fn(), mask: jest.fn() } as any; const module: TestingModule = await Test.createTestingModule({ providers: [ UserAiService, { provide: PrismaService, useValue: prisma }, { provide: CredentialEncryptionService, useValue: crypto }, { provide: SnapshotBuilderService, useValue: snapshotBuilder }, { provide: PriorityRulesService, useValue: priorityRules }, { provide: UserAiQuotaService, useValue: quota }, { provide: PlatformBudgetService, useValue: budget }, ], }).compile(); service = module.get(UserAiService); }); const validDto = { jobType: 'learning_state_analysis', targetType: 'material', targetId: 'm1', }; it('creates a job with snapshot and correct fields', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 }); prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date('2026-06-17') }); const result = await service.createAnalysisJob('u1', validDto); expect(result.jobId).toBe('job-1'); expect(result.status).toBe('pending'); expect(snapshotBuilder.buildSnapshot).toHaveBeenCalledWith('u1', 'material', 'm1'); expect(prisma.aiRuntimeJob.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ userId: 'u1', jobType: 'learning_state_analysis', snapshotId: 'snap-1', priority: 50, promptVersion: 'learning_state_v1', outputSchemaVersion: 'analysis_output_v1', }), })); }); it('auto-creates settings for new user', async () => { prisma.userAiSettings.findUnique.mockResolvedValue(null); prisma.userAiSettings.create.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 }); prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() }); await service.createAnalysisJob('u1', validDto); expect(prisma.userAiSettings.create).toHaveBeenCalledWith({ data: { userId: 'u1' } }); }); it('throws AI_ANALYSIS_DISABLED when user opted out', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: false, apiKeyMode: 'platform_key' }); await expect(service.createAnalysisJob('u1', validDto)).rejects.toThrow(BadRequestException); await expect(service.createAnalysisJob('u1', validDto)).rejects.toMatchObject({ response: { errorCode: 'AI_ANALYSIS_DISABLED' }, }); }); it('returns existing job on idempotent retry', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 }); prisma.aiRuntimeJob.findUnique.mockResolvedValue({ id: 'existing-job', status: 'pending', createdAt: new Date('2026-06-01') }); const result = await service.createAnalysisJob('u1', { ...validDto, idempotencyKey: 'ik-1' }); expect(result.jobId).toBe('existing-job'); expect(snapshotBuilder.buildSnapshot).not.toHaveBeenCalled(); }); it('throws INVALID_JOB_TYPE for unknown job type', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key' }); await expect(service.createAnalysisJob('u1', { ...validDto, jobType: 'invalid_type' })) .rejects.toMatchObject({ response: { errorCode: 'INVALID_JOB_TYPE' } }); }); it('throws CREDENTIAL_REQUIRED for user key mode without credential', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'user_deepseek_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 }); await expect(service.createAnalysisJob('u1', { ...validDto, apiKeyMode: 'user_deepseek_key' })) .rejects.toMatchObject({ response: { errorCode: 'CREDENTIAL_REQUIRED' } }); }); it('throws CREDENTIAL_NOT_FOUND for invalid credential', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'user_deepseek_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 }); prisma.userModelCredential.findFirst.mockResolvedValue(null); await expect(service.createAnalysisJob('u1', { ...validDto, apiKeyMode: 'user_deepseek_key', credentialId: 'bad-cred' })) .rejects.toThrow(NotFoundException); }); it('calls quota check and budget for platform_key', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 }); prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() }); await service.createAnalysisJob('u1', validDto); expect(quota.checkAndReserve).toHaveBeenCalledWith('u1', 'platform_key'); expect(budget.checkPlatformBudget).toHaveBeenCalledWith('deepseek', 'deepseek-chat'); expect(quota.incrementJobCount).toHaveBeenCalledWith('u1', 'platform_key'); }); it('skips budget check for user_deepseek_key mode', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'user_deepseek_key', defaultCredentialId: 'c1', fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 }); prisma.userModelCredential.findFirst.mockResolvedValue({ id: 'c1', userId: 'u1', status: 'active' }); prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() }); await service.createAnalysisJob('u1', { ...validDto }); expect(budget.checkPlatformBudget).not.toHaveBeenCalled(); }); it('computes priority from profile and settings', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 }); prisma.userLearningProfile.findUnique.mockResolvedValue({ qualityPreference: 'exam', examTarget: 'AWS' }); prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() }); priorityRules.computeJobPriority.mockReturnValue(0); await service.createAnalysisJob('u1', validDto); expect(priorityRules.computeJobPriority).toHaveBeenCalled(); expect(prisma.aiRuntimeJob.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ priority: 0 }), })); }); // ── quiz_generation (API-AI-023) ── const quizDto = { jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: 'kb1', questionCount: 10, difficultyLevel: 'medium', questionTypes: ['choice', 'judge'], knowledgePointIds: ['kp1', 'kp2'], }; it('creates QuestionGenerationPlan for quiz_generation', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 }); prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() }); prisma.questionGenerationPlan.create.mockResolvedValue({ id: 'plan-1' }); const result = await service.createAnalysisJob('u1', quizDto); expect(prisma.questionGenerationPlan.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ jobId: 'job-1', count: 10, difficultyLevel: 'medium', questionTypes: ['choice', 'judge'], knowledgePointIds: ['kp1', 'kp2'], status: 'pending', }), })); expect(result).toMatchObject({ jobId: 'job-1', planId: 'plan-1' }); }); it('rejects quiz_generation with non-knowledge_base target', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key' }); await expect(service.createAnalysisJob('u1', { ...quizDto, targetType: 'material' })) .rejects.toMatchObject({ response: { errorCode: 'INVALID_TARGET_TYPE' } }); }); it('uses default questionCount=5 when not provided', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 }); prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() }); prisma.questionGenerationPlan.create.mockResolvedValue({ id: 'plan-1' }); await service.createAnalysisJob('u1', { jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: 'kb1' }); expect(prisma.questionGenerationPlan.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ count: 5, questionTypes: [], knowledgePointIds: [] }), })); }); it('does not create questionGenerationPlan for non-quiz job types', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 }); prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() }); await service.createAnalysisJob('u1', validDto); expect(prisma.questionGenerationPlan.create).not.toHaveBeenCalled(); }); // ── flashcard_generation (API-AI-024) ── const flashcardDto = { jobType: 'flashcard_generation', targetType: 'knowledge_base', targetId: 'kb1', cardCount: 20, difficultyLevel: 'hard', knowledgePointIds: ['kp1'], }; it('creates FlashcardGenerationPlan for flashcard_generation', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 }); prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() }); prisma.flashcardGenerationPlan.create.mockResolvedValue({ id: 'fplan-1' }); const result = await service.createAnalysisJob('u1', flashcardDto); expect(prisma.flashcardGenerationPlan.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ jobId: 'job-1', count: 20, difficultyLevel: 'hard', knowledgePointIds: ['kp1'], status: 'pending' }), })); expect(result).toMatchObject({ jobId: 'job-1', planId: 'fplan-1' }); }); it('rejects flashcard_generation with non-knowledge_base target', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key' }); await expect(service.createAnalysisJob('u1', { ...flashcardDto, targetType: 'material' })) .rejects.toMatchObject({ response: { errorCode: 'INVALID_TARGET_TYPE' } }); }); it('defaults cardCount to 5', async () => { prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 }); prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() }); prisma.flashcardGenerationPlan.create.mockResolvedValue({ id: 'fplan-1' }); await service.createAnalysisJob('u1', { jobType: 'flashcard_generation', targetType: 'knowledge_base', targetId: 'kb1' }); expect(prisma.flashcardGenerationPlan.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ count: 5 }), })); }); });