import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { BatchSubmitter } from '../src/submitter.js'; import { ResolvedConfig } from '../src/config.js'; import { TaskCompletionEvent, TaskType, Complexity, Harness, Provider, Outcome, } from '../src/types/events.js'; function makeConfig(overrides: Partial = {}): ResolvedConfig { return { serverUrl: 'https://tel.example.com', apiKey: 'a'.repeat(64), instanceId: 'test-instance-id', enabled: true, submitIntervalMs: 300_000, maxQueueSize: 1000, batchSize: 100, requestTimeoutMs: 10_000, predictionCacheTtlMs: 21_600_000, dryRun: false, maxRetries: 3, onError: () => {}, ...overrides, }; } function makeEvent(id = 'evt-1'): TaskCompletionEvent { return { instance_id: 'test-instance-id', event_id: id, schema_version: '1.0', timestamp: new Date().toISOString(), task_duration_ms: 5000, task_type: TaskType.IMPLEMENTATION, complexity: Complexity.MEDIUM, harness: Harness.CLAUDE_CODE, model: 'claude-3-opus', provider: Provider.ANTHROPIC, estimated_input_tokens: 1000, estimated_output_tokens: 500, actual_input_tokens: 1100, actual_output_tokens: 550, estimated_cost_usd_micros: 50000, actual_cost_usd_micros: 55000, quality_gate_passed: true, quality_gates_run: [], quality_gates_failed: [], context_compactions: 0, context_rotations: 0, context_utilization_final: 0.5, outcome: Outcome.SUCCESS, retry_count: 0, }; } describe('BatchSubmitter', () => { let fetchSpy: ReturnType; beforeEach(() => { vi.useFakeTimers(); fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); it('should submit a batch successfully', async () => { const responseBody = { accepted: 1, rejected: 0, results: [{ event_id: 'evt-1', status: 'accepted' }], }; fetchSpy.mockResolvedValueOnce({ ok: true, status: 202, json: () => Promise.resolve(responseBody), }); const submitter = new BatchSubmitter(makeConfig()); const result = await submitter.submit([makeEvent()]); expect(result.success).toBe(true); expect(result.response).toEqual(responseBody); expect(fetchSpy).toHaveBeenCalledTimes(1); const [url, options] = fetchSpy.mock.calls[0]; expect(url).toBe('https://tel.example.com/v1/events/batch'); expect(options.method).toBe('POST'); expect(options.headers['Authorization']).toBe(`Bearer ${'a'.repeat(64)}`); }); it('should handle 429 with Retry-After header', async () => { const headers = new Map([['Retry-After', '1']]); fetchSpy.mockResolvedValueOnce({ ok: false, status: 429, headers: { get: (name: string) => headers.get(name) ?? null }, }); // After retry, succeed const responseBody = { accepted: 1, rejected: 0, results: [{ event_id: 'evt-1', status: 'accepted' }], }; fetchSpy.mockResolvedValueOnce({ ok: true, status: 202, json: () => Promise.resolve(responseBody), }); const submitter = new BatchSubmitter(makeConfig({ maxRetries: 1 })); // Run submit in background and advance timers const submitPromise = submitter.submit([makeEvent()]); // Advance enough to cover Retry-After (1s) + backoff with jitter (~1-1.5s) await vi.advanceTimersByTimeAsync(10_000); const result = await submitPromise; expect(result.success).toBe(true); expect(fetchSpy).toHaveBeenCalledTimes(2); }); it('should handle 403 error', async () => { fetchSpy.mockResolvedValueOnce({ ok: false, status: 403, statusText: 'Forbidden', }); const submitter = new BatchSubmitter(makeConfig({ maxRetries: 0 })); const result = await submitter.submit([makeEvent()]); expect(result.success).toBe(false); expect(result.error?.message).toContain('Forbidden'); expect(result.error?.message).toContain('403'); }); it('should retry on network error with backoff', async () => { fetchSpy.mockRejectedValueOnce(new Error('Network error')); fetchSpy.mockResolvedValueOnce({ ok: true, status: 202, json: () => Promise.resolve({ accepted: 1, rejected: 0, results: [{ event_id: 'evt-1', status: 'accepted' }], }), }); const submitter = new BatchSubmitter(makeConfig({ maxRetries: 1 })); const submitPromise = submitter.submit([makeEvent()]); // Advance past backoff delay await vi.advanceTimersByTimeAsync(5000); const result = await submitPromise; expect(result.success).toBe(true); expect(fetchSpy).toHaveBeenCalledTimes(2); }); it('should fail after max retries exhausted', async () => { fetchSpy.mockRejectedValue(new Error('Network error')); const submitter = new BatchSubmitter(makeConfig({ maxRetries: 2 })); const submitPromise = submitter.submit([makeEvent()]); // Advance timers to allow all retries await vi.advanceTimersByTimeAsync(120_000); const result = await submitPromise; expect(result.success).toBe(false); expect(result.error?.message).toBe('Network error'); }); it('should not call fetch in dryRun mode', async () => { const submitter = new BatchSubmitter(makeConfig({ dryRun: true })); const result = await submitter.submit([makeEvent('evt-1'), makeEvent('evt-2')]); expect(result.success).toBe(true); expect(result.response?.accepted).toBe(2); expect(result.response?.rejected).toBe(0); expect(fetchSpy).not.toHaveBeenCalled(); }); it('should handle request timeout via AbortController', async () => { fetchSpy.mockImplementation( (_url: string, options: { signal: AbortSignal }) => new Promise((_resolve, reject) => { options.signal.addEventListener('abort', () => { reject(new DOMException('The operation was aborted.', 'AbortError')); }); }), ); const submitter = new BatchSubmitter( makeConfig({ requestTimeoutMs: 1000, maxRetries: 0 }), ); const submitPromise = submitter.submit([makeEvent()]); await vi.advanceTimersByTimeAsync(2000); const result = await submitPromise; expect(result.success).toBe(false); expect(result.error?.message).toContain('aborted'); }); });