import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { QueueService } from "./queue.service"; import type { QueuedTask, TaskProcessingResult } from "./types"; import type { Job } from "bullmq"; // Mock BullMQ vi.mock("bullmq", () => { return { Queue: vi.fn(), Worker: vi.fn(), Job: vi.fn(), }; }); describe("QueueService", () => { describe("calculateBackoffDelay", () => { let service: QueueService; beforeEach(() => { // Create a minimal instance for testing pure functions const mockValkeyService = { updateTaskStatus: vi.fn(), publishEvent: vi.fn(), }; const mockConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), }; service = new QueueService( mockValkeyService as unknown as never, mockConfigService as unknown as never ); }); it("should calculate exponential backoff delay", () => { const baseDelay = 1000; const maxDelay = 60000; // Attempt 1: 2000ms (1000 * 2^1) const delay1 = service.calculateBackoffDelay(1, baseDelay, maxDelay); expect(delay1).toBe(2000); // Attempt 2: 4000ms (1000 * 2^2) const delay2 = service.calculateBackoffDelay(2, baseDelay, maxDelay); expect(delay2).toBe(4000); // Attempt 3: 8000ms (1000 * 2^3) const delay3 = service.calculateBackoffDelay(3, baseDelay, maxDelay); expect(delay3).toBe(8000); // Attempt 4: 16000ms (1000 * 2^4) const delay4 = service.calculateBackoffDelay(4, baseDelay, maxDelay); expect(delay4).toBe(16000); }); it("should cap delay at maxDelay", () => { const baseDelay = 1000; const maxDelay = 60000; // Attempt 10 would be 1024000ms, but should be capped at 60000ms const delay10 = service.calculateBackoffDelay(10, baseDelay, maxDelay); expect(delay10).toBe(maxDelay); // Attempt 7 would be 128000ms, should be capped at 60000ms const delay7 = service.calculateBackoffDelay(7, baseDelay, maxDelay); expect(delay7).toBe(maxDelay); }); it("should handle zero baseDelay", () => { const delay = service.calculateBackoffDelay(3, 0, 60000); expect(delay).toBe(0); }); it("should handle attempt 0", () => { const delay = service.calculateBackoffDelay(0, 1000, 60000); expect(delay).toBe(1000); // 1000 * 2^0 = 1000 }); it("should handle large attempt numbers", () => { const baseDelay = 1000; const maxDelay = 100000; const delay = service.calculateBackoffDelay(20, baseDelay, maxDelay); expect(delay).toBe(maxDelay); }); it("should work with different base delays", () => { const maxDelay = 100000; // 500ms base const delay1 = service.calculateBackoffDelay(2, 500, maxDelay); expect(delay1).toBe(2000); // 500 * 2^2 // 2000ms base const delay2 = service.calculateBackoffDelay(2, 2000, maxDelay); expect(delay2).toBe(8000); // 2000 * 2^2 }); }); describe("validation logic", () => { let service: QueueService; let mockValkeyService: { updateTaskStatus: ReturnType; publishEvent: ReturnType; }; let mockConfigService: { get: ReturnType; }; beforeEach(() => { mockValkeyService = { updateTaskStatus: vi.fn().mockResolvedValue(undefined), publishEvent: vi.fn().mockResolvedValue(undefined), }; mockConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => { const config: Record = { "orchestrator.valkey.host": "localhost", "orchestrator.valkey.port": 6379, "orchestrator.queue.name": "orchestrator-tasks", "orchestrator.queue.maxRetries": 3, "orchestrator.queue.baseDelay": 1000, "orchestrator.queue.maxDelay": 60000, "orchestrator.queue.concurrency": 5, }; return config[key] ?? defaultValue; }), }; service = new QueueService( mockValkeyService as unknown as never, mockConfigService as unknown as never ); }); it("should be defined", () => { expect(service).toBeDefined(); expect(service.calculateBackoffDelay).toBeDefined(); }); it("should load configuration from ConfigService", () => { expect(mockConfigService.get).toHaveBeenCalledWith( "orchestrator.queue.name", "orchestrator-tasks" ); expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.queue.maxRetries", 3); expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.queue.baseDelay", 1000); expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.queue.maxDelay", 60000); }); it("should load retention configuration from ConfigService on init", async () => { const { Queue, Worker } = await import("bullmq"); const QueueMock = Queue as unknown as ReturnType; const WorkerMock = Worker as unknown as ReturnType; QueueMock.mockImplementation(function (this: unknown) { return { add: vi.fn(), getJobCounts: vi.fn(), pause: vi.fn(), resume: vi.fn(), getJob: vi.fn(), close: vi.fn(), }; } as never); WorkerMock.mockImplementation(function (this: unknown) { return { on: vi.fn().mockReturnThis(), close: vi.fn(), }; } as never); service.onModuleInit(); expect(mockConfigService.get).toHaveBeenCalledWith( "orchestrator.queue.completedRetentionAgeSeconds", 3600 ); expect(mockConfigService.get).toHaveBeenCalledWith( "orchestrator.queue.completedRetentionCount", 100 ); expect(mockConfigService.get).toHaveBeenCalledWith( "orchestrator.queue.failedRetentionAgeSeconds", 86400 ); expect(mockConfigService.get).toHaveBeenCalledWith( "orchestrator.queue.failedRetentionCount", 1000 ); }); }); describe("retry configuration", () => { it("should use default retry configuration", () => { const mockValkeyService = { updateTaskStatus: vi.fn(), publishEvent: vi.fn(), }; const mockConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), }; const service = new QueueService( mockValkeyService as unknown as never, mockConfigService as unknown as never ); // Verify defaults were requested expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.queue.maxRetries", 3); expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.queue.baseDelay", 1000); expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.queue.maxDelay", 60000); }); it("should use custom retry configuration from env", () => { const mockValkeyService = { updateTaskStatus: vi.fn(), publishEvent: vi.fn(), }; const mockConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => { if (key === "orchestrator.queue.maxRetries") return 5; if (key === "orchestrator.queue.baseDelay") return 2000; if (key === "orchestrator.queue.maxDelay") return 120000; return defaultValue; }), }; const service = new QueueService( mockValkeyService as unknown as never, mockConfigService as unknown as never ); // Verify custom values were used const delay1 = service.calculateBackoffDelay(1, 2000, 120000); expect(delay1).toBe(4000); // 2000 * 2^1 }); }); describe("Module Lifecycle Integration", () => { let service: QueueService; let mockValkeyService: { updateTaskStatus: ReturnType; publishEvent: ReturnType; }; let mockConfigService: { get: ReturnType; }; let mockQueue: { add: ReturnType; getJobCounts: ReturnType; pause: ReturnType; resume: ReturnType; getJob: ReturnType; close: ReturnType; }; let mockWorker: { on: ReturnType; close: ReturnType; }; let workerProcessFn: ((job: Job) => Promise) | null; let workerEventHandlers: Record, err?: Error) => Promise>; let QueueMock: ReturnType; let WorkerMock: ReturnType; beforeEach(async () => { workerProcessFn = null; workerEventHandlers = {}; mockValkeyService = { updateTaskStatus: vi.fn().mockResolvedValue(undefined), publishEvent: vi.fn().mockResolvedValue(undefined), }; mockConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => { const config: Record = { "orchestrator.valkey.host": "localhost", "orchestrator.valkey.port": 6379, "orchestrator.valkey.password": undefined, "orchestrator.queue.name": "orchestrator-tasks", "orchestrator.queue.maxRetries": 3, "orchestrator.queue.baseDelay": 1000, "orchestrator.queue.maxDelay": 60000, "orchestrator.queue.concurrency": 5, }; return config[key] ?? defaultValue; }), }; mockQueue = { add: vi.fn().mockResolvedValue({ id: "job-123" }), getJobCounts: vi.fn().mockResolvedValue({ waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0, }), pause: vi.fn().mockResolvedValue(undefined), resume: vi.fn().mockResolvedValue(undefined), getJob: vi.fn().mockResolvedValue(null), close: vi.fn().mockResolvedValue(undefined), }; mockWorker = { on: vi.fn( (event: string, handler: (job?: Job, err?: Error) => Promise) => { workerEventHandlers[event] = handler; return mockWorker; } ), close: vi.fn().mockResolvedValue(undefined), }; // Get mocked modules const { Queue, Worker } = await import("bullmq"); QueueMock = Queue as unknown as ReturnType; WorkerMock = Worker as unknown as ReturnType; // Mock Queue constructor QueueMock.mockImplementation(function (this: unknown, name: string, options?: unknown) { return mockQueue; } as never); // Mock Worker constructor WorkerMock.mockImplementation(function ( this: unknown, name: string, processFn: (job: Job) => Promise, options?: unknown ) { workerProcessFn = processFn; return mockWorker; } as never); service = new QueueService( mockValkeyService as unknown as never, mockConfigService as unknown as never ); }); afterEach(() => { vi.clearAllMocks(); }); describe("onModuleInit", () => { it("should initialize BullMQ queue with default retention configuration", async () => { await service.onModuleInit(); expect(QueueMock).toHaveBeenCalledWith("orchestrator-tasks", { connection: { host: "localhost", port: 6379, password: undefined, }, defaultJobOptions: { removeOnComplete: { age: 3600, count: 100, }, removeOnFail: { age: 86400, count: 1000, }, }, }); }); it("should initialize BullMQ queue with custom retention configuration", async () => { mockConfigService.get = vi.fn((key: string, defaultValue?: unknown) => { const config: Record = { "orchestrator.valkey.host": "localhost", "orchestrator.valkey.port": 6379, "orchestrator.valkey.password": undefined, "orchestrator.queue.name": "orchestrator-tasks", "orchestrator.queue.maxRetries": 3, "orchestrator.queue.baseDelay": 1000, "orchestrator.queue.maxDelay": 60000, "orchestrator.queue.concurrency": 5, "orchestrator.queue.completedRetentionAgeSeconds": 1800, "orchestrator.queue.completedRetentionCount": 50, "orchestrator.queue.failedRetentionAgeSeconds": 43200, "orchestrator.queue.failedRetentionCount": 500, }; return config[key] ?? defaultValue; }); service = new QueueService( mockValkeyService as unknown as never, mockConfigService as unknown as never ); vi.clearAllMocks(); await service.onModuleInit(); expect(QueueMock).toHaveBeenCalledWith("orchestrator-tasks", { connection: { host: "localhost", port: 6379, password: undefined, }, defaultJobOptions: { removeOnComplete: { age: 1800, count: 50, }, removeOnFail: { age: 43200, count: 500, }, }, }); }); it("should initialize BullMQ worker with correct configuration", async () => { await service.onModuleInit(); expect(WorkerMock).toHaveBeenCalledWith("orchestrator-tasks", expect.any(Function), { connection: { host: "localhost", port: 6379, password: undefined, }, concurrency: 5, }); }); it("should setup worker event handlers", async () => { await service.onModuleInit(); expect(mockWorker.on).toHaveBeenCalledWith("failed", expect.any(Function)); expect(mockWorker.on).toHaveBeenCalledWith("completed", expect.any(Function)); }); it("should use password if configured", async () => { mockConfigService.get = vi.fn((key: string, defaultValue?: unknown) => { if (key === "orchestrator.valkey.password") return "secret123"; const config: Record = { "orchestrator.valkey.host": "localhost", "orchestrator.valkey.port": 6379, "orchestrator.queue.name": "orchestrator-tasks", "orchestrator.queue.concurrency": 5, }; return config[key] ?? defaultValue; }); service = new QueueService( mockValkeyService as unknown as never, mockConfigService as unknown as never ); vi.clearAllMocks(); await service.onModuleInit(); expect(QueueMock).toHaveBeenCalledWith( "orchestrator-tasks", expect.objectContaining({ connection: expect.objectContaining({ password: "secret123", }), }) ); }); }); describe("onModuleDestroy", () => { it("should close worker and queue", async () => { await service.onModuleInit(); await service.onModuleDestroy(); expect(mockWorker.close).toHaveBeenCalledOnce(); expect(mockQueue.close).toHaveBeenCalledOnce(); }); }); }); describe("addTask Integration", () => { let service: QueueService; let mockValkeyService: { updateTaskStatus: ReturnType; publishEvent: ReturnType; }; let mockConfigService: { get: ReturnType; }; let mockQueue: { add: ReturnType; getJobCounts: ReturnType; pause: ReturnType; resume: ReturnType; getJob: ReturnType; close: ReturnType; }; let QueueMock: ReturnType; let WorkerMock: ReturnType; beforeEach(async () => { mockValkeyService = { updateTaskStatus: vi.fn().mockResolvedValue(undefined), publishEvent: vi.fn().mockResolvedValue(undefined), }; mockConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => { const config: Record = { "orchestrator.valkey.host": "localhost", "orchestrator.valkey.port": 6379, "orchestrator.queue.name": "orchestrator-tasks", "orchestrator.queue.maxRetries": 3, "orchestrator.queue.baseDelay": 1000, "orchestrator.queue.maxDelay": 60000, "orchestrator.queue.concurrency": 5, }; return config[key] ?? defaultValue; }), }; mockQueue = { add: vi.fn().mockResolvedValue({ id: "job-123" }), getJobCounts: vi.fn().mockResolvedValue({}), pause: vi.fn().mockResolvedValue(undefined), resume: vi.fn().mockResolvedValue(undefined), getJob: vi.fn().mockResolvedValue(null), close: vi.fn().mockResolvedValue(undefined), }; const { Queue, Worker } = await import("bullmq"); QueueMock = Queue as unknown as ReturnType; WorkerMock = Worker as unknown as ReturnType; QueueMock.mockImplementation(function (this: unknown) { return mockQueue; } as never); WorkerMock.mockImplementation(function (this: unknown) { return { on: vi.fn().mockReturnThis(), close: vi.fn().mockResolvedValue(undefined), }; } as never); service = new QueueService( mockValkeyService as unknown as never, mockConfigService as unknown as never ); await service.onModuleInit(); }); afterEach(() => { vi.clearAllMocks(); }); it("should add task with default options", async () => { const taskId = "task-123"; const context = { repository: "test-repo", branch: "main", workItems: ["US-001"], }; await service.addTask(taskId, context); expect(mockQueue.add).toHaveBeenCalledWith( taskId, { taskId, priority: 5, retries: 0, maxRetries: 3, context, }, { priority: 6, // 10 - 5 + 1 attempts: 4, // 3 + 1 backoff: { type: "custom" }, delay: 0, } ); }); it("should add task with custom priority", async () => { const taskId = "task-456"; const context = { repository: "test-repo", branch: "main", workItems: ["US-002"], }; await service.addTask(taskId, context, { priority: 8 }); expect(mockQueue.add).toHaveBeenCalledWith( taskId, expect.objectContaining({ priority: 8, }), expect.objectContaining({ priority: 3, // 10 - 8 + 1 }) ); }); it("should add task with custom maxRetries", async () => { const taskId = "task-789"; const context = { repository: "test-repo", branch: "main", workItems: ["US-003"], }; await service.addTask(taskId, context, { maxRetries: 5 }); expect(mockQueue.add).toHaveBeenCalledWith( taskId, expect.objectContaining({ maxRetries: 5, }), expect.objectContaining({ attempts: 6, // 5 + 1 }) ); }); it("should add task with delay", async () => { const taskId = "task-delayed"; const context = { repository: "test-repo", branch: "main", workItems: ["US-004"], }; await service.addTask(taskId, context, { delay: 5000 }); expect(mockQueue.add).toHaveBeenCalledWith( taskId, expect.any(Object), expect.objectContaining({ delay: 5000, }) ); }); it("should throw error if priority is less than 1", async () => { const taskId = "task-invalid"; const context = { repository: "test-repo", branch: "main", workItems: ["US-005"], }; await expect(service.addTask(taskId, context, { priority: 0 })).rejects.toThrow( "Priority must be between 1 and 10" ); }); it("should throw error if priority is greater than 10", async () => { const taskId = "task-invalid"; const context = { repository: "test-repo", branch: "main", workItems: ["US-006"], }; await expect(service.addTask(taskId, context, { priority: 11 })).rejects.toThrow( "Priority must be between 1 and 10" ); }); it("should throw error if maxRetries is negative", async () => { const taskId = "task-invalid"; const context = { repository: "test-repo", branch: "main", workItems: ["US-007"], }; await expect(service.addTask(taskId, context, { maxRetries: -1 })).rejects.toThrow( "maxRetries must be non-negative" ); }); it("should update Valkey task status to pending", async () => { const taskId = "task-status"; const context = { repository: "test-repo", branch: "main", workItems: ["US-008"], }; await service.addTask(taskId, context); expect(mockValkeyService.updateTaskStatus).toHaveBeenCalledWith(taskId, "pending"); }); it("should publish task.queued event", async () => { const taskId = "task-event"; const context = { repository: "test-repo", branch: "main", workItems: ["US-009"], }; await service.addTask(taskId, context, { priority: 7 }); expect(mockValkeyService.publishEvent).toHaveBeenCalledWith({ type: "task.queued", timestamp: expect.any(String), taskId, data: { priority: 7 }, }); }); }); describe("getStats Integration", () => { let service: QueueService; let mockQueue: { add: ReturnType; getJobCounts: ReturnType; pause: ReturnType; resume: ReturnType; getJob: ReturnType; close: ReturnType; }; let QueueMock: ReturnType; let WorkerMock: ReturnType; beforeEach(async () => { const mockValkeyService = { updateTaskStatus: vi.fn(), publishEvent: vi.fn(), }; const mockConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), }; mockQueue = { add: vi.fn(), getJobCounts: vi.fn().mockResolvedValue({ waiting: 5, active: 2, completed: 10, failed: 1, delayed: 3, }), pause: vi.fn(), resume: vi.fn(), getJob: vi.fn(), close: vi.fn(), }; const { Queue, Worker } = await import("bullmq"); QueueMock = Queue as unknown as ReturnType; WorkerMock = Worker as unknown as ReturnType; QueueMock.mockImplementation(function (this: unknown) { return mockQueue; } as never); WorkerMock.mockImplementation(function (this: unknown) { return { on: vi.fn().mockReturnThis(), close: vi.fn(), }; } as never); service = new QueueService( mockValkeyService as unknown as never, mockConfigService as unknown as never ); await service.onModuleInit(); }); it("should return correct queue statistics", async () => { const stats = await service.getStats(); expect(stats).toEqual({ pending: 5, active: 2, completed: 10, failed: 1, delayed: 3, }); }); it("should handle zero counts gracefully", async () => { mockQueue.getJobCounts = vi.fn().mockResolvedValue({}); const stats = await service.getStats(); expect(stats).toEqual({ pending: 0, active: 0, completed: 0, failed: 0, delayed: 0, }); }); it("should call getJobCounts with correct status parameters", async () => { await service.getStats(); expect(mockQueue.getJobCounts).toHaveBeenCalledWith( "waiting", "active", "completed", "failed", "delayed" ); }); }); describe("Queue Control Integration", () => { let service: QueueService; let mockQueue: { add: ReturnType; getJobCounts: ReturnType; pause: ReturnType; resume: ReturnType; getJob: ReturnType; close: ReturnType; }; let QueueMock: ReturnType; let WorkerMock: ReturnType; beforeEach(async () => { const mockValkeyService = { updateTaskStatus: vi.fn(), publishEvent: vi.fn(), }; const mockConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), }; mockQueue = { add: vi.fn(), getJobCounts: vi.fn(), pause: vi.fn().mockResolvedValue(undefined), resume: vi.fn().mockResolvedValue(undefined), getJob: vi.fn().mockResolvedValue(null), close: vi.fn(), }; const { Queue, Worker } = await import("bullmq"); QueueMock = Queue as unknown as ReturnType; WorkerMock = Worker as unknown as ReturnType; QueueMock.mockImplementation(function (this: unknown) { return mockQueue; } as never); WorkerMock.mockImplementation(function (this: unknown) { return { on: vi.fn().mockReturnThis(), close: vi.fn(), }; } as never); service = new QueueService( mockValkeyService as unknown as never, mockConfigService as unknown as never ); await service.onModuleInit(); }); it("should pause queue", async () => { await service.pause(); expect(mockQueue.pause).toHaveBeenCalledOnce(); }); it("should resume queue", async () => { await service.resume(); expect(mockQueue.resume).toHaveBeenCalledOnce(); }); it("should remove task from queue when job exists", async () => { const mockJob = { remove: vi.fn().mockResolvedValue(undefined), }; mockQueue.getJob = vi.fn().mockResolvedValue(mockJob); await service.removeTask("task-123"); expect(mockQueue.getJob).toHaveBeenCalledWith("task-123"); expect(mockJob.remove).toHaveBeenCalledOnce(); }); it("should handle removeTask when job does not exist", async () => { mockQueue.getJob = vi.fn().mockResolvedValue(null); await expect(service.removeTask("non-existent")).resolves.not.toThrow(); expect(mockQueue.getJob).toHaveBeenCalledWith("non-existent"); }); }); describe("Task Processing Integration", () => { let service: QueueService; let mockValkeyService: { updateTaskStatus: ReturnType; publishEvent: ReturnType; }; let workerProcessFn: ((job: Job) => Promise) | null; let workerEventHandlers: Record, err?: Error) => Promise>; let QueueMock: ReturnType; let WorkerMock: ReturnType; beforeEach(async () => { workerProcessFn = null; workerEventHandlers = {}; mockValkeyService = { updateTaskStatus: vi.fn().mockResolvedValue(undefined), publishEvent: vi.fn().mockResolvedValue(undefined), }; const mockConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => { const config: Record = { "orchestrator.queue.maxRetries": 3, "orchestrator.queue.baseDelay": 1000, "orchestrator.queue.maxDelay": 60000, }; return config[key] ?? defaultValue; }), }; const mockQueue = { add: vi.fn(), getJobCounts: vi.fn(), pause: vi.fn(), resume: vi.fn(), getJob: vi.fn(), close: vi.fn(), }; const mockWorker = { on: vi.fn( (event: string, handler: (job?: Job, err?: Error) => Promise) => { workerEventHandlers[event] = handler; return mockWorker; } ), close: vi.fn(), }; const { Queue, Worker } = await import("bullmq"); QueueMock = Queue as unknown as ReturnType; WorkerMock = Worker as unknown as ReturnType; QueueMock.mockImplementation(function (this: unknown) { return mockQueue; } as never); WorkerMock.mockImplementation(function ( this: unknown, name: string, processFn: (job: Job) => Promise, options?: unknown ) { workerProcessFn = processFn; return mockWorker; } as never); service = new QueueService( mockValkeyService as unknown as never, mockConfigService as unknown as never ); await service.onModuleInit(); }); it("should process task successfully", async () => { const mockJob = { data: { taskId: "task-123", priority: 5, retries: 0, maxRetries: 3, context: { repository: "test-repo", branch: "main", workItems: ["US-001"], }, }, attemptsMade: 0, updateData: vi.fn(), } as unknown as Job; const result = await workerProcessFn!(mockJob); expect(result).toEqual({ success: true, metadata: { attempt: 1 }, }); expect(mockValkeyService.updateTaskStatus).toHaveBeenCalledWith( "task-123", "executing", undefined ); expect(mockValkeyService.publishEvent).toHaveBeenCalledWith({ type: "task.executing", timestamp: expect.any(String), taskId: "task-123", agentId: undefined, data: { attempt: 1, dispatchedByQueue: true }, }); }); it("should handle task completion", async () => { const mockJob = { data: { taskId: "task-completed", priority: 5, retries: 0, maxRetries: 3, context: { repository: "test-repo", branch: "main", workItems: ["US-002"], }, }, } as Job; await workerEventHandlers["completed"](mockJob); expect(mockValkeyService.updateTaskStatus).toHaveBeenCalledWith( "task-completed", "completed" ); expect(mockValkeyService.publishEvent).toHaveBeenCalledWith({ type: "task.completed", timestamp: expect.any(String), taskId: "task-completed", }); }); it("should handle task failure", async () => { const mockJob = { data: { taskId: "task-failed", priority: 5, retries: 0, maxRetries: 3, context: { repository: "test-repo", branch: "main", workItems: ["US-003"], }, }, } as Job; const error = new Error("Processing failed"); await workerEventHandlers["failed"](mockJob, error); expect(mockValkeyService.updateTaskStatus).toHaveBeenCalledWith( "task-failed", "failed", undefined, "Processing failed" ); expect(mockValkeyService.publishEvent).toHaveBeenCalledWith({ type: "task.failed", timestamp: expect.any(String), taskId: "task-failed", error: "Processing failed", }); }); it("should handle retry on failure", async () => { const mockJob = { data: { taskId: "task-retry", priority: 5, retries: 0, maxRetries: 3, context: { repository: "test-repo", branch: "main", workItems: ["US-004"], }, }, attemptsMade: 1, updateData: vi.fn().mockResolvedValue(undefined), } as unknown as Job; // Mock processTask to throw error const error = new Error("Temporary failure"); try { await workerProcessFn!(mockJob); } catch (err) { // Expected to throw } // Manually trigger retry logic by calling processTask again mockValkeyService.updateTaskStatus.mockImplementation(() => { throw error; }); await expect(workerProcessFn!(mockJob)).rejects.toThrow("Temporary failure"); expect(mockJob.updateData).toHaveBeenCalledWith({ ...mockJob.data, retries: 2, }); expect(mockValkeyService.publishEvent).toHaveBeenCalledWith( expect.objectContaining({ type: "task.retry", taskId: "task-retry", data: expect.objectContaining({ attempt: 2, nextDelay: expect.any(Number), }), }) ); }); it("should calculate correct backoff delay on retry", async () => { const mockJob = { data: { taskId: "task-backoff", priority: 5, retries: 0, maxRetries: 3, context: { repository: "test-repo", branch: "main", workItems: ["US-005"], }, }, attemptsMade: 2, updateData: vi.fn().mockResolvedValue(undefined), } as unknown as Job; mockValkeyService.updateTaskStatus.mockImplementation(() => { throw new Error("Retry test"); }); await expect(workerProcessFn!(mockJob)).rejects.toThrow(); expect(mockValkeyService.publishEvent).toHaveBeenCalledWith( expect.objectContaining({ type: "task.retry", data: expect.objectContaining({ nextDelay: 8000, // 1000 * 2^3 }), }) ); }); it("should not retry after max retries exceeded", async () => { const mockJob = { data: { taskId: "task-max-retry", priority: 5, retries: 3, maxRetries: 3, context: { repository: "test-repo", branch: "main", workItems: ["US-006"], }, }, attemptsMade: 3, updateData: vi.fn(), } as unknown as Job; mockValkeyService.updateTaskStatus.mockImplementation(() => { throw new Error("Max retries exceeded"); }); await expect(workerProcessFn!(mockJob)).rejects.toThrow(); // Should not publish retry event expect(mockValkeyService.publishEvent).not.toHaveBeenCalledWith( expect.objectContaining({ type: "task.retry", }) ); }); }); });