From 02979e3c243387711976a78d6cc83ab205065875 Mon Sep 17 00:00:00 2001 From: wangdl Date: Thu, 18 Jun 2026 11:03:23 +0800 Subject: [PATCH] test: add unit tests for runtime-internal.service (API-AI-062) Co-Authored-By: Claude Opus 4.7 --- .../internal/runtime-internal.service.spec.ts | 623 ++++++++++++++++++ 1 file changed, 623 insertions(+) create mode 100644 src/modules/ai-runtime/internal/runtime-internal.service.spec.ts diff --git a/src/modules/ai-runtime/internal/runtime-internal.service.spec.ts b/src/modules/ai-runtime/internal/runtime-internal.service.spec.ts new file mode 100644 index 0000000..c261a23 --- /dev/null +++ b/src/modules/ai-runtime/internal/runtime-internal.service.spec.ts @@ -0,0 +1,623 @@ +import { RuntimeInternalService } from './runtime-internal.service'; + +describe('RuntimeInternalService', () => { + let service: RuntimeInternalService; + + // Prisma mocks + let mockAiRuntimeJob: any; + let mockLearningAnalysisSnapshot: any; + let mockAiRuntimeResult: any; + let mockRuntimeInstance: any; + let mockModelInvocationLog: any; + + // Service dependency mocks + let mockUserAi: any; + + const mockSnapshot = { + id: 'snap-1', + snapshotVersion: 'ai_snapshot_v1', + expiresAt: new Date(Date.now() + 3600_000), + privacyScope: { allowUserProfile: true }, + userProfile: { displayName: 'Test' }, + aiSettings: { modelPreference: 'deepseek' }, + deviceContext: { platform: 'ios' }, + learningBehaviorSummary: { totalSessions: 10 }, + materialProgressSummary: { materialsRead: 5 }, + contentStructureSummary: { topics: 3 }, + behaviorSignals: { avgSessionMin: 15 }, + scoreSignals: { avgScore: 0.8 }, + constraints: { qualityPreference: 'standard' }, + allowedModelFields: ['userProfile', 'behaviorSignals'], + }; + + beforeEach(() => { + mockAiRuntimeJob = { + findMany: jest.fn(), + findUnique: jest.fn(), + findFirst: jest.fn(), + updateMany: jest.fn(), + update: jest.fn(), + }; + mockLearningAnalysisSnapshot = { + findMany: jest.fn(), + findUnique: jest.fn(), + }; + mockAiRuntimeResult = { + findFirst: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + }; + mockRuntimeInstance = { upsert: jest.fn() }; + mockModelInvocationLog = { create: jest.fn() }; + + const mockPrisma = { + aiRuntimeJob: mockAiRuntimeJob, + learningAnalysisSnapshot: mockLearningAnalysisSnapshot, + aiRuntimeResult: mockAiRuntimeResult, + runtimeInstance: mockRuntimeInstance, + modelInvocationLog: mockModelInvocationLog, + } as any; + + mockUserAi = { + resolveCredentialForJob: jest.fn(), + }; + + service = new RuntimeInternalService(mockPrisma as any, mockUserAi); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ═══════════════════════════════════════════════════════════════════ + // pollJobs + // ═══════════════════════════════════════════════════════════════════ + + describe('pollJobs', () => { + const pendingJobs = [ + { id: 'j1', jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: 'kb1', priority: 1, snapshotId: 'snap-1', promptVersion: 'v1', outputSchemaVersion: 'ov1' }, + { id: 'j2', jobType: 'flashcard_generation', targetType: 'material', targetId: 'm1', priority: 2, snapshotId: null, promptVersion: 'v1', outputSchemaVersion: 'ov2' }, + ]; + + it('returns pending jobs for supported job types', async () => { + mockAiRuntimeJob.findMany.mockResolvedValue(pendingJobs); + + const result = await service.pollJobs('rt-1', ['quiz_generation', 'flashcard_generation'], 10); + + expect(mockAiRuntimeJob.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { status: 'pending', jobType: { in: ['quiz_generation', 'flashcard_generation'] } }, + take: 10, + }), + ); + expect(result.jobs).toHaveLength(2); + }); + + it('caps limit at 50 and defaults to 5 when no limit given', async () => { + mockAiRuntimeJob.findMany.mockResolvedValue([]); + + await service.pollJobs('rt-1', ['quiz_generation'], undefined as any); + + expect(mockAiRuntimeJob.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 5 }), + ); + }); + + it('caps limit to 50 even if higher given', async () => { + mockAiRuntimeJob.findMany.mockResolvedValue([]); + + await service.pollJobs('rt-1', ['quiz_generation'], 100); + + expect(mockAiRuntimeJob.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 50 }), + ); + }); + + it('filters by outputSchemaVersion when capabilities declare supportedOutputSchemaVersions', async () => { + mockAiRuntimeJob.findMany.mockResolvedValue([]); + mockRuntimeInstance.upsert.mockResolvedValue({}); + + await service.pollJobs('rt-1', ['quiz_generation'], 10, { + supportedOutputSchemaVersions: ['ov1', 'ov2'], + }); + + expect(mockAiRuntimeJob.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ outputSchemaVersion: { in: ['ov1', 'ov2'] } }), + }), + ); + }); + + it('registers/updates RuntimeInstance when capabilities have snapshot or output version info', async () => { + mockAiRuntimeJob.findMany.mockResolvedValue([]); + mockRuntimeInstance.upsert.mockResolvedValue({}); + + await service.pollJobs('rt-1', ['quiz_generation'], 10, { + supportedSnapshotVersions: ['ai_snapshot_v1'], + }); + + expect(mockRuntimeInstance.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { runtimeInstanceId: 'rt-1' }, + }), + ); + }); + + it('does not upsert RuntimeInstance when no version capabilities provided', async () => { + mockAiRuntimeJob.findMany.mockResolvedValue([]); + + await service.pollJobs('rt-1', ['quiz_generation'], 10, { someOtherCap: true }); + + expect(mockRuntimeInstance.upsert).not.toHaveBeenCalled(); + }); + + it('post-filters jobs by snapshotVersion compatibility', async () => { + mockAiRuntimeJob.findMany.mockResolvedValue(pendingJobs); + mockRuntimeInstance.upsert.mockResolvedValue({}); + mockLearningAnalysisSnapshot.findMany.mockResolvedValue([ + { id: 'snap-1', snapshotVersion: 'ai_snapshot_v2' }, + ]); + + const result = await service.pollJobs('rt-1', ['quiz_generation'], 10, { + supportedSnapshotVersions: ['ai_snapshot_v1'], + }); + + expect(result.jobs).toHaveLength(1); + expect(result.jobs[0].id).toBe('j2'); + }); + + it('keeps jobs with snapshot when snapshot version is compatible', async () => { + mockAiRuntimeJob.findMany.mockResolvedValue(pendingJobs); + mockRuntimeInstance.upsert.mockResolvedValue({}); + mockLearningAnalysisSnapshot.findMany.mockResolvedValue([ + { id: 'snap-1', snapshotVersion: 'ai_snapshot_v1' }, + ]); + + const result = await service.pollJobs('rt-1', ['quiz_generation'], 10, { + supportedSnapshotVersions: ['ai_snapshot_v1'], + }); + + expect(result.jobs).toHaveLength(2); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // lockJob + // ═══════════════════════════════════════════════════════════════════ + + describe('lockJob', () => { + it('locks a pending unlocked job', async () => { + mockAiRuntimeJob.updateMany.mockResolvedValue({ count: 1 }); + + const result = await service.lockJob('j1', 'rt-1'); + + expect(mockAiRuntimeJob.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: 'j1', + status: 'pending', + OR: [ + { lockUntil: null }, + { lockUntil: { lt: expect.any(Date) } }, + ], + }, + data: expect.objectContaining({ + status: 'locked', + lockedBy: 'rt-1', + }), + }), + ); + expect(result.status).toBe('locked'); + expect(result.jobId).toBe('j1'); + expect(result.lockUntil).toBeGreaterThan(Date.now()); + }); + + it('throws ConflictException when job cannot be locked (count=0)', async () => { + mockAiRuntimeJob.updateMany.mockResolvedValue({ count: 0 }); + + await expect(service.lockJob('j1', 'rt-1')).rejects.toMatchObject({ + response: expect.objectContaining({ errorCode: 'JOB_ALREADY_LOCKED' }), + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // heartbeatJob + // ═══════════════════════════════════════════════════════════════════ + + describe('heartbeatJob', () => { + it('updates lockUntil and status for locked or running job', async () => { + mockAiRuntimeJob.updateMany.mockResolvedValue({ count: 1 }); + + await service.heartbeatJob('j1', 'rt-1'); + + expect(mockAiRuntimeJob.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: 'j1', + lockedBy: 'rt-1', + status: { in: ['locked', 'running'] }, + }, + data: { + lockUntil: expect.any(Date), + status: 'running', + startedAt: expect.any(Date), + }, + }), + ); + }); + + it('throws NotFoundException when job not locked by this runtime', async () => { + mockAiRuntimeJob.updateMany.mockResolvedValue({ count: 0 }); + + await expect(service.heartbeatJob('j1', 'rt-1')).rejects.toMatchObject({ + response: expect.objectContaining({ errorCode: 'JOB_NOT_FOUND' }), + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // getSnapshot + // ═══════════════════════════════════════════════════════════════════ + + describe('getSnapshot', () => { + it('returns snapshot data when job has valid snapshot', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ + id: 'j1', snapshotId: 'snap-1', + }); + mockLearningAnalysisSnapshot.findUnique.mockResolvedValue(mockSnapshot); + + const result = await service.getSnapshot('j1'); + + expect(result.jobId).toBe('j1'); + expect(result.snapshotId).toBe('snap-1'); + expect(result.snapshotVersion).toBe('ai_snapshot_v1'); + }); + + it('throws NotFoundException when job not found', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(null); + + await expect(service.getSnapshot('j1')).rejects.toMatchObject({ + response: expect.objectContaining({ errorCode: 'JOB_NOT_FOUND' }), + }); + }); + + it('throws SNAPSHOT_NOT_FOUND when job has no snapshotId', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ + id: 'j1', snapshotId: null, + }); + + await expect(service.getSnapshot('j1')).rejects.toMatchObject({ + response: expect.objectContaining({ errorCode: 'SNAPSHOT_NOT_FOUND' }), + }); + }); + + it('throws SNAPSHOT_NOT_FOUND when snapshot does not exist', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ + id: 'j1', snapshotId: 'snap-1', + }); + mockLearningAnalysisSnapshot.findUnique.mockResolvedValue(null); + + await expect(service.getSnapshot('j1')).rejects.toMatchObject({ + response: expect.objectContaining({ errorCode: 'SNAPSHOT_NOT_FOUND' }), + }); + }); + + it('throws SNAPSHOT_EXPIRED when snapshot is expired', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ + id: 'j1', snapshotId: 'snap-1', + }); + mockLearningAnalysisSnapshot.findUnique.mockResolvedValue({ + ...mockSnapshot, + expiresAt: new Date(Date.now() - 3600_000), + }); + + await expect(service.getSnapshot('j1')).rejects.toMatchObject({ + response: expect.objectContaining({ errorCode: 'SNAPSHOT_EXPIRED' }), + }); + }); + + it('returns snapshot when expiresAt is null (no expiry)', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ + id: 'j1', snapshotId: 'snap-1', + }); + mockLearningAnalysisSnapshot.findUnique.mockResolvedValue({ + ...mockSnapshot, + expiresAt: null, + }); + + const result = await service.getSnapshot('j1'); + + expect(result.snapshotId).toBe('snap-1'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // resolveCredential + // ═══════════════════════════════════════════════════════════════════ + + describe('resolveCredential', () => { + it('resolves credential for user_deepseek_key mode', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ userId: 'u1' }); + mockUserAi.resolveCredentialForJob.mockResolvedValue({ + provider: 'deepseek', + apiKey: 'sk-test', + }); + + const result = await service.resolveCredential('j1', 'user_deepseek_key', 'deepseek', 'cred-1'); + + expect(mockUserAi.resolveCredentialForJob).toHaveBeenCalledWith('u1', 'cred-1'); + expect(result.apiKey).toBe('sk-test'); + expect(result.apiKeyMode).toBe('user_deepseek_key'); + }); + + it('throws BadRequestException when credentialId missing for user_deepseek_key', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ userId: 'u1' }); + + await expect( + service.resolveCredential('j1', 'user_deepseek_key', 'deepseek'), + ).rejects.toMatchObject({ + response: expect.objectContaining({ errorCode: 'CREDENTIAL_NOT_FOUND' }), + }); + }); + + it('returns empty apiKey for platform_key mode', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ userId: 'u1' }); + + const result = await service.resolveCredential('j1', 'platform_key', 'deepseek'); + + expect(result.apiKey).toBe(''); + expect(result.apiKeyMode).toBe('platform_key'); + }); + + it('throws NotFoundException when job not found', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(null); + + await expect( + service.resolveCredential('j1', 'platform_key', 'deepseek'), + ).rejects.toMatchObject({ + response: expect.objectContaining({ errorCode: 'JOB_NOT_FOUND' }), + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // submitResult + // ═══════════════════════════════════════════════════════════════════ + + describe('submitResult', () => { + const dto = { + runtimeInstanceId: 'rt-1', + schemaVersion: 'ov1', + status: 'succeeded', + validatedOutput: { learningState: 'good' }, + attemptNo: 1, + outputHash: 'hash1', + }; + + const job = { + id: 'j1', userId: 'u1', jobType: 'learning_state_analysis', + status: 'running', outputSchemaVersion: 'ov1', + }; + + it('stores result and marks job succeeded', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(job); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue(null); + mockAiRuntimeResult.create.mockResolvedValue({}); + mockAiRuntimeJob.update.mockResolvedValue({}); + + const result = await service.submitResult('j1', dto); + + expect(mockAiRuntimeResult.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + jobId: 'j1', + userId: 'u1', + status: 'succeeded', + }), + }), + ); + expect(mockAiRuntimeJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'j1' }, + data: expect.objectContaining({ + status: 'succeeded', + finishedAt: expect.any(Date), + }), + }), + ); + expect(result.status).toBe('ok'); + expect(result.duplicate).toBe(false); + }); + + it('throws NotFoundException when job not found', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(null); + + await expect(service.submitResult('j1', dto)).rejects.toMatchObject({ + response: expect.objectContaining({ errorCode: 'JOB_NOT_FOUND' }), + }); + }); + + it('throws ConflictException when job is not locked or running', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ ...job, status: 'succeeded' }); + + await expect(service.submitResult('j1', dto)).rejects.toMatchObject({ + response: expect.objectContaining({ errorCode: 'JOB_NOT_ACTIVE' }), + }); + }); + + it('throws BadRequestException when schemaVersion mismatch', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ ...job, outputSchemaVersion: 'ov2' }); + + await expect(service.submitResult('j1', dto)).rejects.toMatchObject({ + response: expect.objectContaining({ errorCode: 'RESULT_SCHEMA_UNSUPPORTED' }), + }); + }); + + it('returns duplicate=true when same idempotency key exists', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(job); + mockAiRuntimeResult.findFirst.mockResolvedValue({ id: 'r1' }); + + const result = await service.submitResult('j1', dto); + + expect(result.duplicate).toBe(true); + expect(mockAiRuntimeResult.create).not.toHaveBeenCalled(); + }); + + it('throws ConflictException when job already has a succeeded result with different hash', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(job); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue({ id: 'r-old', status: 'succeeded' }); + + await expect(service.submitResult('j1', dto)).rejects.toMatchObject({ + response: expect.objectContaining({ errorCode: 'RESULT_ALREADY_EXISTS' }), + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // submitFailure + // ═══════════════════════════════════════════════════════════════════ + + describe('submitFailure', () => { + const job = { + id: 'j1', userId: 'u1', jobType: 'quiz_generation', + status: 'running', retryCount: 0, maxRetryCount: 3, + }; + + it('re-enqueues as pending when retryable and not exceeded', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(job); + mockAiRuntimeJob.update.mockResolvedValue({}); + + const result = await service.submitFailure('j1', { + runtimeInstanceId: 'rt-1', errorCode: 'MODEL_TIMEOUT', + errorMessage: 'Timeout', retryable: true, + }); + + expect(mockAiRuntimeJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'j1' }, + data: expect.objectContaining({ + status: 'pending', lockedBy: null, retryCount: 1, + }), + }), + ); + expect(result.status).toBe('pending'); + expect(result.retryCount).toBe(1); + }); + + it('marks failed when retryable but retries exceeded', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ ...job, retryCount: 3 }); + mockAiRuntimeJob.update.mockResolvedValue({}); + + const result = await service.submitFailure('j1', { + runtimeInstanceId: 'rt-1', errorCode: 'MODEL_TIMEOUT', + errorMessage: 'Timeout', retryable: true, + }); + + expect(mockAiRuntimeJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'j1' }, + data: expect.objectContaining({ + status: 'failed', finishedAt: expect.any(Date), retryCount: 4, + }), + }), + ); + expect(result.status).toBe('failed'); + }); + + it('marks failed when not retryable (DB update)', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(job); + mockAiRuntimeJob.update.mockResolvedValue({}); + + await service.submitFailure('j1', { + runtimeInstanceId: 'rt-1', errorCode: 'INVALID_OUTPUT', + errorMessage: 'Bad JSON', retryable: false, + }); + + expect(mockAiRuntimeJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'j1' }, + data: expect.objectContaining({ status: 'failed' }), + }), + ); + }); + + it('throws NotFoundException when job not found', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(null); + + await expect(service.submitFailure('j1', { + runtimeInstanceId: 'rt-1', errorCode: 'ERR', errorMessage: 'x', retryable: false, + })).rejects.toMatchObject({ + response: expect.objectContaining({ errorCode: 'JOB_NOT_FOUND' }), + }); + }); + + it('throws ConflictException when job is not active', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ ...job, status: 'succeeded' }); + + await expect(service.submitFailure('j1', { + runtimeInstanceId: 'rt-1', errorCode: 'ERR', errorMessage: 'x', retryable: false, + })).rejects.toMatchObject({ + response: expect.objectContaining({ errorCode: 'JOB_NOT_ACTIVE' }), + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // submitInvocationLogs + // ═══════════════════════════════════════════════════════════════════ + + describe('submitInvocationLogs', () => { + const logEntry = { + jobId: 'j1', provider: 'deepseek', model: 'deepseek-chat', + apiKeyMode: 'platform_key', promptName: 'quiz_gen', + promptVersion: 'v1', outputSchemaVersion: 'ov1', + inputTokens: 100, outputTokens: 200, totalTokens: 300, + latencyMs: 1500, success: true, + retryCount: 0, runtimeInstanceId: 'rt-1', + }; + + it('creates invocation logs for valid jobs', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ userId: 'u1' }); + mockModelInvocationLog.create.mockResolvedValue({}); + + const result = await service.submitInvocationLogs([logEntry]); + + expect(mockModelInvocationLog.create).toHaveBeenCalledTimes(1); + expect(result.accepted).toBe(1); + }); + + it('skips logs for non-existent jobs', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(null); + + const result = await service.submitInvocationLogs([logEntry]); + + expect(mockModelInvocationLog.create).not.toHaveBeenCalled(); + expect(result.accepted).toBe(0); + }); + + it('handles mixed valid and invalid job logs', async () => { + mockAiRuntimeJob.findUnique + .mockResolvedValueOnce({ userId: 'u1' }) + .mockResolvedValueOnce(null); + mockModelInvocationLog.create.mockResolvedValue({}); + + const result = await service.submitInvocationLogs([ + logEntry, + { ...logEntry, jobId: 'j2' }, + ]); + + expect(mockModelInvocationLog.create).toHaveBeenCalledTimes(1); + expect(result.accepted).toBe(1); + }); + + it('returns accepted=0 for empty logs array', async () => { + const result = await service.submitInvocationLogs([]); + + expect(result.accepted).toBe(0); + expect(mockModelInvocationLog.create).not.toHaveBeenCalled(); + }); + }); +});