feat: Phase 1 MVP — complete AgentIdP implementation
Implements all P0 features per OpenSpec change phase-1-mvp-implementation: - Agent Registry Service (CRUD) — full lifecycle management - OAuth 2.0 Token Service (Client Credentials flow) - Credential Management (generate, rotate, revoke) - Immutable Audit Log Service Tech: Node.js 18+, TypeScript 5.3+ strict, Express 4.18+, PostgreSQL 14+, Redis 7+ Standards: OpenAPI 3.0 specs, DRY/SOLID, zero `any` types Quality: 18 unit test suites, 244 tests passing, 97%+ coverage OpenAPI: 4 complete specs (14 endpoints total) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
194
tests/unit/services/AgentService.test.ts
Normal file
194
tests/unit/services/AgentService.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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',
|
||||
email: 'agent@sentryagent.ai',
|
||||
agentType: 'screener',
|
||||
version: '1.0.0',
|
||||
capabilities: ['resume:read'],
|
||||
owner: 'team-a',
|
||||
deploymentEnv: 'production',
|
||||
status: 'active',
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user