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"); }); });