267 lines
14 KiB
TypeScript
267 lines
14 KiB
TypeScript
|
|
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 }),
|
||
|
|
}));
|
||
|
|
});
|
||
|
|
});
|