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:
276
tests/unit/repositories/AgentRepository.test.ts
Normal file
276
tests/unit/repositories/AgentRepository.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Unit tests for src/repositories/AgentRepository.ts
|
||||
* Uses a mocked pg.Pool — no real database connection.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { AgentRepository } from '../../../src/repositories/AgentRepository';
|
||||
import { IAgent, ICreateAgentRequest, IUpdateAgentRequest, IAgentListFilters } from '../../../src/types/index';
|
||||
|
||||
jest.mock('pg', () => ({
|
||||
Pool: jest.fn().mockImplementation(() => ({
|
||||
query: jest.fn(),
|
||||
connect: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const AGENT_ROW = {
|
||||
agent_id: 'a1b2c3d4-0000-0000-0000-000000000001',
|
||||
email: 'agent@sentryagent.ai',
|
||||
agent_type: 'screener',
|
||||
version: '1.0.0',
|
||||
capabilities: ['resume:read'],
|
||||
owner: 'team-a',
|
||||
deployment_env: 'production',
|
||||
status: 'active',
|
||||
created_at: new Date('2026-03-28T09:00:00Z'),
|
||||
updated_at: new Date('2026-03-28T09:00:00Z'),
|
||||
};
|
||||
|
||||
const EXPECTED_AGENT: IAgent = {
|
||||
agentId: AGENT_ROW.agent_id,
|
||||
email: AGENT_ROW.email,
|
||||
agentType: 'screener',
|
||||
version: AGENT_ROW.version,
|
||||
capabilities: AGENT_ROW.capabilities,
|
||||
owner: AGENT_ROW.owner,
|
||||
deploymentEnv: 'production',
|
||||
status: 'active',
|
||||
createdAt: AGENT_ROW.created_at,
|
||||
updatedAt: AGENT_ROW.updated_at,
|
||||
};
|
||||
|
||||
// ─── suite ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AgentRepository', () => {
|
||||
let pool: jest.Mocked<Pool>;
|
||||
let repo: AgentRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
pool = new Pool() as jest.Mocked<Pool>;
|
||||
repo = new AgentRepository(pool);
|
||||
});
|
||||
|
||||
// ── create ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('create()', () => {
|
||||
const createData: ICreateAgentRequest = {
|
||||
email: 'agent@sentryagent.ai',
|
||||
agentType: 'screener',
|
||||
version: '1.0.0',
|
||||
capabilities: ['resume:read'],
|
||||
owner: 'team-a',
|
||||
deploymentEnv: 'production',
|
||||
};
|
||||
|
||||
it('should insert a row and return a mapped IAgent', async () => {
|
||||
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [AGENT_ROW], rowCount: 1 });
|
||||
|
||||
const result = await repo.create(createData);
|
||||
|
||||
expect(pool.query).toHaveBeenCalledTimes(1);
|
||||
const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('INSERT INTO agents');
|
||||
expect(params).toContain(createData.email);
|
||||
expect(params).toContain(createData.agentType);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
email: EXPECTED_AGENT.email,
|
||||
agentType: EXPECTED_AGENT.agentType,
|
||||
status: 'active',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── findById ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('findById()', () => {
|
||||
it('should return a mapped IAgent when the row exists', async () => {
|
||||
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [AGENT_ROW], rowCount: 1 });
|
||||
|
||||
const result = await repo.findById(AGENT_ROW.agent_id);
|
||||
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT'),
|
||||
[AGENT_ROW.agent_id],
|
||||
);
|
||||
expect(result).toMatchObject(EXPECTED_AGENT);
|
||||
});
|
||||
|
||||
it('should return null when no rows are returned', async () => {
|
||||
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
||||
|
||||
const result = await repo.findById('nonexistent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── findByEmail ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('findByEmail()', () => {
|
||||
it('should return a mapped IAgent when the email exists', async () => {
|
||||
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [AGENT_ROW], rowCount: 1 });
|
||||
|
||||
const result = await repo.findByEmail(AGENT_ROW.email);
|
||||
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('email'),
|
||||
[AGENT_ROW.email],
|
||||
);
|
||||
expect(result).toMatchObject(EXPECTED_AGENT);
|
||||
});
|
||||
|
||||
it('should return null when no rows are returned', async () => {
|
||||
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
||||
|
||||
const result = await repo.findByEmail('notfound@example.com');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── findAll ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('findAll()', () => {
|
||||
it('should return paginated agents with total count (no filters)', async () => {
|
||||
(pool.query as jest.Mock)
|
||||
.mockResolvedValueOnce({ rows: [{ count: '1' }], rowCount: 1 }) // count query
|
||||
.mockResolvedValueOnce({ rows: [AGENT_ROW], rowCount: 1 }); // data query
|
||||
|
||||
const filters: IAgentListFilters = { page: 1, limit: 20 };
|
||||
const result = await repo.findAll(filters);
|
||||
|
||||
expect(pool.query).toHaveBeenCalledTimes(2);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.agents).toHaveLength(1);
|
||||
expect(result.agents[0]).toMatchObject(EXPECTED_AGENT);
|
||||
});
|
||||
|
||||
it('should apply owner, agentType, and status filters', async () => {
|
||||
(pool.query as jest.Mock)
|
||||
.mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 })
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
||||
|
||||
const filters: IAgentListFilters = {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
owner: 'team-a',
|
||||
agentType: 'screener',
|
||||
status: 'active',
|
||||
};
|
||||
const result = await repo.findAll(filters);
|
||||
|
||||
const [countSql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
|
||||
expect(countSql).toContain('owner');
|
||||
expect(countSql).toContain('agent_type');
|
||||
expect(countSql).toContain('status');
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.agents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return an empty list when no agents exist', async () => {
|
||||
(pool.query as jest.Mock)
|
||||
.mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 })
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
||||
|
||||
const result = await repo.findAll({ page: 1, limit: 20 });
|
||||
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.agents).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── update ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('update()', () => {
|
||||
it('should update fields and return mapped IAgent', async () => {
|
||||
const updatedRow = { ...AGENT_ROW, version: '2.0.0' };
|
||||
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [updatedRow], rowCount: 1 });
|
||||
|
||||
const data: IUpdateAgentRequest = { version: '2.0.0' };
|
||||
const result = await repo.update(AGENT_ROW.agent_id, data);
|
||||
|
||||
expect(pool.query).toHaveBeenCalledTimes(1);
|
||||
const [sql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('UPDATE agents');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.version).toBe('2.0.0');
|
||||
});
|
||||
|
||||
it('should return null when the agent is not found after update', async () => {
|
||||
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
||||
|
||||
const result = await repo.update('nonexistent', { version: '2.0.0' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no fields are provided', async () => {
|
||||
const result = await repo.update(AGENT_ROW.agent_id, {});
|
||||
|
||||
expect(pool.query).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should update multiple fields at once', async () => {
|
||||
const updatedRow = { ...AGENT_ROW, version: '3.0.0', status: 'suspended', owner: 'team-b' };
|
||||
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [updatedRow], rowCount: 1 });
|
||||
|
||||
const data: IUpdateAgentRequest = { version: '3.0.0', status: 'suspended', owner: 'team-b' };
|
||||
const result = await repo.update(AGENT_ROW.agent_id, data);
|
||||
|
||||
expect(result?.status).toBe('suspended');
|
||||
expect(result?.owner).toBe('team-b');
|
||||
});
|
||||
});
|
||||
|
||||
// ── decommission ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decommission()', () => {
|
||||
it('should set status to decommissioned and return the agent', async () => {
|
||||
const decomRow = { ...AGENT_ROW, status: 'decommissioned' };
|
||||
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [decomRow], rowCount: 1 });
|
||||
|
||||
const result = await repo.decommission(AGENT_ROW.agent_id);
|
||||
|
||||
const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('decommissioned');
|
||||
expect(params).toContain(AGENT_ROW.agent_id);
|
||||
expect(result?.status).toBe('decommissioned');
|
||||
});
|
||||
|
||||
it('should return null when agent is not found', async () => {
|
||||
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
||||
|
||||
const result = await repo.decommission('nonexistent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── countActive ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('countActive()', () => {
|
||||
it('should return the count of non-decommissioned agents', async () => {
|
||||
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [{ count: '42' }], rowCount: 1 });
|
||||
|
||||
const count = await repo.countActive();
|
||||
|
||||
const [sql] = (pool.query as jest.Mock).mock.calls[0] as [string];
|
||||
expect(sql).toContain('decommissioned');
|
||||
expect(count).toBe(42);
|
||||
});
|
||||
|
||||
it('should return 0 when there are no active agents', async () => {
|
||||
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 });
|
||||
|
||||
const count = await repo.countActive();
|
||||
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user