/** * Unit tests for src/services/OrgService.ts */ import { OrgService } from '../../../src/services/OrgService'; import { OrgRepository } from '../../../src/repositories/OrgRepository'; import { AgentRepository } from '../../../src/repositories/AgentRepository'; import { OrgNotFoundError, OrgHasActiveAgentsError, AlreadyMemberError, ValidationError, AgentNotFoundError, } from '../../../src/utils/errors'; import { IOrganization, IOrgMember } from '../../../src/types/organization'; import { IAgent } from '../../../src/types/index'; // Mock dependencies jest.mock('../../../src/repositories/OrgRepository'); jest.mock('../../../src/repositories/AgentRepository'); const MockOrgRepository = OrgRepository as jest.MockedClass; const MockAgentRepository = AgentRepository as jest.MockedClass; const MOCK_ORG: IOrganization = { organizationId: 'org_01ABCDEFGHIJKLMNOPQRSTU', name: 'Acme Corp', slug: 'acme-corp', planTier: 'pro', maxAgents: 100, maxTokensPerMonth: 10000, status: 'active', createdAt: new Date('2026-03-28T09:00:00Z'), updatedAt: new Date('2026-03-28T09:00:00Z'), }; const MOCK_AGENT: IAgent = { agentId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', organizationId: MOCK_ORG.organizationId, 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 MOCK_MEMBER: IOrgMember = { memberId: 'mem_01ABCDEFGHIJKLMNOPQRSTU', organizationId: MOCK_ORG.organizationId, agentId: MOCK_AGENT.agentId, role: 'member', joinedAt: new Date('2026-03-28T09:00:00Z'), }; describe('OrgService', () => { let orgService: OrgService; let orgRepo: jest.Mocked; let agentRepo: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); orgRepo = new MockOrgRepository({} as never) as jest.Mocked; agentRepo = new MockAgentRepository({} as never) as jest.Mocked; orgService = new OrgService(orgRepo, agentRepo); }); // ──────────────────────────────────────────────────────────────── // createOrg // ──────────────────────────────────────────────────────────────── describe('createOrg()', () => { const createData = { name: 'Acme Corp', slug: 'acme-corp', planTier: 'pro' as const }; it('should create and return a new organization when slug is unique', async () => { orgRepo.findBySlug.mockResolvedValue(null); orgRepo.create.mockResolvedValue(MOCK_ORG); const result = await orgService.createOrg(createData); expect(result).toEqual(MOCK_ORG); expect(orgRepo.findBySlug).toHaveBeenCalledWith(createData.slug); expect(orgRepo.create).toHaveBeenCalledWith(createData); }); it('should throw ValidationError with SLUG_ALREADY_EXISTS when slug is taken', async () => { orgRepo.findBySlug.mockResolvedValue(MOCK_ORG); await expect(orgService.createOrg(createData)).rejects.toThrow(ValidationError); await expect(orgService.createOrg(createData)).rejects.toMatchObject({ details: { code: 'SLUG_ALREADY_EXISTS', slug: createData.slug }, }); expect(orgRepo.create).not.toHaveBeenCalled(); }); }); // ──────────────────────────────────────────────────────────────── // listOrgs // ──────────────────────────────────────────────────────────────── describe('listOrgs()', () => { it('should return a paginated list of organizations', async () => { orgRepo.findAll.mockResolvedValue({ orgs: [MOCK_ORG], total: 1 }); const result = await orgService.listOrgs({ page: 1, limit: 20 }); expect(result.data).toHaveLength(1); expect(result.total).toBe(1); expect(result.page).toBe(1); expect(result.limit).toBe(20); }); it('should pass filters through to the repository', async () => { orgRepo.findAll.mockResolvedValue({ orgs: [], total: 0 }); await orgService.listOrgs({ page: 2, limit: 10, status: 'active' }); expect(orgRepo.findAll).toHaveBeenCalledWith({ page: 2, limit: 10, status: 'active' }); }); it('should return an empty list when no organizations exist', async () => { orgRepo.findAll.mockResolvedValue({ orgs: [], total: 0 }); const result = await orgService.listOrgs({ page: 1, limit: 20 }); expect(result.data).toHaveLength(0); expect(result.total).toBe(0); }); }); // ──────────────────────────────────────────────────────────────── // getOrg // ──────────────────────────────────────────────────────────────── describe('getOrg()', () => { it('should return the organization when found', async () => { orgRepo.findById.mockResolvedValue(MOCK_ORG); const result = await orgService.getOrg(MOCK_ORG.organizationId); expect(result).toEqual(MOCK_ORG); expect(orgRepo.findById).toHaveBeenCalledWith(MOCK_ORG.organizationId); }); it('should throw OrgNotFoundError when organization does not exist', async () => { orgRepo.findById.mockResolvedValue(null); await expect(orgService.getOrg('nonexistent-org-id')).rejects.toThrow(OrgNotFoundError); }); }); // ──────────────────────────────────────────────────────────────── // updateOrg // ──────────────────────────────────────────────────────────────── describe('updateOrg()', () => { it('should update and return the organization', async () => { const updated = { ...MOCK_ORG, name: 'Acme Corp Updated' }; orgRepo.findById.mockResolvedValue(MOCK_ORG); orgRepo.update.mockResolvedValue(updated); const result = await orgService.updateOrg(MOCK_ORG.organizationId, { name: 'Acme Corp Updated' }); expect(result.name).toBe('Acme Corp Updated'); expect(orgRepo.update).toHaveBeenCalledWith(MOCK_ORG.organizationId, { name: 'Acme Corp Updated' }); }); it('should throw OrgNotFoundError when organization does not exist on findById', async () => { orgRepo.findById.mockResolvedValue(null); await expect( orgService.updateOrg('nonexistent-id', { name: 'New Name' }), ).rejects.toThrow(OrgNotFoundError); expect(orgRepo.update).not.toHaveBeenCalled(); }); it('should throw OrgNotFoundError when update returns null (race condition)', async () => { orgRepo.findById.mockResolvedValue(MOCK_ORG); orgRepo.update.mockResolvedValue(null); await expect( orgService.updateOrg(MOCK_ORG.organizationId, { name: 'New Name' }), ).rejects.toThrow(OrgNotFoundError); }); }); // ──────────────────────────────────────────────────────────────── // deleteOrg // ──────────────────────────────────────────────────────────────── describe('deleteOrg()', () => { it('should soft-delete the organization when it exists and has no active agents', async () => { orgRepo.findById.mockResolvedValue(MOCK_ORG); orgRepo.countActiveAgents.mockResolvedValue(0); orgRepo.softDelete.mockResolvedValue(true); await orgService.deleteOrg(MOCK_ORG.organizationId); expect(orgRepo.softDelete).toHaveBeenCalledWith(MOCK_ORG.organizationId); }); it('should throw OrgNotFoundError when organization does not exist', async () => { orgRepo.findById.mockResolvedValue(null); await expect(orgService.deleteOrg('nonexistent-id')).rejects.toThrow(OrgNotFoundError); expect(orgRepo.countActiveAgents).not.toHaveBeenCalled(); expect(orgRepo.softDelete).not.toHaveBeenCalled(); }); it('should throw OrgHasActiveAgentsError when organization has active agents', async () => { orgRepo.findById.mockResolvedValue(MOCK_ORG); orgRepo.countActiveAgents.mockResolvedValue(3); await expect(orgService.deleteOrg(MOCK_ORG.organizationId)).rejects.toThrow(OrgHasActiveAgentsError); expect(orgRepo.softDelete).not.toHaveBeenCalled(); }); it('should include activeCount in OrgHasActiveAgentsError details', async () => { orgRepo.findById.mockResolvedValue(MOCK_ORG); orgRepo.countActiveAgents.mockResolvedValue(5); await expect(orgService.deleteOrg(MOCK_ORG.organizationId)).rejects.toMatchObject({ details: { activeCount: 5 }, }); }); }); // ──────────────────────────────────────────────────────────────── // addMember // ──────────────────────────────────────────────────────────────── describe('addMember()', () => { const addMemberData = { agentId: MOCK_AGENT.agentId, role: 'member' as const }; it('should add the agent as a member and return the membership record', async () => { orgRepo.findById.mockResolvedValue(MOCK_ORG); agentRepo.findById.mockResolvedValue(MOCK_AGENT); orgRepo.findMember.mockResolvedValue(null); orgRepo.addMember.mockResolvedValue(MOCK_MEMBER); const result = await orgService.addMember(MOCK_ORG.organizationId, addMemberData); expect(result).toEqual(MOCK_MEMBER); expect(orgRepo.addMember).toHaveBeenCalledWith( MOCK_ORG.organizationId, MOCK_AGENT.agentId, 'member', ); }); it('should throw OrgNotFoundError when organization does not exist', async () => { orgRepo.findById.mockResolvedValue(null); await expect( orgService.addMember('nonexistent-org', addMemberData), ).rejects.toThrow(OrgNotFoundError); expect(agentRepo.findById).not.toHaveBeenCalled(); }); it('should throw AgentNotFoundError when agent does not exist', async () => { orgRepo.findById.mockResolvedValue(MOCK_ORG); agentRepo.findById.mockResolvedValue(null); await expect( orgService.addMember(MOCK_ORG.organizationId, addMemberData), ).rejects.toThrow(AgentNotFoundError); expect(orgRepo.findMember).not.toHaveBeenCalled(); }); it('should throw AlreadyMemberError when agent is already a member', async () => { orgRepo.findById.mockResolvedValue(MOCK_ORG); agentRepo.findById.mockResolvedValue(MOCK_AGENT); orgRepo.findMember.mockResolvedValue(MOCK_MEMBER); await expect( orgService.addMember(MOCK_ORG.organizationId, addMemberData), ).rejects.toThrow(AlreadyMemberError); expect(orgRepo.addMember).not.toHaveBeenCalled(); }); it('should add member with admin role when role is admin', async () => { orgRepo.findById.mockResolvedValue(MOCK_ORG); agentRepo.findById.mockResolvedValue(MOCK_AGENT); orgRepo.findMember.mockResolvedValue(null); orgRepo.addMember.mockResolvedValue({ ...MOCK_MEMBER, role: 'admin' }); const result = await orgService.addMember(MOCK_ORG.organizationId, { agentId: MOCK_AGENT.agentId, role: 'admin', }); expect(result.role).toBe('admin'); expect(orgRepo.addMember).toHaveBeenCalledWith( MOCK_ORG.organizationId, MOCK_AGENT.agentId, 'admin', ); }); }); });