api-server/src/modules/ai-runtime/user-ai.service.spec.ts
wangdl c88af39673
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 45s
feat: AI Runtime 完整业务逻辑实现
- runtime-internal.service: resolveSnapshot 自动重建、persistResult 5种jobType持久化、validateOutput 校验、convertQuizCandidates/convertFlashcardCandidates 候选转换、notifyJobComplete 通知、JOB_CANCELLED处理、heartbeat 双阶段更新+取消检测
- user-ai.service: createAnalysisJob 11步流程、cancelJob、publishQuiz/publishFlashcard、getAnalysis/listAnalyses等
- user-ai.controller: 20+ 用户API端点
- 新增服务: SnapshotBuilderService、PriorityRulesService、SnapshotCleanupService、JobReaperService
- 新增模块: admin-learning (CRUD管理)
- Prisma schema: cancelRequestedAt/cancelledAt/sourceBlockIds 字段、expiresAt 索引
- 文档: ai-runtime-user-api.md、Issue 记录

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-18 11:22:03 +08:00

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