Files
sentryagent-idp/tests/unit/services/AgentService.test.ts
SentryAgent.ai Developer af630b43d4 chore(phase-4): QA fixes + gitignore portal build artifacts
- Fix 7 test fixtures missing isPublic field added in WS4 Marketplace
- Add portal/.next/ to .gitignore (build artifacts should not be tracked)
- Mark all Phase 4 tasks 11.1-11.11 complete in tasks.md

QA results: 611/611 tests pass, tsc zero errors, portal build OK, CLI build OK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:59:11 +00:00

260 lines
11 KiB
TypeScript

/**
* 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<typeof AgentRepository>;
const MockCredentialRepository = CredentialRepository as jest.MockedClass<typeof CredentialRepository>;
const MockAuditService = AuditService as jest.MockedClass<typeof AuditService>;
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<AgentRepository>;
let credentialRepo: jest.Mocked<CredentialRepository>;
let auditService: jest.Mocked<AuditService>;
beforeEach(() => {
jest.clearAllMocks();
agentRepo = new MockAgentRepository({} as never) as jest.Mocked<AgentRepository>;
credentialRepo = new MockCredentialRepository({} as never) as jest.Mocked<CredentialRepository>;
auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;
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);
});
});
});