From 3e43b2b52dd917f2bd703c2e4655781948054b45 Mon Sep 17 00:00:00 2001 From: wangdl Date: Thu, 18 Jun 2026 12:19:06 +0800 Subject: [PATCH] test: add unit tests for runtime-internal.controller (API-AI-067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 12 tests covering all 9 endpoints + instanceId extraction - instanceId: from header / default "unknown" - pollJobs: limit default 5, capabilities pass-through - lockJob/heartbeatJob: runtimeInstanceId fallback from dto→header - getSnapshot: direct delegation - resolveCredential: all fields + undefined credentialId - submitResult/submitFailure: entire dto pass-through - submitInvocationLogs: logs array extraction Co-Authored-By: Claude Opus 4.7 --- .../runtime-internal.controller.spec.ts | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 src/modules/ai-runtime/internal/runtime-internal.controller.spec.ts diff --git a/src/modules/ai-runtime/internal/runtime-internal.controller.spec.ts b/src/modules/ai-runtime/internal/runtime-internal.controller.spec.ts new file mode 100644 index 0000000..bad19b1 --- /dev/null +++ b/src/modules/ai-runtime/internal/runtime-internal.controller.spec.ts @@ -0,0 +1,191 @@ +import { RuntimeInternalController } from './runtime-internal.controller'; + +describe('RuntimeInternalController', () => { + let controller: RuntimeInternalController; + let mockService: any; + + beforeEach(() => { + mockService = { + pollJobs: jest.fn(), + lockJob: jest.fn(), + heartbeatJob: jest.fn(), + getSnapshot: jest.fn(), + resolveCredential: jest.fn(), + submitResult: jest.fn(), + submitFailure: jest.fn(), + submitInvocationLogs: jest.fn(), + }; + controller = new RuntimeInternalController(mockService); + }); + + const req = (instanceId?: string) => + ({ headers: { 'x-runtime-instance-id': instanceId } }) as any; + + // ═══════════════════════════════════════════════════════════════════ + // instanceId extraction + // ═══════════════════════════════════════════════════════════════════ + + describe('instanceId extraction', () => { + it('extracts x-runtime-instance-id from header', async () => { + const dto = { supportedJobTypes: ['quiz_generation'], limit: 10, capabilities: {} }; + mockService.pollJobs.mockResolvedValue({ jobs: [] }); + + await controller.pollJobs(req('rt-001'), dto as any); + + expect(mockService.pollJobs).toHaveBeenCalledWith( + 'rt-001', ['quiz_generation'], 10, {}, + ); + }); + + it('falls back to "unknown" when header is missing', async () => { + const dto = { supportedJobTypes: ['quiz_generation'] }; + mockService.pollJobs.mockResolvedValue({ jobs: [] }); + + await controller.pollJobs(req(undefined), dto as any); + + expect(mockService.pollJobs).toHaveBeenCalledWith( + 'unknown', ['quiz_generation'], 5, undefined, + ); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Poll + // ═══════════════════════════════════════════════════════════════════ + + describe('pollJobs', () => { + it('delegates with defaults (limit=5 when undefined)', async () => { + const dto = { supportedJobTypes: ['learning_state_analysis'], limit: undefined }; + mockService.pollJobs.mockResolvedValue({ jobs: [] }); + + await controller.pollJobs(req('rt-1'), dto as any); + + expect(mockService.pollJobs).toHaveBeenCalledWith('rt-1', ['learning_state_analysis'], 5, undefined); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Lock + // ═══════════════════════════════════════════════════════════════════ + + describe('lockJob', () => { + it('uses dto.runtimeInstanceId when provided', async () => { + const dto = { runtimeInstanceId: 'rt-custom' }; + mockService.lockJob.mockResolvedValue({ status: 'locked' }); + + await controller.lockJob(req('rt-head'), 'j1', dto); + + expect(mockService.lockJob).toHaveBeenCalledWith('j1', 'rt-custom'); + }); + + it('falls back to header instanceId', async () => { + const dto = { runtimeInstanceId: undefined as any }; + mockService.lockJob.mockResolvedValue({ status: 'locked' }); + + await controller.lockJob(req('rt-head'), 'j1', dto); + + expect(mockService.lockJob).toHaveBeenCalledWith('j1', 'rt-head'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Heartbeat + // ═══════════════════════════════════════════════════════════════════ + + describe('heartbeatJob', () => { + it('delegates with instanceId fallback', async () => { + const dto = { runtimeInstanceId: undefined as any }; + mockService.heartbeatJob.mockResolvedValue({ lockUntil: 123, cancelRequested: false }); + + await controller.heartbeatJob(req('rt-hb'), 'j1', dto); + + expect(mockService.heartbeatJob).toHaveBeenCalledWith('j1', 'rt-hb'); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Snapshot + // ═══════════════════════════════════════════════════════════════════ + + describe('getSnapshot', () => { + it('delegates to service.getSnapshot', async () => { + mockService.getSnapshot.mockResolvedValue({ snapshotId: 's1' }); + + const result = await controller.getSnapshot('j1'); + + expect(mockService.getSnapshot).toHaveBeenCalledWith('j1'); + expect(result).toEqual({ snapshotId: 's1' }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Credential Resolve + // ═══════════════════════════════════════════════════════════════════ + + describe('resolveCredential', () => { + it('delegates with all fields', async () => { + const dto = { jobId: 'j1', apiKeyMode: 'user_deepseek_key', provider: 'deepseek', credentialId: 'c1' }; + mockService.resolveCredential.mockResolvedValue({ apiKey: 'sk-xx' }); + + await controller.resolveCredential(dto as any); + + expect(mockService.resolveCredential).toHaveBeenCalledWith('j1', 'user_deepseek_key', 'deepseek', 'c1'); + }); + + it('passes undefined credentialId when omitted', async () => { + const dto = { jobId: 'j1', apiKeyMode: 'platform_key', provider: 'deepseek' }; + mockService.resolveCredential.mockResolvedValue({ apiKey: '' }); + + await controller.resolveCredential(dto as any); + + expect(mockService.resolveCredential).toHaveBeenCalledWith('j1', 'platform_key', 'deepseek', undefined); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Submit Result + // ═══════════════════════════════════════════════════════════════════ + + describe('submitResult', () => { + it('delegates entire dto to service', async () => { + const dto = { runtimeInstanceId: 'rt-1', schemaVersion: 'v1', status: 'succeeded', attemptNo: 1 }; + mockService.submitResult.mockResolvedValue({ status: 'ok' }); + + const result = await controller.submitResult('j1', dto as any); + + expect(mockService.submitResult).toHaveBeenCalledWith('j1', dto); + expect(result).toEqual({ status: 'ok' }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Submit Failure + // ═══════════════════════════════════════════════════════════════════ + + describe('submitFailure', () => { + it('delegates entire dto to service', async () => { + const dto = { runtimeInstanceId: 'rt-1', errorCode: 'ERR', errorMessage: 'msg', retryable: false }; + mockService.submitFailure.mockResolvedValue({ status: 'failed' }); + + await controller.submitFailure('j1', dto as any); + + expect(mockService.submitFailure).toHaveBeenCalledWith('j1', dto); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Submit Invocation Logs + // ═══════════════════════════════════════════════════════════════════ + + describe('submitInvocationLogs', () => { + it('extracts logs array from dto', async () => { + const dto = { logs: [{ jobId: 'j1', provider: 'deepseek' }] }; + mockService.submitInvocationLogs.mockResolvedValue({ accepted: 1 }); + + const result = await controller.submitInvocationLogs(dto as any); + + expect(mockService.submitInvocationLogs).toHaveBeenCalledWith(dto.logs); + expect(result).toEqual({ accepted: 1 }); + }); + }); +});