Files
sentryagent-idp/tests/unit/repositories/AgentRepository.test.ts
SentryAgent.ai Developer d252097f71 feat(phase-3): workstream 1 — Multi-Tenancy
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>
2026-03-30 00:29:32 +00:00

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);
});
});
});