Introduces full multi-tenant organization model to AgentIdP: Schema: - 6 migrations: organizations + organization_members tables; organization_id FK added to agents, credentials, audit_logs; PostgreSQL RLS policies on all three tables; system org seed + backfill API: - 6 new /api/v1/organizations endpoints (CRUD + members) gated by admin:orgs scope - OPA scopes.json updated with 6 new org endpoint → admin:orgs mappings Implementation: - OrgRepository, OrgService, OrgController, createOrgsRouter - OrgContextMiddleware: sets app.organization_id session variable so RLS enforces per-request org isolation at the database layer - JWT payload extended with organization_id claim; auth.ts backfills org_system for backward-compatible tokens - New error classes: OrgNotFoundError, OrgHasActiveAgentsError, AlreadyMemberError Tests: 373 passing, 80.64% branch coverage, zero any types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
/**
|
|
* 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',
|
|
organization_id: 'org_system',
|
|
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,
|
|
organizationId: AGENT_ROW.organization_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);
|
|
});
|
|
});
|
|
});
|