/** * Unit tests for src/services/AgentService.ts */ import { AgentService } from '../../../src/services/AgentService'; import { AgentRepository } from '../../../src/repositories/AgentRepository'; import { CredentialRepository } from '../../../src/repositories/CredentialRepository'; import { AuditService } from '../../../src/services/AuditService'; import { AgentNotFoundError, AgentAlreadyExistsError, AgentAlreadyDecommissionedError, FreeTierLimitError, } from '../../../src/utils/errors'; import { IAgent, ICreateAgentRequest } from '../../../src/types/index'; // Mock dependencies jest.mock('../../../src/repositories/AgentRepository'); jest.mock('../../../src/repositories/CredentialRepository'); jest.mock('../../../src/services/AuditService'); const MockAgentRepository = AgentRepository as jest.MockedClass; const MockCredentialRepository = CredentialRepository as jest.MockedClass; const MockAuditService = AuditService as jest.MockedClass; const MOCK_AGENT: IAgent = { agentId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', organizationId: 'org_system', email: 'agent@sentryagent.ai', agentType: 'screener', version: '1.0.0', capabilities: ['resume:read'], owner: 'team-a', deploymentEnv: 'production', status: 'active', isPublic: false, createdAt: new Date('2026-03-28T09:00:00Z'), updatedAt: new Date('2026-03-28T09:00:00Z'), }; const IP = '127.0.0.1'; const UA = 'test-agent/1.0'; describe('AgentService', () => { let agentService: AgentService; let agentRepo: jest.Mocked; let credentialRepo: jest.Mocked; let auditService: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); agentRepo = new MockAgentRepository({} as never) as jest.Mocked; credentialRepo = new MockCredentialRepository({} as never) as jest.Mocked; auditService = new MockAuditService({} as never) as jest.Mocked; agentService = new AgentService(agentRepo, credentialRepo, auditService); }); // ──────────────────────────────────────────────────────────────── // registerAgent // ──────────────────────────────────────────────────────────────── describe('registerAgent()', () => { const createData: ICreateAgentRequest = { email: 'agent@sentryagent.ai', agentType: 'screener', version: '1.0.0', capabilities: ['resume:read'], owner: 'team-a', deploymentEnv: 'production', }; it('should create and return a new agent', async () => { agentRepo.countActive.mockResolvedValue(0); agentRepo.findByEmail.mockResolvedValue(null); agentRepo.create.mockResolvedValue(MOCK_AGENT); auditService.logEvent.mockResolvedValue({} as never); const result = await agentService.registerAgent(createData, IP, UA); expect(result).toEqual(MOCK_AGENT); expect(agentRepo.create).toHaveBeenCalledWith(createData); }); it('should throw FreeTierLimitError when 100 agents already registered', async () => { agentRepo.countActive.mockResolvedValue(100); await expect(agentService.registerAgent(createData, IP, UA)).rejects.toThrow( FreeTierLimitError, ); }); it('should throw AgentAlreadyExistsError if email is already registered', async () => { agentRepo.countActive.mockResolvedValue(0); agentRepo.findByEmail.mockResolvedValue(MOCK_AGENT); await expect(agentService.registerAgent(createData, IP, UA)).rejects.toThrow( AgentAlreadyExistsError, ); }); }); // ──────────────────────────────────────────────────────────────── // getAgentById // ──────────────────────────────────────────────────────────────── describe('getAgentById()', () => { it('should return the agent when found', async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); const result = await agentService.getAgentById(MOCK_AGENT.agentId); expect(result).toEqual(MOCK_AGENT); }); it('should throw AgentNotFoundError when not found', async () => { agentRepo.findById.mockResolvedValue(null); await expect(agentService.getAgentById('nonexistent-id')).rejects.toThrow( AgentNotFoundError, ); }); }); // ──────────────────────────────────────────────────────────────── // listAgents // ──────────────────────────────────────────────────────────────── describe('listAgents()', () => { it('should return a paginated list of agents', async () => { agentRepo.findAll.mockResolvedValue({ agents: [MOCK_AGENT], total: 1 }); const result = await agentService.listAgents({ page: 1, limit: 20 }); expect(result.data).toHaveLength(1); expect(result.total).toBe(1); expect(result.page).toBe(1); expect(result.limit).toBe(20); }); }); // ──────────────────────────────────────────────────────────────── // updateAgent // ──────────────────────────────────────────────────────────────── describe('updateAgent()', () => { it('should update and return the agent', async () => { const updated = { ...MOCK_AGENT, version: '2.0.0' }; agentRepo.findById.mockResolvedValue(MOCK_AGENT); agentRepo.update.mockResolvedValue(updated); auditService.logEvent.mockResolvedValue({} as never); const result = await agentService.updateAgent( MOCK_AGENT.agentId, { version: '2.0.0' }, IP, UA, ); expect(result.version).toBe('2.0.0'); }); it('should throw AgentNotFoundError when agent does not exist', async () => { agentRepo.findById.mockResolvedValue(null); await expect( agentService.updateAgent('nonexistent', { version: '2.0.0' }, IP, UA), ).rejects.toThrow(AgentNotFoundError); }); it('should throw AgentAlreadyDecommissionedError for decommissioned agent', async () => { agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' }); await expect( agentService.updateAgent(MOCK_AGENT.agentId, { version: '2.0.0' }, IP, UA), ).rejects.toThrow(AgentAlreadyDecommissionedError); }); it('should throw AgentNotFoundError when update() returns null (race condition)', async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); agentRepo.update.mockResolvedValue(null); await expect( agentService.updateAgent(MOCK_AGENT.agentId, { version: '2.0.0' }, IP, UA), ).rejects.toThrow(AgentNotFoundError); }); it('should log agent.suspended audit action when status changes to suspended', async () => { const updated = { ...MOCK_AGENT, status: 'suspended' as const }; agentRepo.findById.mockResolvedValue(MOCK_AGENT); agentRepo.update.mockResolvedValue(updated); auditService.logEvent.mockResolvedValue({} as never); await agentService.updateAgent(MOCK_AGENT.agentId, { status: 'suspended' }, IP, UA); expect(auditService.logEvent).toHaveBeenCalledWith( MOCK_AGENT.agentId, 'agent.suspended', 'success', IP, UA, expect.any(Object), ); }); it('should log agent.reactivated audit action when status changes from suspended to active', async () => { const suspended = { ...MOCK_AGENT, status: 'suspended' as const }; const reactivated = { ...MOCK_AGENT, status: 'active' as const }; agentRepo.findById.mockResolvedValue(suspended); agentRepo.update.mockResolvedValue(reactivated); auditService.logEvent.mockResolvedValue({} as never); await agentService.updateAgent(suspended.agentId, { status: 'active' }, IP, UA); expect(auditService.logEvent).toHaveBeenCalledWith( suspended.agentId, 'agent.reactivated', 'success', IP, UA, expect.any(Object), ); }); it('should log agent.decommissioned audit action when status changes to decommissioned via update', async () => { const updated = { ...MOCK_AGENT, status: 'decommissioned' as const }; agentRepo.findById.mockResolvedValue(MOCK_AGENT); agentRepo.update.mockResolvedValue(updated); auditService.logEvent.mockResolvedValue({} as never); await agentService.updateAgent(MOCK_AGENT.agentId, { status: 'decommissioned' }, IP, UA); expect(auditService.logEvent).toHaveBeenCalledWith( MOCK_AGENT.agentId, 'agent.decommissioned', 'success', IP, UA, expect.any(Object), ); }); }); // ──────────────────────────────────────────────────────────────── // decommissionAgent // ──────────────────────────────────────────────────────────────── describe('decommissionAgent()', () => { it('should decommission the agent and revoke credentials', async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); credentialRepo.revokeAllForAgent.mockResolvedValue(2); agentRepo.decommission.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' }); auditService.logEvent.mockResolvedValue({} as never); await agentService.decommissionAgent(MOCK_AGENT.agentId, IP, UA); expect(credentialRepo.revokeAllForAgent).toHaveBeenCalledWith(MOCK_AGENT.agentId); expect(agentRepo.decommission).toHaveBeenCalledWith(MOCK_AGENT.agentId); }); it('should throw AgentNotFoundError if agent does not exist', async () => { agentRepo.findById.mockResolvedValue(null); await expect( agentService.decommissionAgent('nonexistent', IP, UA), ).rejects.toThrow(AgentNotFoundError); }); it('should throw AgentAlreadyDecommissionedError if already decommissioned', async () => { agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' }); await expect( agentService.decommissionAgent(MOCK_AGENT.agentId, IP, UA), ).rejects.toThrow(AgentAlreadyDecommissionedError); }); }); });