diff --git a/src/modules/ai-runtime/internal/runtime-internal.service.spec.ts b/src/modules/ai-runtime/internal/runtime-internal.service.spec.ts index c261a23..aa3f34a 100644 --- a/src/modules/ai-runtime/internal/runtime-internal.service.spec.ts +++ b/src/modules/ai-runtime/internal/runtime-internal.service.spec.ts @@ -9,13 +9,25 @@ describe('RuntimeInternalService', () => { let mockAiRuntimeResult: any; let mockRuntimeInstance: any; let mockModelInvocationLog: any; + let mockAiLearningAnalysis: any; + let mockWeakPointCandidate: any; + let mockNextActionRecommendation: any; + let mockQuestionGenerationPlan: any; + let mockFlashcardGenerationPlan: any; + let mockQuiz: any; + let mockQuizQuestion: any; + let mockFlashcard: any; + let mockKnowledgeItem: any; + let mockNotification: any; // Service dependency mocks let mockUserAi: any; + let mockSnapshotBuilder: any; const mockSnapshot = { id: 'snap-1', snapshotVersion: 'ai_snapshot_v1', + sourceDataVersion: '1.0', expiresAt: new Date(Date.now() + 3600_000), privacyScope: { allowUserProfile: true }, userProfile: { displayName: 'Test' }, @@ -49,6 +61,16 @@ describe('RuntimeInternalService', () => { }; mockRuntimeInstance = { upsert: jest.fn() }; mockModelInvocationLog = { create: jest.fn() }; + mockAiLearningAnalysis = { create: jest.fn() }; + mockWeakPointCandidate = { updateMany: jest.fn(), create: jest.fn() }; + mockNextActionRecommendation = { updateMany: jest.fn(), create: jest.fn() }; + mockQuestionGenerationPlan = { updateMany: jest.fn() }; + mockFlashcardGenerationPlan = { updateMany: jest.fn() }; + mockQuiz = { create: jest.fn(), update: jest.fn() }; + mockQuizQuestion = { findFirst: jest.fn(), create: jest.fn() }; + mockFlashcard = { findFirst: jest.fn(), create: jest.fn() }; + mockKnowledgeItem = { findMany: jest.fn().mockResolvedValue([]) }; + mockNotification = { create: jest.fn() }; const mockPrisma = { aiRuntimeJob: mockAiRuntimeJob, @@ -56,13 +78,22 @@ describe('RuntimeInternalService', () => { aiRuntimeResult: mockAiRuntimeResult, runtimeInstance: mockRuntimeInstance, modelInvocationLog: mockModelInvocationLog, + aiLearningAnalysis: mockAiLearningAnalysis, + weakPointCandidate: mockWeakPointCandidate, + nextActionRecommendation: mockNextActionRecommendation, + questionGenerationPlan: mockQuestionGenerationPlan, + flashcardGenerationPlan: mockFlashcardGenerationPlan, + quiz: mockQuiz, + quizQuestion: mockQuizQuestion, + flashcard: mockFlashcard, + knowledgeItem: mockKnowledgeItem, + notification: mockNotification, } as any; - mockUserAi = { - resolveCredentialForJob: jest.fn(), - }; + mockUserAi = { resolveCredentialForJob: jest.fn() }; + mockSnapshotBuilder = { buildSnapshot: jest.fn() }; - service = new RuntimeInternalService(mockPrisma as any, mockUserAi); + service = new RuntimeInternalService(mockPrisma as any, mockUserAi, mockSnapshotBuilder); }); afterEach(() => { @@ -75,108 +106,64 @@ describe('RuntimeInternalService', () => { 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' }, + { id: 'j1', jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: 'kb1', priority: 1, snapshotId: 'snap-1', promptVersion: 'v1', outputSchemaVersion: 'ov1', apiKeyMode: 'platform_key', credentialId: null }, + { id: 'j2', jobType: 'flashcard_generation', targetType: 'material', targetId: 'm1', priority: 2, snapshotId: null, promptVersion: 'v1', outputSchemaVersion: 'ov2', apiKeyMode: 'platform_key', credentialId: null }, ]; 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 () => { + it('caps limit to 50 when no limit given (defaults to 5)', async () => { mockAiRuntimeJob.findMany.mockResolvedValue([]); - await service.pollJobs('rt-1', ['quiz_generation'], undefined as any); - - expect(mockAiRuntimeJob.findMany).toHaveBeenCalledWith( - expect.objectContaining({ take: 5 }), - ); + 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 }), - ); + 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'], - }); - + await service.pollJobs('rt-1', ['quiz_generation'], 10, { supportedOutputSchemaVersions: ['ov1', 'ov2'] }); expect(mockAiRuntimeJob.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ outputSchemaVersion: { in: ['ov1', 'ov2'] } }), - }), + expect.objectContaining({ where: expect.objectContaining({ outputSchemaVersion: { in: ['ov1', 'ov2'] } }) }), ); }); - it('registers/updates RuntimeInstance when capabilities have snapshot or output version info', async () => { + it('upserts 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' }, - }), - ); + await service.pollJobs('rt-1', ['quiz_generation'], 10, { supportedSnapshotVersions: ['ai_snapshot_v1'] }); + expect(mockRuntimeInstance.upsert).toHaveBeenCalled(); }); - it('does not upsert RuntimeInstance when no version capabilities provided', async () => { + it('does not upsert RuntimeInstance when no version capabilities', 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'], - }); - + 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 () => { + it('keeps jobs with compatible snapshot version', 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'], - }); - + 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); }); }); @@ -188,33 +175,14 @@ describe('RuntimeInternalService', () => { 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 () => { + it('throws ConflictException when job cannot be locked', async () => { mockAiRuntimeJob.updateMany.mockResolvedValue({ count: 0 }); - await expect(service.lockJob('j1', 'rt-1')).rejects.toMatchObject({ response: expect.objectContaining({ errorCode: 'JOB_ALREADY_LOCKED' }), }); @@ -226,34 +194,65 @@ describe('RuntimeInternalService', () => { // ═══════════════════════════════════════════════════════════════════ describe('heartbeatJob', () => { - it('updates lockUntil and status for locked or running job', async () => { - mockAiRuntimeJob.updateMany.mockResolvedValue({ count: 1 }); + it('transitions locked→running on first heartbeat (two-phase updateMany)', async () => { + mockAiRuntimeJob.updateMany + .mockResolvedValueOnce({ count: 1 }) // locked→running + .mockResolvedValueOnce({ count: 0 }); // running→running (no-op) + mockAiRuntimeJob.findUnique.mockResolvedValue({ cancelRequestedAt: null }); - await service.heartbeatJob('j1', 'rt-1'); + const result = await service.heartbeatJob('j1', 'rt-1'); - expect(mockAiRuntimeJob.updateMany).toHaveBeenCalledWith( + expect(mockAiRuntimeJob.updateMany).toHaveBeenCalledTimes(2); + // First call: locked→running with startedAt + expect(mockAiRuntimeJob.updateMany).toHaveBeenNthCalledWith(1, expect.objectContaining({ - where: { - id: 'j1', - lockedBy: 'rt-1', - status: { in: ['locked', 'running'] }, - }, - data: { - lockUntil: expect.any(Date), - status: 'running', - startedAt: expect.any(Date), - }, + where: { id: 'j1', lockedBy: 'rt-1', status: 'locked' }, + data: expect.objectContaining({ status: 'running', startedAt: expect.any(Date) }), }), ); + // Second call: running→running (extend lock only) + expect(mockAiRuntimeJob.updateMany).toHaveBeenNthCalledWith(2, + expect.objectContaining({ + where: { id: 'j1', lockedBy: 'rt-1', status: 'running' }, + data: { lockUntil: expect.any(Date) }, + }), + ); + expect(result.jobId).toBe('j1'); + expect(result.lockUntil).toBeGreaterThan(Date.now()); + expect(result.cancelRequested).toBe(false); }); - it('throws NotFoundException when job not locked by this runtime', async () => { - mockAiRuntimeJob.updateMany.mockResolvedValue({ count: 0 }); + it('extends lock on running→running subsequent heartbeat', async () => { + mockAiRuntimeJob.updateMany + .mockResolvedValueOnce({ count: 0 }) // locked→running (no-op) + .mockResolvedValueOnce({ count: 1 }); // running→running + mockAiRuntimeJob.findUnique.mockResolvedValue({ cancelRequestedAt: null }); + + const result = await service.heartbeatJob('j1', 'rt-1'); + + expect(result.cancelRequested).toBe(false); + }); + + it('throws NotFoundException when neither locked nor running', async () => { + mockAiRuntimeJob.updateMany + .mockResolvedValueOnce({ count: 0 }) + .mockResolvedValueOnce({ count: 0 }); await expect(service.heartbeatJob('j1', 'rt-1')).rejects.toMatchObject({ response: expect.objectContaining({ errorCode: 'JOB_NOT_FOUND' }), }); }); + + it('returns cancelRequested=true when cancellation was requested', async () => { + mockAiRuntimeJob.updateMany + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 0 }); + mockAiRuntimeJob.findUnique.mockResolvedValue({ cancelRequestedAt: new Date() }); + + const result = await service.heartbeatJob('j1', 'rt-1'); + + expect(result.cancelRequested).toBe(true); + }); }); // ═══════════════════════════════════════════════════════════════════ @@ -261,75 +260,71 @@ describe('RuntimeInternalService', () => { // ═══════════════════════════════════════════════════════════════════ describe('getSnapshot', () => { - it('returns snapshot data when job has valid snapshot', async () => { - mockAiRuntimeJob.findUnique.mockResolvedValue({ - id: 'j1', snapshotId: 'snap-1', - }); + const jobWithSnapshot = { id: 'j1', userId: 'u1', targetType: 'material', targetId: 'm1', snapshotId: 'snap-1' }; + + it('returns existing valid snapshot (matching sourceDataVersion + not expired)', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(jobWithSnapshot); 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'); + expect(mockSnapshotBuilder.buildSnapshot).not.toHaveBeenCalled(); }); 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, - }); + it('rebuilds snapshot when job has no snapshotId', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ id: 'j1', userId: 'u1', targetType: 'material', targetId: 'm1', snapshotId: null }); + mockSnapshotBuilder.buildSnapshot.mockResolvedValue(mockSnapshot); + mockAiRuntimeJob.update.mockResolvedValue({}); const result = await service.getSnapshot('j1'); + expect(mockSnapshotBuilder.buildSnapshot).toHaveBeenCalledWith('u1', 'material', 'm1'); + expect(mockAiRuntimeJob.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'j1' }, data: { snapshotId: 'snap-1' }, + })); expect(result.snapshotId).toBe('snap-1'); }); + + it('rebuilds snapshot when existing snapshot has stale sourceDataVersion', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(jobWithSnapshot); + mockLearningAnalysisSnapshot.findUnique.mockResolvedValue({ ...mockSnapshot, sourceDataVersion: '0.9' }); + mockSnapshotBuilder.buildSnapshot.mockResolvedValue({ ...mockSnapshot, id: 'snap-new' }); + mockAiRuntimeJob.update.mockResolvedValue({}); + + const result = await service.getSnapshot('j1'); + + expect(mockSnapshotBuilder.buildSnapshot).toHaveBeenCalled(); + expect(result.snapshotId).toBe('snap-new'); + }); + + it('rebuilds snapshot when existing snapshot is expired', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(jobWithSnapshot); + mockLearningAnalysisSnapshot.findUnique.mockResolvedValue({ ...mockSnapshot, expiresAt: new Date(Date.now() - 3600_000) }); + mockSnapshotBuilder.buildSnapshot.mockResolvedValue({ ...mockSnapshot, id: 'snap-new' }); + mockAiRuntimeJob.update.mockResolvedValue({}); + + const result = await service.getSnapshot('j1'); + + expect(mockSnapshotBuilder.buildSnapshot).toHaveBeenCalled(); + expect(result.snapshotId).toBe('snap-new'); + }); + + it('returns existing snapshot when expiresAt is null (no expiry)', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(jobWithSnapshot); + mockLearningAnalysisSnapshot.findUnique.mockResolvedValue({ ...mockSnapshot, expiresAt: null }); + const result = await service.getSnapshot('j1'); + expect(result.snapshotId).toBe('snap-1'); + expect(mockSnapshotBuilder.buildSnapshot).not.toHaveBeenCalled(); + }); }); // ═══════════════════════════════════════════════════════════════════ @@ -339,43 +334,29 @@ describe('RuntimeInternalService', () => { describe('resolveCredential', () => { it('resolves credential for user_deepseek_key mode', async () => { mockAiRuntimeJob.findUnique.mockResolvedValue({ userId: 'u1' }); - mockUserAi.resolveCredentialForJob.mockResolvedValue({ - provider: 'deepseek', - apiKey: 'sk-test', - }); - + 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({ + 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({ + await expect(service.resolveCredential('j1', 'platform_key', 'deepseek')).rejects.toMatchObject({ response: expect.objectContaining({ errorCode: 'JOB_NOT_FOUND' }), }); }); @@ -389,51 +370,43 @@ describe('RuntimeInternalService', () => { const dto = { runtimeInstanceId: 'rt-1', schemaVersion: 'ov1', - status: 'succeeded', + status: 'succeeded' as const, validatedOutput: { learningState: 'good' }, attemptNo: 1, outputHash: 'hash1', }; const job = { - id: 'j1', userId: 'u1', jobType: 'learning_state_analysis', + id: 'j1', userId: 'u1', jobType: 'learning_state_analysis' as const, + targetType: 'material', targetId: 'm1', status: 'running', outputSchemaVersion: 'ov1', + snapshotId: 'snap-1', promptVersion: 'v1', }; - it('stores result and marks job succeeded', async () => { + beforeEach(() => { + mockNotification.create.mockResolvedValue({}); + }); + + it('stores result and marks job succeeded with finishedAt', async () => { mockAiRuntimeJob.findUnique.mockResolvedValue(job); mockAiRuntimeResult.findFirst.mockResolvedValue(null); mockAiRuntimeResult.findUnique.mockResolvedValue(null); mockAiRuntimeResult.create.mockResolvedValue({}); mockAiRuntimeJob.update.mockResolvedValue({}); + mockAiLearningAnalysis.create.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(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' }), }); @@ -441,7 +414,6 @@ describe('RuntimeInternalService', () => { 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' }), }); @@ -449,7 +421,6 @@ describe('RuntimeInternalService', () => { 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' }), }); @@ -458,22 +429,197 @@ describe('RuntimeInternalService', () => { 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 () => { + it('throws ConflictException when job already has succeeded result', 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' }), }); }); + + it('creates success notification', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(job); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue(null); + mockAiRuntimeResult.create.mockResolvedValue({}); + mockAiRuntimeJob.update.mockResolvedValue({}); + mockAiLearningAnalysis.create.mockResolvedValue({}); + + await service.submitResult('j1', dto); + + expect(mockNotification.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ type: 'ai_job_succeeded' }), + })); + }); + + // ── persistResult: job type routing ── + + it('persists learning_state_analysis output', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(job); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue(null); + mockAiRuntimeResult.create.mockResolvedValue({}); + mockAiRuntimeJob.update.mockResolvedValue({}); + mockAiLearningAnalysis.create.mockResolvedValue({}); + + await service.submitResult('j1', dto); + + expect(mockAiLearningAnalysis.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ userId: 'u1', jobId: 'j1', learningState: 'good' }), + })); + }); + + it('persists weak_point_analysis output and resolves prior active candidates', async () => { + const wpDto = { ...dto, validatedOutput: { candidates: [{ knowledgePointId: 'kp1', title: 'Weak Spot' }] } }; + const wpJob = { ...job, jobType: 'weak_point_analysis' as const }; + mockAiRuntimeJob.findUnique.mockResolvedValue(wpJob); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue(null); + mockAiRuntimeResult.create.mockResolvedValue({}); + mockAiRuntimeJob.update.mockResolvedValue({}); + mockWeakPointCandidate.updateMany.mockResolvedValue({ count: 1 }); + mockWeakPointCandidate.create.mockResolvedValue({}); + + await service.submitResult('j1', wpDto); + + expect(mockWeakPointCandidate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { userId: 'u1', targetType: 'material', targetId: 'm1', status: 'active' }, + data: { status: 'resolved' }, + })); + expect(mockWeakPointCandidate.create).toHaveBeenCalledTimes(1); + }); + + it('persists next_action_planning output', async () => { + const naDto = { ...dto, validatedOutput: { actions: [{ actionType: 'review', title: 'Review Ch1' }] } }; + const naJob = { ...job, jobType: 'next_action_planning' as const }; + mockAiRuntimeJob.findUnique.mockResolvedValue(naJob); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue(null); + mockAiRuntimeResult.create.mockResolvedValue({}); + mockAiRuntimeJob.update.mockResolvedValue({}); + mockNextActionRecommendation.updateMany.mockResolvedValue({ count: 1 }); + mockNextActionRecommendation.create.mockResolvedValue({}); + + await service.submitResult('j1', naDto); + + expect(mockNextActionRecommendation.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ title: 'Review Ch1', status: 'active' }), + })); + }); + + it('persists quiz_generation output (creates quiz + questions)', async () => { + const qDto = { ...dto, validatedOutput: { questions: [{ stem: 'Q1?', answer: 'A1' }] } }; + const qJob = { ...job, jobType: 'quiz_generation' as const, targetType: 'knowledge_base' as const, targetId: 'kb1' }; + mockAiRuntimeJob.findUnique.mockResolvedValue(qJob); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue(null); + mockAiRuntimeResult.create.mockResolvedValue({}); + mockAiRuntimeJob.update.mockResolvedValue({}); + mockQuestionGenerationPlan.updateMany.mockResolvedValue({ count: 1 }); + mockQuiz.create.mockResolvedValue({ id: 'quiz-1' }); + mockQuizQuestion.findFirst.mockResolvedValue(null); + mockQuizQuestion.create.mockResolvedValue({}); + + await service.submitResult('j1', qDto); + + expect(mockQuiz.create).toHaveBeenCalled(); + expect(mockQuizQuestion.create).toHaveBeenCalledTimes(1); + }); + + it('persists flashcard_generation output', async () => { + const fcDto = { ...dto, validatedOutput: { cards: [{ front: 'Front', back: 'Back' }] } }; + const fcJob = { ...job, jobType: 'flashcard_generation' as const }; + mockAiRuntimeJob.findUnique.mockResolvedValue(fcJob); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue(null); + mockAiRuntimeResult.create.mockResolvedValue({}); + mockAiRuntimeJob.update.mockResolvedValue({}); + mockFlashcardGenerationPlan.updateMany.mockResolvedValue({ count: 1 }); + mockFlashcard.findFirst.mockResolvedValue(null); + mockFlashcard.create.mockResolvedValue({}); + + await service.submitResult('j1', fcDto); + + expect(mockFlashcardGenerationPlan.updateMany).toHaveBeenCalled(); + expect(mockFlashcard.create).toHaveBeenCalledTimes(1); + }); + + it('skips persistResult when validatedOutput is empty', async () => { + const noOutputDto = { ...dto, validatedOutput: undefined }; + mockAiRuntimeJob.findUnique.mockResolvedValue(job); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue(null); + mockAiRuntimeResult.create.mockResolvedValue({}); + mockAiRuntimeJob.update.mockResolvedValue({}); + + await service.submitResult('j1', noOutputDto); + + expect(mockAiLearningAnalysis.create).not.toHaveBeenCalled(); + }); + + // ── validateOutput warnings (via submitResult) ── + + it('logs warning on missing learningState for learning_state_analysis', async () => { + const loggerSpy = jest.spyOn((service as any).logger, 'warn'); + mockAiRuntimeJob.findUnique.mockResolvedValue(job); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue(null); + mockAiRuntimeResult.create.mockResolvedValue({}); + mockAiRuntimeJob.update.mockResolvedValue({}); + mockAiLearningAnalysis.create.mockResolvedValue({}); + + await service.submitResult('j1', { ...dto, validatedOutput: { summary: 'no learningState' } }); + + expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('missing learningState')); + }); + + it('logs warning on confidence out of range [0,1]', async () => { + const loggerSpy = jest.spyOn((service as any).logger, 'warn'); + mockAiRuntimeJob.findUnique.mockResolvedValue(job); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue(null); + mockAiRuntimeResult.create.mockResolvedValue({}); + mockAiRuntimeJob.update.mockResolvedValue({}); + mockAiLearningAnalysis.create.mockResolvedValue({}); + + await service.submitResult('j1', { ...dto, validatedOutput: { learningState: 'good', confidence: 1.5 } }); + + expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('confidence out of range')); + }); + + it('logs warning when weak_point_analysis has no candidates', async () => { + const loggerSpy = jest.spyOn((service as any).logger, 'warn'); + const wpJob = { ...job, jobType: 'weak_point_analysis' as const }; + mockAiRuntimeJob.findUnique.mockResolvedValue(wpJob); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue(null); + mockAiRuntimeResult.create.mockResolvedValue({}); + mockAiRuntimeJob.update.mockResolvedValue({}); + + await service.submitResult('j1', { ...dto, validatedOutput: { candidates: [] } }); + + expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('no candidates')); + }); + + it('logs warning when next_action_planning has no actions', async () => { + const loggerSpy = jest.spyOn((service as any).logger, 'warn'); + const naJob = { ...job, jobType: 'next_action_planning' as const }; + mockAiRuntimeJob.findUnique.mockResolvedValue(naJob); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue(null); + mockAiRuntimeResult.create.mockResolvedValue({}); + mockAiRuntimeJob.update.mockResolvedValue({}); + + await service.submitResult('j1', { ...dto, validatedOutput: { actions: [] } }); + + expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('no actions')); + }); }); // ═══════════════════════════════════════════════════════════════════ @@ -482,29 +628,38 @@ describe('RuntimeInternalService', () => { describe('submitFailure', () => { const job = { - id: 'j1', userId: 'u1', jobType: 'quiz_generation', - status: 'running', retryCount: 0, maxRetryCount: 3, + id: 'j1', userId: 'u1', jobType: 'quiz_generation' as const, + status: 'running' as const, retryCount: 0, maxRetryCount: 3, }; + it('handles JOB_CANCELLED error code', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(job); + mockAiRuntimeJob.update.mockResolvedValue({}); + + const result = await service.submitFailure('j1', { + runtimeInstanceId: 'rt-1', errorCode: 'JOB_CANCELLED', errorMessage: 'Cancelled', retryable: false, + }); + + expect(mockAiRuntimeJob.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'j1' }, + data: expect.objectContaining({ status: 'cancelled', cancelledAt: expect.any(Date) }), + })); + expect(result.status).toBe('cancelled'); + }); + 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, + 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(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 () => { @@ -512,18 +667,13 @@ describe('RuntimeInternalService', () => { mockAiRuntimeJob.update.mockResolvedValue({}); const result = await service.submitFailure('j1', { - runtimeInstanceId: 'rt-1', errorCode: 'MODEL_TIMEOUT', - errorMessage: 'Timeout', retryable: true, + 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(mockAiRuntimeJob.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'j1' }, + data: expect.objectContaining({ status: 'failed', finishedAt: expect.any(Date), retryCount: 4 }), + })); expect(result.status).toBe('failed'); }); @@ -532,21 +682,31 @@ describe('RuntimeInternalService', () => { mockAiRuntimeJob.update.mockResolvedValue({}); await service.submitFailure('j1', { - runtimeInstanceId: 'rt-1', errorCode: 'INVALID_OUTPUT', - errorMessage: 'Bad JSON', retryable: false, + 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' }), - }), - ); + expect(mockAiRuntimeJob.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'j1' }, + data: expect.objectContaining({ status: 'failed' }), + })); + }); + + it('creates failure notification when retries exceeded', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue({ ...job, retryCount: 3 }); + mockAiRuntimeJob.update.mockResolvedValue({}); + mockNotification.create.mockResolvedValue({}); + + await service.submitFailure('j1', { + runtimeInstanceId: 'rt-1', errorCode: 'MODEL_TIMEOUT', errorMessage: 'Timeout', retryable: true, + }); + + expect(mockNotification.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ type: 'ai_job_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({ @@ -556,7 +716,6 @@ describe('RuntimeInternalService', () => { 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({ @@ -582,18 +741,14 @@ describe('RuntimeInternalService', () => { 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); }); @@ -603,21 +758,194 @@ describe('RuntimeInternalService', () => { .mockResolvedValueOnce({ userId: 'u1' }) .mockResolvedValueOnce(null); mockModelInvocationLog.create.mockResolvedValue({}); - - const result = await service.submitInvocationLogs([ - logEntry, - { ...logEntry, jobId: 'j2' }, - ]); - + 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(); }); }); + + // ═══════════════════════════════════════════════════════════════════ + // resolveSnapshot (via getSnapshot) + // ═══════════════════════════════════════════════════════════════════ + + describe('resolveSnapshot (via getSnapshot)', () => { + const job = { id: 'j1', userId: 'u1', targetType: 'material', targetId: 'm1', snapshotId: 'snap-old' }; + + it('rebuilds when existing snapshot has both wrong version and expired', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(job); + mockLearningAnalysisSnapshot.findUnique.mockResolvedValue({ + ...mockSnapshot, id: 'snap-old', sourceDataVersion: '0.8', + expiresAt: new Date(Date.now() - 3600_000), + }); + mockSnapshotBuilder.buildSnapshot.mockResolvedValue({ ...mockSnapshot, id: 'snap-new' }); + mockAiRuntimeJob.update.mockResolvedValue({}); + + const result = await service.getSnapshot('j1'); + + expect(mockSnapshotBuilder.buildSnapshot).toHaveBeenCalled(); + expect(result.snapshotId).toBe('snap-new'); + }); + + it('rebuilds when snapshot does not exist in DB', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(job); + mockLearningAnalysisSnapshot.findUnique.mockResolvedValue(null); + mockSnapshotBuilder.buildSnapshot.mockResolvedValue(mockSnapshot); + mockAiRuntimeJob.update.mockResolvedValue({}); + + const result = await service.getSnapshot('j1'); + + expect(mockSnapshotBuilder.buildSnapshot).toHaveBeenCalledWith('u1', 'material', 'm1'); + expect(result.snapshotId).toBe('snap-1'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // convertQuizCandidates (via submitResult) + // ═══════════════════════════════════════════════════════════════════ + + describe('convertQuizCandidates (via submitResult)', () => { + const qJob = { + id: 'j1', userId: 'u1', jobType: 'quiz_generation' as const, + targetType: 'knowledge_base' as const, targetId: 'kb1', + status: 'running' as const, outputSchemaVersion: 'ov1', + snapshotId: 'snap-1', promptVersion: 'v1', + }; + + beforeEach(() => { + mockNotification.create.mockResolvedValue({}); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue(null); + mockAiRuntimeResult.create.mockResolvedValue({}); + mockAiRuntimeJob.update.mockResolvedValue({}); + mockQuestionGenerationPlan.updateMany.mockResolvedValue({ count: 1 }); + mockQuiz.create.mockResolvedValue({ id: 'quiz-1' }); + mockQuizQuestion.findFirst.mockResolvedValue(null); + mockQuizQuestion.create.mockResolvedValue({}); + }); + + it('skips questions missing stem or answer', async () => { + const loggerSpy = jest.spyOn((service as any).logger, 'warn'); + mockAiRuntimeJob.findUnique.mockResolvedValue(qJob); + + await service.submitResult('j1', { + runtimeInstanceId: 'rt-1', schemaVersion: 'ov1', status: 'succeeded', + validatedOutput: { questions: [{ stem: 'Valid', answer: 'A' }, { stem: '', answer: '' }] }, + attemptNo: 1, outputHash: 'h1', + }); + + expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('skipped 1 question')); + }); + + it('returns early when all questions are invalid', async () => { + mockAiRuntimeJob.findUnique.mockResolvedValue(qJob); + + await service.submitResult('j1', { + runtimeInstanceId: 'rt-1', schemaVersion: 'ov1', status: 'succeeded', + validatedOutput: { questions: [{ stem: '', answer: '' }] }, + attemptNo: 1, outputHash: 'h2', + }); + + expect(mockQuiz.create).not.toHaveBeenCalled(); + }); + + it('skips duplicate questions (same stem for user)', async () => { + const loggerSpy = jest.spyOn((service as any).logger, 'warn'); + mockAiRuntimeJob.findUnique.mockResolvedValue(qJob); + mockQuizQuestion.findFirst.mockResolvedValueOnce({ id: 'existing-q' }); // duplicate + mockQuizQuestion.findFirst.mockResolvedValueOnce(null); // unique + mockQuiz.update.mockResolvedValue({}); + + await service.submitResult('j1', { + runtimeInstanceId: 'rt-1', schemaVersion: 'ov1', status: 'succeeded', + validatedOutput: { + questions: [ + { stem: 'Duplicate?', answer: 'A' }, + { stem: 'Unique?', answer: 'B' }, + ], + }, + attemptNo: 1, outputHash: 'h3', + }); + + expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('skipping duplicate question')); + expect(mockQuiz.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'quiz-1' }, data: { questionCount: 1 }, + })); + }); + + it('does not create quiz when targetType is not knowledge_base', async () => { + const nonKbJob = { ...qJob, targetType: 'material' as const }; + mockAiRuntimeJob.findUnique.mockResolvedValue(nonKbJob); + + await service.submitResult('j1', { + runtimeInstanceId: 'rt-1', schemaVersion: 'ov1', status: 'succeeded', + validatedOutput: { questions: [{ stem: 'Q?', answer: 'A' }] }, + attemptNo: 1, outputHash: 'h4', + }); + + expect(mockQuiz.create).not.toHaveBeenCalled(); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // convertFlashcardCandidates (via submitResult) + // ═══════════════════════════════════════════════════════════════════ + + describe('convertFlashcardCandidates (via submitResult)', () => { + const fcJob = { + id: 'j1', userId: 'u1', jobType: 'flashcard_generation' as const, + targetType: 'material' as const, targetId: 'm1', + status: 'running' as const, outputSchemaVersion: 'ov1', + snapshotId: 'snap-1', promptVersion: 'v1', + }; + + beforeEach(() => { + mockNotification.create.mockResolvedValue({}); + mockAiRuntimeResult.findFirst.mockResolvedValue(null); + mockAiRuntimeResult.findUnique.mockResolvedValue(null); + mockAiRuntimeResult.create.mockResolvedValue({}); + mockAiRuntimeJob.update.mockResolvedValue({}); + mockFlashcardGenerationPlan.updateMany.mockResolvedValue({ count: 1 }); + mockFlashcard.findFirst.mockResolvedValue(null); + mockFlashcard.create.mockResolvedValue({}); + }); + + it('skips cards missing front or back', async () => { + const loggerSpy = jest.spyOn((service as any).logger, 'warn'); + mockAiRuntimeJob.findUnique.mockResolvedValue(fcJob); + + await service.submitResult('j1', { + runtimeInstanceId: 'rt-1', schemaVersion: 'ov1', status: 'succeeded', + validatedOutput: { cards: [{ front: 'F', back: 'B' }, { front: '', back: '' }] }, + attemptNo: 1, outputHash: 'h1', + }); + + expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('skipped 1 card')); + }); + + it('skips duplicate cards (same front for user)', async () => { + const loggerSpy = jest.spyOn((service as any).logger, 'warn'); + mockAiRuntimeJob.findUnique.mockResolvedValue(fcJob); + mockFlashcard.findFirst.mockResolvedValueOnce({ id: 'dup-1' }); + mockFlashcard.findFirst.mockResolvedValueOnce(null); + + await service.submitResult('j1', { + runtimeInstanceId: 'rt-1', schemaVersion: 'ov1', status: 'succeeded', + validatedOutput: { + cards: [ + { front: 'Dup', back: 'B1' }, + { front: 'Unique', back: 'B2' }, + ], + }, + attemptNo: 1, outputHash: 'h2', + }); + + expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('skipping duplicate')); + }); + }); });