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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-18 11:03:23 +08:00
parent 012e26b950
commit 02979e3c24

View 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();
});
});
});