diff --git a/apps/server/agent/lifecycle/controller.test.ts b/apps/server/agent/lifecycle/controller.test.ts new file mode 100644 index 0000000..de7462b --- /dev/null +++ b/apps/server/agent/lifecycle/controller.test.ts @@ -0,0 +1,154 @@ +/** + * AgentLifecycleController Tests — Regression coverage for event emissions. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { AgentLifecycleController } from './controller.js'; +import type { AgentRepository } from '../../db/repositories/agent-repository.js'; +import type { AccountRepository } from '../../db/repositories/account-repository.js'; +import type { SignalManager } from './signal-manager.js'; +import type { RetryPolicy } from './retry-policy.js'; +import type { AgentErrorAnalyzer } from './error-analyzer.js'; +import type { ProcessManager } from '../process-manager.js'; +import type { CleanupManager } from '../cleanup-manager.js'; +import type { CleanupStrategy } from './cleanup-strategy.js'; +import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js'; + +function makeController(overrides: { + repository?: Partial; + accountRepository?: Partial; + eventBus?: EventBus; +}): AgentLifecycleController { + const signalManager: SignalManager = { + clearSignal: vi.fn(), + checkSignalExists: vi.fn(), + readSignal: vi.fn(), + waitForSignal: vi.fn(), + validateSignalFile: vi.fn(), + }; + const retryPolicy: RetryPolicy = { + maxAttempts: 3, + shouldRetry: vi.fn().mockReturnValue(false), + getRetryDelay: vi.fn().mockReturnValue(0), + }; + const errorAnalyzer = { analyzeError: vi.fn() } as unknown as AgentErrorAnalyzer; + const processManager = { getAgentWorkdir: vi.fn() } as unknown as ProcessManager; + const cleanupManager = {} as unknown as CleanupManager; + const cleanupStrategy = { + shouldCleanup: vi.fn(), + executeCleanup: vi.fn(), + } as unknown as CleanupStrategy; + + return new AgentLifecycleController( + signalManager, + retryPolicy, + errorAnalyzer, + processManager, + overrides.repository as AgentRepository, + cleanupManager, + cleanupStrategy, + overrides.accountRepository as AccountRepository | undefined, + false, + overrides.eventBus, + ); +} + +describe('AgentLifecycleController', () => { + describe('handleAccountExhaustion', () => { + it('emits agent:account_switched with correct payload when new account is available', async () => { + const emittedEvents: AgentAccountSwitchedEvent[] = []; + const eventBus: EventBus = { + emit: vi.fn((event) => { emittedEvents.push(event as AgentAccountSwitchedEvent); }), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; + + const agentRecord = { + id: 'agent-1', + name: 'test-agent', + accountId: 'old-account-id', + provider: 'claude', + }; + const newAccount = { id: 'new-account-id' }; + + const repository: Partial = { + findById: vi.fn().mockResolvedValue(agentRecord), + }; + const accountRepository: Partial = { + markExhausted: vi.fn().mockResolvedValue(agentRecord), + findNextAvailable: vi.fn().mockResolvedValue(newAccount), + }; + + const controller = makeController({ repository, accountRepository, eventBus }); + + // Call private method via any-cast + await (controller as any).handleAccountExhaustion('agent-1'); + + const accountSwitchedEvents = emittedEvents.filter( + (e) => e.type === 'agent:account_switched' + ); + expect(accountSwitchedEvents).toHaveLength(1); + const event = accountSwitchedEvents[0]; + expect(event.type).toBe('agent:account_switched'); + expect(event.payload.agentId).toBe('agent-1'); + expect(event.payload.name).toBe('test-agent'); + expect(event.payload.previousAccountId).toBe('old-account-id'); + expect(event.payload.newAccountId).toBe('new-account-id'); + expect(event.payload.reason).toBe('account_exhausted'); + }); + + it('does not emit agent:account_switched when no new account is available', async () => { + const eventBus: EventBus = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; + + const agentRecord = { + id: 'agent-2', + name: 'test-agent-2', + accountId: 'old-account-id', + provider: 'claude', + }; + + const repository: Partial = { + findById: vi.fn().mockResolvedValue(agentRecord), + }; + const accountRepository: Partial = { + markExhausted: vi.fn().mockResolvedValue(agentRecord), + findNextAvailable: vi.fn().mockResolvedValue(null), + }; + + const controller = makeController({ repository, accountRepository, eventBus }); + + await (controller as any).handleAccountExhaustion('agent-2'); + + expect(eventBus.emit).not.toHaveBeenCalled(); + }); + + it('does not emit when agent has no accountId', async () => { + const eventBus: EventBus = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + }; + + const repository: Partial = { + findById: vi.fn().mockResolvedValue({ id: 'agent-3', name: 'x', accountId: null }), + }; + const accountRepository: Partial = { + markExhausted: vi.fn(), + findNextAvailable: vi.fn(), + }; + + const controller = makeController({ repository, accountRepository, eventBus }); + + await (controller as any).handleAccountExhaustion('agent-3'); + + expect(eventBus.emit).not.toHaveBeenCalled(); + }); + }); +});