test: add unit tests for runtime-internal.service (API-AI-062)
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 46s
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
012e26b950
commit
02979e3c24
623
src/modules/ai-runtime/internal/runtime-internal.service.spec.ts
Normal file
623
src/modules/ai-runtime/internal/runtime-internal.service.spec.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user