diff --git a/src/modules/hr/__tests__/contracts.service.test.ts b/src/modules/hr/__tests__/contracts.service.test.ts new file mode 100644 index 0000000..b807913 --- /dev/null +++ b/src/modules/hr/__tests__/contracts.service.test.ts @@ -0,0 +1,573 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; + +// Mock query functions +const mockQuery = jest.fn(); +const mockQueryOne = jest.fn(); + +jest.mock('../../../config/database.js', () => ({ + query: (...args: any[]) => mockQuery(...args), + queryOne: (...args: any[]) => mockQueryOne(...args), +})); + +// Import after mocking +import { contractsService } from '../contracts.service.js'; +import { NotFoundError, ValidationError } from '../../../shared/errors/index.js'; + +// Helper factory +function createMockContract(overrides: Record = {}) { + return { + id: 'contract-uuid-1', + tenant_id: 'test-tenant-uuid', + company_id: 'company-uuid-1', + company_name: 'Test Company', + employee_id: 'employee-uuid-1', + employee_name: 'John Doe', + employee_number: 'EMP-001', + name: 'Contract John Doe 2026', + reference: 'CON-001', + contract_type: 'permanent', + status: 'draft', + job_position_id: 'position-uuid-1', + job_position_name: 'Developer', + department_id: 'dept-uuid-1', + department_name: 'Engineering', + date_start: new Date('2026-01-01'), + date_end: null, + trial_date_end: new Date('2026-04-01'), + wage: 50000, + wage_type: 'monthly', + currency_id: 'currency-uuid-1', + currency_code: 'MXN', + hours_per_week: 40, + vacation_days: 12, + christmas_bonus_days: 15, + document_url: null, + notes: null, + created_at: new Date(), + ...overrides, + }; +} + +describe('ContractsService', () => { + const tenantId = 'test-tenant-uuid'; + const userId = 'test-user-uuid'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return contracts with pagination', async () => { + const mockContracts = [ + createMockContract({ id: '1', name: 'Contract 1' }), + createMockContract({ id: '2', name: 'Contract 2' }), + ]; + + mockQueryOne.mockResolvedValue({ count: '2' }); + mockQuery.mockResolvedValue(mockContracts); + + const result = await contractsService.findAll(tenantId, { page: 1, limit: 20 }); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it('should filter by company_id', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await contractsService.findAll(tenantId, { company_id: 'company-uuid' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('c.company_id = $'), + expect.arrayContaining([tenantId, 'company-uuid']) + ); + }); + + it('should filter by employee_id', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await contractsService.findAll(tenantId, { employee_id: 'employee-uuid' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('c.employee_id = $'), + expect.arrayContaining([tenantId, 'employee-uuid']) + ); + }); + + it('should filter by status', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await contractsService.findAll(tenantId, { status: 'active' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('c.status = $'), + expect.arrayContaining([tenantId, 'active']) + ); + }); + + it('should filter by contract_type', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await contractsService.findAll(tenantId, { contract_type: 'temporary' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('c.contract_type = $'), + expect.arrayContaining([tenantId, 'temporary']) + ); + }); + + it('should filter by search term', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await contractsService.findAll(tenantId, { search: 'John' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('c.name ILIKE'), + expect.arrayContaining([tenantId, '%John%']) + ); + }); + + it('should apply pagination correctly', async () => { + mockQueryOne.mockResolvedValue({ count: '50' }); + mockQuery.mockResolvedValue([]); + + await contractsService.findAll(tenantId, { page: 3, limit: 10 }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('LIMIT'), + expect.arrayContaining([10, 20]) // limit=10, offset=20 (page 3) + ); + }); + }); + + describe('findById', () => { + it('should return contract when found', async () => { + const mockContract = createMockContract(); + mockQueryOne.mockResolvedValue(mockContract); + + const result = await contractsService.findById('contract-uuid-1', tenantId); + + expect(result).toEqual(mockContract); + }); + + it('should throw NotFoundError when contract not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + contractsService.findById('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should throw NotFoundError with correct message', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + contractsService.findById('nonexistent-id', tenantId) + ).rejects.toThrow('Contrato no encontrado'); + }); + }); + + describe('create', () => { + const createDto = { + company_id: 'company-uuid', + employee_id: 'employee-uuid-1', + name: 'New Contract', + contract_type: 'permanent' as const, + date_start: '2026-01-15', + wage: 45000, + }; + + it('should create contract successfully', async () => { + const newContract = createMockContract({ ...createDto, id: 'new-uuid' }); + + // No active contract exists + mockQueryOne.mockResolvedValueOnce(null); + // INSERT returns new contract + mockQueryOne.mockResolvedValueOnce({ id: 'new-uuid' }); + // findById for return value + mockQueryOne.mockResolvedValueOnce(newContract); + + const result = await contractsService.create(createDto, tenantId, userId); + + expect(result.name).toBe('New Contract'); + }); + + it('should throw ValidationError when employee already has active contract', async () => { + mockQueryOne.mockResolvedValue({ id: 'existing-active-contract' }); + + await expect( + contractsService.create(createDto, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with correct message for active contract', async () => { + mockQueryOne.mockResolvedValue({ id: 'existing-active-contract' }); + + await expect( + contractsService.create(createDto, tenantId, userId) + ).rejects.toThrow('El empleado ya tiene un contrato activo'); + }); + + it('should create contract with all optional fields', async () => { + const fullDto = { + company_id: 'company-uuid', + employee_id: 'employee-uuid-1', + name: 'Full Contract', + reference: 'REF-001', + contract_type: 'temporary' as const, + job_position_id: 'position-uuid', + department_id: 'dept-uuid', + date_start: '2026-01-15', + date_end: '2026-12-31', + trial_date_end: '2026-04-15', + wage: 45000, + wage_type: 'monthly', + currency_id: 'currency-uuid', + hours_per_week: 48, + vacation_days: 12, + christmas_bonus_days: 15, + document_url: 'https://docs.example.com/contract.pdf', + notes: 'Test notes', + }; + + mockQueryOne.mockResolvedValueOnce(null); + mockQueryOne.mockResolvedValueOnce({ id: 'new-uuid' }); + mockQueryOne.mockResolvedValueOnce(createMockContract(fullDto)); + + const result = await contractsService.create(fullDto, tenantId, userId); + + expect(result.name).toBe('Full Contract'); + }); + }); + + describe('update', () => { + it('should update contract in draft status', async () => { + const existingContract = createMockContract({ status: 'draft' }); + const updatedContract = createMockContract({ status: 'draft', wage: 55000 }); + + mockQueryOne.mockResolvedValueOnce(existingContract); // findById + mockQuery.mockResolvedValue([]); // UPDATE + mockQueryOne.mockResolvedValueOnce(updatedContract); // findById after update + + const result = await contractsService.update( + 'contract-uuid-1', + { wage: 55000 }, + tenantId, + userId + ); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('UPDATE hr.contracts SET'), + expect.any(Array) + ); + }); + + it('should throw NotFoundError when contract not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + contractsService.update('nonexistent-id', { wage: 50000 }, tenantId, userId) + ).rejects.toThrow(NotFoundError); + }); + + it('should throw ValidationError when contract is not in draft status', async () => { + const activeContract = createMockContract({ status: 'active' }); + mockQueryOne.mockResolvedValue(activeContract); + + await expect( + contractsService.update('contract-uuid-1', { wage: 50000 }, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with correct message for non-draft contract', async () => { + const activeContract = createMockContract({ status: 'active' }); + mockQueryOne.mockResolvedValue(activeContract); + + await expect( + contractsService.update('contract-uuid-1', { wage: 50000 }, tenantId, userId) + ).rejects.toThrow('Solo se pueden editar contratos en estado borrador'); + }); + + it('should return unchanged contract when no fields to update', async () => { + const existingContract = createMockContract({ status: 'draft' }); + mockQueryOne.mockResolvedValue(existingContract); + + const result = await contractsService.update( + 'contract-uuid-1', + {}, + tenantId, + userId + ); + + expect(result).toEqual(existingContract); + }); + }); + + describe('activate', () => { + it('should activate draft contract', async () => { + const draftContract = createMockContract({ status: 'draft' }); + const activatedContract = createMockContract({ status: 'active' }); + + mockQueryOne.mockResolvedValueOnce(draftContract); // findById + mockQueryOne.mockResolvedValueOnce(null); // no other active contract + mockQuery.mockResolvedValueOnce([]); // UPDATE status + mockQuery.mockResolvedValueOnce([]); // UPDATE employee + mockQueryOne.mockResolvedValueOnce(activatedContract); // findById after update + + const result = await contractsService.activate('contract-uuid-1', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'active'"), + expect.any(Array) + ); + }); + + it('should throw ValidationError when contract is not draft', async () => { + const activeContract = createMockContract({ status: 'active' }); + mockQueryOne.mockResolvedValue(activeContract); + + await expect( + contractsService.activate('contract-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with correct message for non-draft', async () => { + const activeContract = createMockContract({ status: 'active' }); + mockQueryOne.mockResolvedValue(activeContract); + + await expect( + contractsService.activate('contract-uuid-1', tenantId, userId) + ).rejects.toThrow('Solo se pueden activar contratos en estado borrador'); + }); + + it('should throw ValidationError when employee has another active contract', async () => { + const draftContract = createMockContract({ status: 'draft' }); + + mockQueryOne.mockResolvedValueOnce(draftContract); // findById + mockQueryOne.mockResolvedValueOnce({ id: 'other-contract-uuid' }); // active contract exists + + await expect( + contractsService.activate('contract-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with correct message for existing active contract', async () => { + const draftContract = createMockContract({ status: 'draft' }); + + mockQueryOne.mockResolvedValueOnce(draftContract); + mockQueryOne.mockResolvedValueOnce({ id: 'other-contract-uuid' }); + + await expect( + contractsService.activate('contract-uuid-1', tenantId, userId) + ).rejects.toThrow('El empleado ya tiene otro contrato activo'); + }); + + it('should update employee department and position when specified', async () => { + const draftContract = createMockContract({ + status: 'draft', + department_id: 'dept-uuid', + job_position_id: 'position-uuid', + }); + + mockQueryOne.mockResolvedValueOnce(draftContract); + mockQueryOne.mockResolvedValueOnce(null); + mockQuery.mockResolvedValueOnce([]); + mockQuery.mockResolvedValueOnce([]); + mockQueryOne.mockResolvedValueOnce(createMockContract({ status: 'active' })); + + await contractsService.activate('contract-uuid-1', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('UPDATE hr.employees SET'), + expect.any(Array) + ); + }); + }); + + describe('terminate', () => { + it('should terminate active contract', async () => { + const activeContract = createMockContract({ status: 'active' }); + const terminatedContract = createMockContract({ status: 'terminated' }); + + mockQueryOne.mockResolvedValueOnce(activeContract); // findById + mockQuery.mockResolvedValue([]); // UPDATE + mockQueryOne.mockResolvedValueOnce(terminatedContract); // findById after update + + const result = await contractsService.terminate( + 'contract-uuid-1', + '2026-12-31', + tenantId, + userId + ); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'terminated'"), + expect.any(Array) + ); + }); + + it('should throw ValidationError when contract is not active', async () => { + const draftContract = createMockContract({ status: 'draft' }); + mockQueryOne.mockResolvedValue(draftContract); + + await expect( + contractsService.terminate('contract-uuid-1', '2026-12-31', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with correct message for non-active', async () => { + const draftContract = createMockContract({ status: 'draft' }); + mockQueryOne.mockResolvedValue(draftContract); + + await expect( + contractsService.terminate('contract-uuid-1', '2026-12-31', tenantId, userId) + ).rejects.toThrow('Solo se pueden terminar contratos activos'); + }); + + it('should set the termination date', async () => { + const activeContract = createMockContract({ status: 'active' }); + + mockQueryOne.mockResolvedValueOnce(activeContract); + mockQuery.mockResolvedValue([]); + mockQueryOne.mockResolvedValueOnce(createMockContract({ status: 'terminated' })); + + await contractsService.terminate('contract-uuid-1', '2026-06-15', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('date_end = $'), + expect.arrayContaining(['2026-06-15']) + ); + }); + }); + + describe('cancel', () => { + it('should cancel draft contract', async () => { + const draftContract = createMockContract({ status: 'draft' }); + const cancelledContract = createMockContract({ status: 'cancelled' }); + + mockQueryOne.mockResolvedValueOnce(draftContract); // findById + mockQuery.mockResolvedValue([]); // UPDATE + mockQueryOne.mockResolvedValueOnce(cancelledContract); // findById after update + + const result = await contractsService.cancel('contract-uuid-1', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'cancelled'"), + expect.any(Array) + ); + }); + + it('should throw ValidationError when contract is active', async () => { + const activeContract = createMockContract({ status: 'active' }); + mockQueryOne.mockResolvedValue(activeContract); + + await expect( + contractsService.cancel('contract-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when contract is terminated', async () => { + const terminatedContract = createMockContract({ status: 'terminated' }); + mockQueryOne.mockResolvedValue(terminatedContract); + + await expect( + contractsService.cancel('contract-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with correct message', async () => { + const activeContract = createMockContract({ status: 'active' }); + mockQueryOne.mockResolvedValue(activeContract); + + await expect( + contractsService.cancel('contract-uuid-1', tenantId, userId) + ).rejects.toThrow('No se puede cancelar un contrato activo o terminado'); + }); + + it('should allow cancelling expired contract', async () => { + const expiredContract = createMockContract({ status: 'expired' }); + const cancelledContract = createMockContract({ status: 'cancelled' }); + + mockQueryOne.mockResolvedValueOnce(expiredContract); + mockQuery.mockResolvedValue([]); + mockQueryOne.mockResolvedValueOnce(cancelledContract); + + await contractsService.cancel('contract-uuid-1', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'cancelled'"), + expect.any(Array) + ); + }); + }); + + describe('delete', () => { + it('should delete draft contract', async () => { + const draftContract = createMockContract({ status: 'draft' }); + + mockQueryOne.mockResolvedValueOnce(draftContract); // findById + mockQuery.mockResolvedValue([]); // DELETE + + await contractsService.delete('contract-uuid-1', tenantId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM hr.contracts'), + expect.any(Array) + ); + }); + + it('should delete cancelled contract', async () => { + const cancelledContract = createMockContract({ status: 'cancelled' }); + + mockQueryOne.mockResolvedValueOnce(cancelledContract); + mockQuery.mockResolvedValue([]); + + await contractsService.delete('contract-uuid-1', tenantId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM hr.contracts'), + expect.any(Array) + ); + }); + + it('should throw NotFoundError when contract not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + contractsService.delete('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should throw ValidationError when contract is active', async () => { + const activeContract = createMockContract({ status: 'active' }); + mockQueryOne.mockResolvedValue(activeContract); + + await expect( + contractsService.delete('contract-uuid-1', tenantId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError when contract is terminated', async () => { + const terminatedContract = createMockContract({ status: 'terminated' }); + mockQueryOne.mockResolvedValue(terminatedContract); + + await expect( + contractsService.delete('contract-uuid-1', tenantId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with correct message', async () => { + const activeContract = createMockContract({ status: 'active' }); + mockQueryOne.mockResolvedValue(activeContract); + + await expect( + contractsService.delete('contract-uuid-1', tenantId) + ).rejects.toThrow('Solo se pueden eliminar contratos en borrador o cancelados'); + }); + }); +}); diff --git a/src/modules/hr/__tests__/departments.service.test.ts b/src/modules/hr/__tests__/departments.service.test.ts new file mode 100644 index 0000000..1c750c9 --- /dev/null +++ b/src/modules/hr/__tests__/departments.service.test.ts @@ -0,0 +1,626 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; + +// Mock query functions +const mockQuery = jest.fn(); +const mockQueryOne = jest.fn(); + +jest.mock('../../../config/database.js', () => ({ + query: (...args: any[]) => mockQuery(...args), + queryOne: (...args: any[]) => mockQueryOne(...args), +})); + +// Import after mocking +import { departmentsService } from '../departments.service.js'; +import { NotFoundError, ConflictError } from '../../../shared/errors/index.js'; + +// Helper factories +function createMockDepartment(overrides: Record = {}) { + return { + id: 'dept-uuid-1', + tenant_id: 'test-tenant-uuid', + company_id: 'company-uuid-1', + company_name: 'Test Company', + name: 'Engineering', + code: 'ENG', + parent_id: null, + parent_name: null, + manager_id: null, + manager_name: null, + description: 'Engineering department', + color: '#3B82F6', + active: true, + employee_count: 5, + created_at: new Date(), + ...overrides, + }; +} + +function createMockJobPosition(overrides: Record = {}) { + return { + id: 'position-uuid-1', + tenant_id: 'test-tenant-uuid', + name: 'Senior Developer', + department_id: 'dept-uuid-1', + department_name: 'Engineering', + description: 'Senior software developer position', + requirements: 'Experience with TypeScript', + responsibilities: 'Code review, mentoring', + min_salary: 50000, + max_salary: 80000, + active: true, + employee_count: 3, + created_at: new Date(), + ...overrides, + }; +} + +describe('DepartmentsService', () => { + const tenantId = 'test-tenant-uuid'; + const userId = 'test-user-uuid'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ========== DEPARTMENTS ========== + + describe('findAll', () => { + it('should return departments with pagination', async () => { + const mockDepartments = [ + createMockDepartment({ id: '1', name: 'Engineering' }), + createMockDepartment({ id: '2', name: 'Marketing' }), + ]; + + mockQueryOne.mockResolvedValue({ count: '2' }); + mockQuery.mockResolvedValue(mockDepartments); + + const result = await departmentsService.findAll(tenantId, { page: 1, limit: 50 }); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it('should filter by company_id', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await departmentsService.findAll(tenantId, { company_id: 'company-uuid' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('d.company_id = $'), + expect.arrayContaining([tenantId, 'company-uuid']) + ); + }); + + it('should filter by active status', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await departmentsService.findAll(tenantId, { active: true }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('d.active = $'), + expect.arrayContaining([tenantId, true]) + ); + }); + + it('should filter by search term', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await departmentsService.findAll(tenantId, { search: 'Eng' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('d.name ILIKE'), + expect.arrayContaining([tenantId, '%Eng%']) + ); + }); + + it('should apply pagination correctly', async () => { + mockQueryOne.mockResolvedValue({ count: '100' }); + mockQuery.mockResolvedValue([]); + + await departmentsService.findAll(tenantId, { page: 3, limit: 20 }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('LIMIT'), + expect.arrayContaining([20, 40]) // limit=20, offset=40 (page 3) + ); + }); + + it('should return empty result when no departments found', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + const result = await departmentsService.findAll(tenantId, {}); + + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + }); + }); + + describe('findById', () => { + it('should return department when found', async () => { + const mockDepartment = createMockDepartment(); + mockQueryOne.mockResolvedValue(mockDepartment); + + const result = await departmentsService.findById('dept-uuid-1', tenantId); + + expect(result).toEqual(mockDepartment); + }); + + it('should throw NotFoundError when department not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + departmentsService.findById('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should throw NotFoundError with correct message', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + departmentsService.findById('nonexistent-id', tenantId) + ).rejects.toThrow('Departamento no encontrado'); + }); + }); + + describe('create', () => { + const createDto = { + company_id: 'company-uuid', + name: 'New Department', + code: 'NEW', + description: 'A new department', + }; + + it('should create department successfully', async () => { + const newDept = createMockDepartment({ ...createDto, id: 'new-uuid' }); + + // No existing department with same name + mockQueryOne.mockResolvedValueOnce(null); + // INSERT returns new department + mockQueryOne.mockResolvedValueOnce({ id: 'new-uuid' }); + // findById for return value + mockQueryOne.mockResolvedValueOnce(newDept); + + const result = await departmentsService.create(createDto, tenantId, userId); + + expect(result.name).toBe('New Department'); + }); + + it('should throw ConflictError when department name already exists', async () => { + mockQueryOne.mockResolvedValue({ id: 'existing-uuid' }); + + await expect( + departmentsService.create(createDto, tenantId, userId) + ).rejects.toThrow(ConflictError); + }); + + it('should throw ConflictError with correct message for duplicate', async () => { + mockQueryOne.mockResolvedValue({ id: 'existing-uuid' }); + + await expect( + departmentsService.create(createDto, tenantId, userId) + ).rejects.toThrow('Ya existe un departamento con ese nombre en esta empresa'); + }); + + it('should create department with optional fields', async () => { + const fullDto = { + company_id: 'company-uuid', + name: 'Full Department', + code: 'FULL', + parent_id: 'parent-uuid', + manager_id: 'manager-uuid', + description: 'Full description', + color: '#FF0000', + }; + + mockQueryOne.mockResolvedValueOnce(null); + mockQueryOne.mockResolvedValueOnce({ id: 'new-uuid' }); + mockQueryOne.mockResolvedValueOnce(createMockDepartment({ ...fullDto })); + + const result = await departmentsService.create(fullDto, tenantId, userId); + + expect(result.name).toBe('Full Department'); + }); + }); + + describe('update', () => { + it('should update department successfully', async () => { + const existingDept = createMockDepartment(); + const updatedDept = createMockDepartment({ name: 'Updated Name' }); + + mockQueryOne.mockResolvedValueOnce(existingDept); // findById + mockQueryOne.mockResolvedValueOnce(null); // no name conflict + mockQuery.mockResolvedValue([]); // UPDATE + mockQueryOne.mockResolvedValueOnce(updatedDept); // findById after update + + const result = await departmentsService.update( + 'dept-uuid-1', + { name: 'Updated Name' }, + tenantId + ); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('UPDATE hr.departments SET'), + expect.any(Array) + ); + }); + + it('should throw NotFoundError when department not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + departmentsService.update('nonexistent-id', { name: 'Test' }, tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should return unchanged department when no fields to update', async () => { + const existingDept = createMockDepartment(); + mockQueryOne.mockResolvedValue(existingDept); + + const result = await departmentsService.update( + 'dept-uuid-1', + {}, + tenantId + ); + + expect(result).toEqual(existingDept); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('should throw ConflictError when updating to existing name', async () => { + const existingDept = createMockDepartment({ name: 'Original' }); + + mockQueryOne.mockResolvedValueOnce(existingDept); // findById + mockQueryOne.mockResolvedValueOnce({ id: 'other-uuid' }); // name check + + await expect( + departmentsService.update('dept-uuid-1', { name: 'Existing Name' }, tenantId) + ).rejects.toThrow(ConflictError); + }); + + it('should update multiple fields at once', async () => { + const existingDept = createMockDepartment(); + const updatedDept = createMockDepartment({ + name: 'New Name', + code: 'NEW', + active: false, + }); + + mockQueryOne.mockResolvedValueOnce(existingDept); + mockQueryOne.mockResolvedValueOnce(null); // no name conflict + mockQuery.mockResolvedValue([]); + mockQueryOne.mockResolvedValueOnce(updatedDept); + + const result = await departmentsService.update( + 'dept-uuid-1', + { name: 'New Name', code: 'NEW', active: false }, + tenantId + ); + + expect(mockQuery).toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('should delete department without employees or subdepartments', async () => { + const department = createMockDepartment({ employee_count: 0 }); + + mockQueryOne + .mockResolvedValueOnce(department) // findById + .mockResolvedValueOnce({ count: '0' }) // hasEmployees + .mockResolvedValueOnce({ count: '0' }); // hasChildren + mockQuery.mockResolvedValue([]); + + await departmentsService.delete('dept-uuid-1', tenantId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM hr.departments'), + expect.any(Array) + ); + }); + + it('should throw NotFoundError when department not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + departmentsService.delete('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should throw ConflictError when department has employees', async () => { + const department = createMockDepartment(); + + mockQueryOne + .mockResolvedValueOnce(department) // findById + .mockResolvedValueOnce({ count: '5' }); // hasEmployees + + await expect( + departmentsService.delete('dept-uuid-1', tenantId) + ).rejects.toThrow(ConflictError); + }); + + it('should throw ConflictError with correct message for employees', async () => { + const department = createMockDepartment(); + + mockQueryOne + .mockResolvedValueOnce(department) + .mockResolvedValueOnce({ count: '5' }); + + await expect( + departmentsService.delete('dept-uuid-1', tenantId) + ).rejects.toThrow('No se puede eliminar un departamento con empleados asociados'); + }); + + it('should throw ConflictError when department has subdepartments', async () => { + const department = createMockDepartment(); + + mockQueryOne + .mockResolvedValueOnce(department) // findById + .mockResolvedValueOnce({ count: '0' }) // hasEmployees + .mockResolvedValueOnce({ count: '3' }); // hasChildren + + await expect( + departmentsService.delete('dept-uuid-1', tenantId) + ).rejects.toThrow(ConflictError); + }); + + it('should throw ConflictError with correct message for subdepartments', async () => { + const department = createMockDepartment(); + + mockQueryOne + .mockResolvedValueOnce(department) + .mockResolvedValueOnce({ count: '0' }) + .mockResolvedValueOnce({ count: '3' }); + + await expect( + departmentsService.delete('dept-uuid-1', tenantId) + ).rejects.toThrow('No se puede eliminar un departamento con subdepartamentos'); + }); + }); + + // ========== JOB POSITIONS ========== + + describe('getJobPositions', () => { + it('should return active job positions by default', async () => { + const mockPositions = [ + createMockJobPosition({ id: '1', name: 'Developer' }), + createMockJobPosition({ id: '2', name: 'Designer' }), + ]; + + mockQuery.mockResolvedValue(mockPositions); + + const result = await departmentsService.getJobPositions(tenantId); + + expect(result).toHaveLength(2); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('j.active = TRUE'), + [tenantId] + ); + }); + + it('should include inactive positions when flag is true', async () => { + mockQuery.mockResolvedValue([]); + + await departmentsService.getJobPositions(tenantId, true); + + expect(mockQuery).toHaveBeenCalledWith( + expect.not.stringContaining('j.active = TRUE'), + [tenantId] + ); + }); + + it('should return empty array when no positions found', async () => { + mockQuery.mockResolvedValue([]); + + const result = await departmentsService.getJobPositions(tenantId); + + expect(result).toHaveLength(0); + }); + }); + + describe('getJobPositionById', () => { + it('should return job position when found', async () => { + const mockPosition = createMockJobPosition(); + mockQueryOne.mockResolvedValue(mockPosition); + + const result = await departmentsService.getJobPositionById('position-uuid-1', tenantId); + + expect(result).toEqual(mockPosition); + }); + + it('should throw NotFoundError when position not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + departmentsService.getJobPositionById('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should throw NotFoundError with correct message', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + departmentsService.getJobPositionById('nonexistent-id', tenantId) + ).rejects.toThrow('Puesto no encontrado'); + }); + }); + + describe('createJobPosition', () => { + const createDto = { + name: 'New Position', + department_id: 'dept-uuid-1', + description: 'New position description', + }; + + it('should create job position successfully', async () => { + const newPosition = createMockJobPosition({ ...createDto, id: 'new-uuid' }); + + mockQueryOne.mockResolvedValueOnce(null); // No existing + mockQueryOne.mockResolvedValueOnce(newPosition); // INSERT + + const result = await departmentsService.createJobPosition(createDto, tenantId); + + expect(result.name).toBe('New Position'); + }); + + it('should throw ConflictError when position name already exists', async () => { + mockQueryOne.mockResolvedValue({ id: 'existing-uuid' }); + + await expect( + departmentsService.createJobPosition(createDto, tenantId) + ).rejects.toThrow(ConflictError); + }); + + it('should throw ConflictError with correct message for duplicate', async () => { + mockQueryOne.mockResolvedValue({ id: 'existing-uuid' }); + + await expect( + departmentsService.createJobPosition(createDto, tenantId) + ).rejects.toThrow('Ya existe un puesto con ese nombre'); + }); + + it('should create position with salary range', async () => { + const dtoWithSalary = { + name: 'Senior Developer', + min_salary: 50000, + max_salary: 80000, + }; + + mockQueryOne.mockResolvedValueOnce(null); + mockQueryOne.mockResolvedValueOnce(createMockJobPosition(dtoWithSalary)); + + const result = await departmentsService.createJobPosition(dtoWithSalary, tenantId); + + expect(result.min_salary).toBe(50000); + expect(result.max_salary).toBe(80000); + }); + }); + + describe('updateJobPosition', () => { + it('should update job position successfully', async () => { + const existingPosition = createMockJobPosition(); + const updatedPosition = createMockJobPosition({ name: 'Updated Position' }); + + mockQueryOne.mockResolvedValueOnce(existingPosition); // getById + mockQueryOne.mockResolvedValueOnce(null); // no name conflict + mockQuery.mockResolvedValue([]); // UPDATE + mockQueryOne.mockResolvedValueOnce(updatedPosition); // getById after update + + const result = await departmentsService.updateJobPosition( + 'position-uuid-1', + { name: 'Updated Position' }, + tenantId + ); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('UPDATE hr.job_positions SET'), + expect.any(Array) + ); + }); + + it('should throw NotFoundError when position not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + departmentsService.updateJobPosition('nonexistent-id', { name: 'Test' }, tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should return unchanged position when no fields to update', async () => { + const existingPosition = createMockJobPosition(); + mockQueryOne.mockResolvedValue(existingPosition); + + const result = await departmentsService.updateJobPosition( + 'position-uuid-1', + {}, + tenantId + ); + + expect(result).toEqual(existingPosition); + }); + + it('should throw ConflictError when updating to existing name', async () => { + const existingPosition = createMockJobPosition({ name: 'Original' }); + + mockQueryOne.mockResolvedValueOnce(existingPosition); // getById + mockQueryOne.mockResolvedValueOnce({ id: 'other-uuid' }); // name check + + await expect( + departmentsService.updateJobPosition('position-uuid-1', { name: 'Existing Name' }, tenantId) + ).rejects.toThrow(ConflictError); + }); + + it('should update salary fields', async () => { + const existingPosition = createMockJobPosition(); + const updatedPosition = createMockJobPosition({ + min_salary: 60000, + max_salary: 90000, + }); + + mockQueryOne.mockResolvedValueOnce(existingPosition); + mockQuery.mockResolvedValue([]); + mockQueryOne.mockResolvedValueOnce(updatedPosition); + + await departmentsService.updateJobPosition( + 'position-uuid-1', + { min_salary: 60000, max_salary: 90000 }, + tenantId + ); + + expect(mockQuery).toHaveBeenCalled(); + }); + }); + + describe('deleteJobPosition', () => { + it('should delete position without employees', async () => { + const position = createMockJobPosition({ employee_count: 0 }); + + mockQueryOne + .mockResolvedValueOnce(position) // getById + .mockResolvedValueOnce({ count: '0' }); // hasEmployees + mockQuery.mockResolvedValue([]); + + await departmentsService.deleteJobPosition('position-uuid-1', tenantId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM hr.job_positions'), + expect.any(Array) + ); + }); + + it('should throw NotFoundError when position not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + departmentsService.deleteJobPosition('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should throw ConflictError when position has employees', async () => { + const position = createMockJobPosition(); + + mockQueryOne + .mockResolvedValueOnce(position) // getById + .mockResolvedValueOnce({ count: '5' }); // hasEmployees + + await expect( + departmentsService.deleteJobPosition('position-uuid-1', tenantId) + ).rejects.toThrow(ConflictError); + }); + + it('should throw ConflictError with correct message for assigned employees', async () => { + const position = createMockJobPosition(); + + mockQueryOne + .mockResolvedValueOnce(position) + .mockResolvedValueOnce({ count: '5' }); + + await expect( + departmentsService.deleteJobPosition('position-uuid-1', tenantId) + ).rejects.toThrow('No se puede eliminar un puesto con empleados asociados'); + }); + }); +}); diff --git a/src/modules/hr/__tests__/leaves.service.test.ts b/src/modules/hr/__tests__/leaves.service.test.ts new file mode 100644 index 0000000..95a14a3 --- /dev/null +++ b/src/modules/hr/__tests__/leaves.service.test.ts @@ -0,0 +1,926 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; + +// Mock query functions +const mockQuery = jest.fn(); +const mockQueryOne = jest.fn(); + +jest.mock('../../../config/database.js', () => ({ + query: (...args: any[]) => mockQuery(...args), + queryOne: (...args: any[]) => mockQueryOne(...args), +})); + +// Import after mocking +import { leavesService } from '../leaves.service.js'; +import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js'; + +// Helper factories +function createMockLeaveType(overrides: Record = {}) { + return { + id: 'leave-type-uuid-1', + tenant_id: 'test-tenant-uuid', + name: 'Vacaciones', + code: 'VAC', + leave_type: 'vacation', + requires_approval: true, + max_days: 30, + is_paid: true, + color: '#3B82F6', + active: true, + created_at: new Date(), + ...overrides, + }; +} + +function createMockLeave(overrides: Record = {}) { + return { + id: 'leave-uuid-1', + tenant_id: 'test-tenant-uuid', + company_id: 'company-uuid-1', + company_name: 'Test Company', + employee_id: 'employee-uuid-1', + employee_name: 'John Doe', + employee_number: 'EMP-001', + leave_type_id: 'leave-type-uuid-1', + leave_type_name: 'Vacaciones', + name: 'Vacation Request', + date_from: new Date('2026-02-01'), + date_to: new Date('2026-02-05'), + number_of_days: 5, + status: 'draft', + description: 'Vacation time', + approved_by: null, + approved_by_name: null, + approved_at: null, + rejection_reason: null, + created_at: new Date(), + ...overrides, + }; +} + +describe('LeavesService', () => { + const tenantId = 'test-tenant-uuid'; + const userId = 'test-user-uuid'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ========== LEAVE TYPES ========== + + describe('getLeaveTypes', () => { + it('should return active leave types by default', async () => { + const mockLeaveTypes = [ + createMockLeaveType({ id: '1', name: 'Vacaciones' }), + createMockLeaveType({ id: '2', name: 'Enfermedad' }), + ]; + + mockQuery.mockResolvedValue(mockLeaveTypes); + + const result = await leavesService.getLeaveTypes(tenantId); + + expect(result).toHaveLength(2); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('active = TRUE'), + [tenantId] + ); + }); + + it('should include inactive types when flag is true', async () => { + mockQuery.mockResolvedValue([]); + + await leavesService.getLeaveTypes(tenantId, true); + + expect(mockQuery).toHaveBeenCalledWith( + expect.not.stringContaining('active = TRUE'), + [tenantId] + ); + }); + + it('should return empty array when no types found', async () => { + mockQuery.mockResolvedValue([]); + + const result = await leavesService.getLeaveTypes(tenantId); + + expect(result).toHaveLength(0); + }); + }); + + describe('getLeaveTypeById', () => { + it('should return leave type when found', async () => { + const mockLeaveType = createMockLeaveType(); + mockQueryOne.mockResolvedValue(mockLeaveType); + + const result = await leavesService.getLeaveTypeById('leave-type-uuid-1', tenantId); + + expect(result).toEqual(mockLeaveType); + }); + + it('should throw NotFoundError when type not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + leavesService.getLeaveTypeById('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should throw NotFoundError with correct message', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + leavesService.getLeaveTypeById('nonexistent-id', tenantId) + ).rejects.toThrow('Tipo de ausencia no encontrado'); + }); + }); + + describe('createLeaveType', () => { + const createDto = { + name: 'New Leave Type', + code: 'NEW', + leave_type: 'personal' as const, + }; + + it('should create leave type successfully', async () => { + const newLeaveType = createMockLeaveType({ ...createDto, id: 'new-uuid' }); + + mockQueryOne.mockResolvedValueOnce(null); // No existing + mockQueryOne.mockResolvedValueOnce(newLeaveType); // INSERT + + const result = await leavesService.createLeaveType(createDto, tenantId); + + expect(result.name).toBe('New Leave Type'); + }); + + it('should throw ConflictError when name already exists', async () => { + mockQueryOne.mockResolvedValue({ id: 'existing-uuid' }); + + await expect( + leavesService.createLeaveType(createDto, tenantId) + ).rejects.toThrow(ConflictError); + }); + + it('should throw ConflictError with correct message', async () => { + mockQueryOne.mockResolvedValue({ id: 'existing-uuid' }); + + await expect( + leavesService.createLeaveType(createDto, tenantId) + ).rejects.toThrow('Ya existe un tipo de ausencia con ese nombre'); + }); + + it('should create leave type with all optional fields', async () => { + const fullDto = { + name: 'Full Leave Type', + code: 'FULL', + leave_type: 'vacation' as const, + requires_approval: false, + max_days: 15, + is_paid: false, + color: '#FF0000', + }; + + mockQueryOne.mockResolvedValueOnce(null); + mockQueryOne.mockResolvedValueOnce(createMockLeaveType(fullDto)); + + const result = await leavesService.createLeaveType(fullDto, tenantId); + + expect(result.name).toBe('Full Leave Type'); + }); + + it('should use default values for optional fields', async () => { + const minimalDto = { + name: 'Minimal Type', + leave_type: 'other' as const, + }; + + mockQueryOne.mockResolvedValueOnce(null); + mockQueryOne.mockResolvedValueOnce(createMockLeaveType({ + ...minimalDto, + requires_approval: true, + is_paid: true, + })); + + const result = await leavesService.createLeaveType(minimalDto, tenantId); + + expect(result.requires_approval).toBe(true); + expect(result.is_paid).toBe(true); + }); + }); + + describe('updateLeaveType', () => { + it('should update leave type successfully', async () => { + const existingType = createMockLeaveType(); + const updatedType = createMockLeaveType({ name: 'Updated Name' }); + + mockQueryOne.mockResolvedValueOnce(existingType); // getById + mockQueryOne.mockResolvedValueOnce(null); // no name conflict + mockQuery.mockResolvedValue([]); // UPDATE + mockQueryOne.mockResolvedValueOnce(updatedType); // getById after update + + const result = await leavesService.updateLeaveType( + 'leave-type-uuid-1', + { name: 'Updated Name' }, + tenantId + ); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('UPDATE hr.leave_types SET'), + expect.any(Array) + ); + }); + + it('should throw NotFoundError when type not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + leavesService.updateLeaveType('nonexistent-id', { name: 'Test' }, tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should return unchanged type when no fields to update', async () => { + const existingType = createMockLeaveType(); + mockQueryOne.mockResolvedValue(existingType); + + const result = await leavesService.updateLeaveType( + 'leave-type-uuid-1', + {}, + tenantId + ); + + expect(result).toEqual(existingType); + }); + + it('should throw ConflictError when updating to existing name', async () => { + const existingType = createMockLeaveType({ name: 'Original' }); + + mockQueryOne.mockResolvedValueOnce(existingType); // getById + mockQueryOne.mockResolvedValueOnce({ id: 'other-uuid' }); // name check + + await expect( + leavesService.updateLeaveType('leave-type-uuid-1', { name: 'Existing Name' }, tenantId) + ).rejects.toThrow(ConflictError); + }); + }); + + describe('deleteLeaveType', () => { + it('should delete leave type not in use', async () => { + const leaveType = createMockLeaveType(); + + mockQueryOne + .mockResolvedValueOnce(leaveType) // getById + .mockResolvedValueOnce({ count: '0' }); // inUse + mockQuery.mockResolvedValue([]); + + await leavesService.deleteLeaveType('leave-type-uuid-1', tenantId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM hr.leave_types'), + expect.any(Array) + ); + }); + + it('should throw NotFoundError when type not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + leavesService.deleteLeaveType('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should throw ConflictError when type has associated leaves', async () => { + const leaveType = createMockLeaveType(); + + mockQueryOne + .mockResolvedValueOnce(leaveType) + .mockResolvedValueOnce({ count: '5' }); + + await expect( + leavesService.deleteLeaveType('leave-type-uuid-1', tenantId) + ).rejects.toThrow(ConflictError); + }); + + it('should throw ConflictError with correct message', async () => { + const leaveType = createMockLeaveType(); + + mockQueryOne + .mockResolvedValueOnce(leaveType) + .mockResolvedValueOnce({ count: '5' }); + + await expect( + leavesService.deleteLeaveType('leave-type-uuid-1', tenantId) + ).rejects.toThrow('No se puede eliminar un tipo de ausencia que esta en uso'); + }); + }); + + // ========== LEAVES ========== + + describe('findAll (leaves)', () => { + it('should return leaves with pagination', async () => { + const mockLeaves = [ + createMockLeave({ id: '1', name: 'Leave 1' }), + createMockLeave({ id: '2', name: 'Leave 2' }), + ]; + + mockQueryOne.mockResolvedValue({ count: '2' }); + mockQuery.mockResolvedValue(mockLeaves); + + const result = await leavesService.findAll(tenantId, { page: 1, limit: 20 }); + + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it('should filter by company_id', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await leavesService.findAll(tenantId, { company_id: 'company-uuid' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('l.company_id = $'), + expect.arrayContaining([tenantId, 'company-uuid']) + ); + }); + + it('should filter by employee_id', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await leavesService.findAll(tenantId, { employee_id: 'employee-uuid' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('l.employee_id = $'), + expect.arrayContaining([tenantId, 'employee-uuid']) + ); + }); + + it('should filter by leave_type_id', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await leavesService.findAll(tenantId, { leave_type_id: 'type-uuid' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('l.leave_type_id = $'), + expect.arrayContaining([tenantId, 'type-uuid']) + ); + }); + + it('should filter by status', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await leavesService.findAll(tenantId, { status: 'approved' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('l.status = $'), + expect.arrayContaining([tenantId, 'approved']) + ); + }); + + it('should filter by date_from', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await leavesService.findAll(tenantId, { date_from: '2026-01-01' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('l.date_from >= $'), + expect.arrayContaining([tenantId, '2026-01-01']) + ); + }); + + it('should filter by date_to', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await leavesService.findAll(tenantId, { date_to: '2026-12-31' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('l.date_to <= $'), + expect.arrayContaining([tenantId, '2026-12-31']) + ); + }); + + it('should filter by search term', async () => { + mockQueryOne.mockResolvedValue({ count: '0' }); + mockQuery.mockResolvedValue([]); + + await leavesService.findAll(tenantId, { search: 'John' }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('l.name ILIKE'), + expect.arrayContaining([tenantId, '%John%']) + ); + }); + }); + + describe('findById (leave)', () => { + it('should return leave when found', async () => { + const mockLeave = createMockLeave(); + mockQueryOne.mockResolvedValue(mockLeave); + + const result = await leavesService.findById('leave-uuid-1', tenantId); + + expect(result).toEqual(mockLeave); + }); + + it('should throw NotFoundError when leave not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + leavesService.findById('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should throw NotFoundError with correct message', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + leavesService.findById('nonexistent-id', tenantId) + ).rejects.toThrow('Solicitud de ausencia no encontrada'); + }); + }); + + describe('create (leave)', () => { + const createDto = { + company_id: 'company-uuid', + employee_id: 'employee-uuid-1', + leave_type_id: 'leave-type-uuid-1', + date_from: '2026-02-01', + date_to: '2026-02-05', + }; + + it('should create leave successfully', async () => { + const leaveType = createMockLeaveType({ max_days: 30 }); + const newLeave = createMockLeave({ ...createDto, id: 'new-uuid' }); + + mockQueryOne.mockResolvedValueOnce(leaveType); // getLeaveTypeById + mockQueryOne.mockResolvedValueOnce({ count: '0' }); // overlap check + mockQueryOne.mockResolvedValueOnce({ id: 'new-uuid' }); // INSERT + mockQueryOne.mockResolvedValueOnce(newLeave); // findById + + const result = await leavesService.create(createDto, tenantId, userId); + + expect(result.employee_id).toBe('employee-uuid-1'); + }); + + it('should calculate days correctly for same date (1 day)', async () => { + const leaveType = createMockLeaveType({ max_days: 30 }); + const sameDayDto = { + ...createDto, + date_from: '2026-02-05', + date_to: '2026-02-05', + }; + const newLeave = createMockLeave({ ...sameDayDto, number_of_days: 1 }); + + mockQueryOne.mockResolvedValueOnce(leaveType); // getLeaveTypeById + mockQueryOne.mockResolvedValueOnce({ count: '0' }); // overlap check + mockQueryOne.mockResolvedValueOnce({ id: 'new-uuid' }); // INSERT + mockQueryOne.mockResolvedValueOnce(newLeave); // findById + + const result = await leavesService.create(sameDayDto, tenantId, userId); + + expect(result.number_of_days).toBe(1); + }); + + it('should throw ValidationError when exceeding max days', async () => { + const leaveType = createMockLeaveType({ max_days: 3 }); + const longDto = { + ...createDto, + date_from: '2026-02-01', + date_to: '2026-02-10', // 10 days > 3 max + }; + + mockQueryOne.mockResolvedValueOnce(leaveType); + + await expect( + leavesService.create(longDto, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with max days message', async () => { + const leaveType = createMockLeaveType({ max_days: 3 }); + const longDto = { + ...createDto, + date_from: '2026-02-01', + date_to: '2026-02-10', + }; + + mockQueryOne.mockResolvedValueOnce(leaveType); + + await expect( + leavesService.create(longDto, tenantId, userId) + ).rejects.toThrow('Este tipo de ausencia tiene un maximo de 3 dias'); + }); + + it('should throw ValidationError when overlapping leaves exist', async () => { + const leaveType = createMockLeaveType({ max_days: null }); + + mockQueryOne.mockResolvedValueOnce(leaveType); + mockQueryOne.mockResolvedValueOnce({ count: '1' }); // overlap exists + + await expect( + leavesService.create(createDto, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with overlap message', async () => { + const leaveType = createMockLeaveType({ max_days: null }); + + mockQueryOne.mockResolvedValueOnce(leaveType); + mockQueryOne.mockResolvedValueOnce({ count: '1' }); + + await expect( + leavesService.create(createDto, tenantId, userId) + ).rejects.toThrow('Ya existe una solicitud de ausencia para estas fechas'); + }); + }); + + describe('update (leave)', () => { + it('should update draft leave', async () => { + const existingLeave = createMockLeave({ status: 'draft' }); + const updatedLeave = createMockLeave({ status: 'draft', description: 'Updated' }); + + mockQueryOne.mockResolvedValueOnce(existingLeave); // findById + mockQuery.mockResolvedValue([]); // UPDATE + mockQueryOne.mockResolvedValueOnce(updatedLeave); // findById after update + + const result = await leavesService.update( + 'leave-uuid-1', + { description: 'Updated' }, + tenantId, + userId + ); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('UPDATE hr.leaves SET'), + expect.any(Array) + ); + }); + + it('should throw NotFoundError when leave not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + leavesService.update('nonexistent-id', { description: 'Test' }, tenantId, userId) + ).rejects.toThrow(NotFoundError); + }); + + it('should throw ValidationError when leave is not draft', async () => { + const submittedLeave = createMockLeave({ status: 'submitted' }); + mockQueryOne.mockResolvedValue(submittedLeave); + + await expect( + leavesService.update('leave-uuid-1', { description: 'Test' }, tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with correct message for non-draft', async () => { + const submittedLeave = createMockLeave({ status: 'submitted' }); + mockQueryOne.mockResolvedValue(submittedLeave); + + await expect( + leavesService.update('leave-uuid-1', { description: 'Test' }, tenantId, userId) + ).rejects.toThrow('Solo se pueden editar solicitudes en borrador'); + }); + + it('should return unchanged leave when no fields to update', async () => { + const existingLeave = createMockLeave({ status: 'draft' }); + mockQueryOne.mockResolvedValue(existingLeave); + + const result = await leavesService.update( + 'leave-uuid-1', + {}, + tenantId, + userId + ); + + expect(result).toEqual(existingLeave); + }); + + it('should recalculate days when dates change', async () => { + const existingLeave = createMockLeave({ status: 'draft' }); + const updatedLeave = createMockLeave({ + status: 'draft', + date_from: new Date('2026-03-01'), + date_to: new Date('2026-03-10'), + number_of_days: 10, + }); + + mockQueryOne.mockResolvedValueOnce(existingLeave); + mockQuery.mockResolvedValue([]); + mockQueryOne.mockResolvedValueOnce(updatedLeave); + + await leavesService.update( + 'leave-uuid-1', + { date_from: '2026-03-01', date_to: '2026-03-10' }, + tenantId, + userId + ); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('number_of_days = $'), + expect.any(Array) + ); + }); + }); + + describe('submit', () => { + it('should submit draft leave', async () => { + const draftLeave = createMockLeave({ status: 'draft' }); + const submittedLeave = createMockLeave({ status: 'submitted' }); + + mockQueryOne.mockResolvedValueOnce(draftLeave); // findById + mockQuery.mockResolvedValue([]); // UPDATE + mockQueryOne.mockResolvedValueOnce(submittedLeave); // findById after update + + const result = await leavesService.submit('leave-uuid-1', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'submitted'"), + expect.any(Array) + ); + }); + + it('should throw ValidationError when leave is not draft', async () => { + const submittedLeave = createMockLeave({ status: 'submitted' }); + mockQueryOne.mockResolvedValue(submittedLeave); + + await expect( + leavesService.submit('leave-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with correct message', async () => { + const submittedLeave = createMockLeave({ status: 'submitted' }); + mockQueryOne.mockResolvedValue(submittedLeave); + + await expect( + leavesService.submit('leave-uuid-1', tenantId, userId) + ).rejects.toThrow('Solo se pueden enviar solicitudes en borrador'); + }); + }); + + describe('approve', () => { + it('should approve submitted leave', async () => { + const submittedLeave = createMockLeave({ + status: 'submitted', + date_from: new Date('2099-01-01'), + date_to: new Date('2099-01-05'), + }); + const approvedLeave = createMockLeave({ + status: 'approved', + approved_by: userId, + }); + + mockQueryOne.mockResolvedValueOnce(submittedLeave); // findById + mockQuery.mockResolvedValueOnce([]); // UPDATE status + mockQueryOne.mockResolvedValueOnce(approvedLeave); // findById after update + + const result = await leavesService.approve('leave-uuid-1', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'approved'"), + expect.any(Array) + ); + }); + + it('should throw ValidationError when leave is not submitted', async () => { + const draftLeave = createMockLeave({ status: 'draft' }); + mockQueryOne.mockResolvedValue(draftLeave); + + await expect( + leavesService.approve('leave-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with correct message', async () => { + const draftLeave = createMockLeave({ status: 'draft' }); + mockQueryOne.mockResolvedValue(draftLeave); + + await expect( + leavesService.approve('leave-uuid-1', tenantId, userId) + ).rejects.toThrow('Solo se pueden aprobar solicitudes enviadas'); + }); + + it('should record approver information', async () => { + const submittedLeave = createMockLeave({ + status: 'submitted', + date_from: new Date('2099-01-01'), + date_to: new Date('2099-01-05'), + }); + + mockQueryOne.mockResolvedValueOnce(submittedLeave); + mockQuery.mockResolvedValue([]); + mockQueryOne.mockResolvedValueOnce(createMockLeave({ status: 'approved' })); + + await leavesService.approve('leave-uuid-1', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('approved_by = $'), + expect.arrayContaining([userId]) + ); + }); + }); + + describe('reject', () => { + it('should reject submitted leave with reason', async () => { + const submittedLeave = createMockLeave({ status: 'submitted' }); + const rejectedLeave = createMockLeave({ + status: 'rejected', + rejection_reason: 'Project deadline', + }); + + mockQueryOne.mockResolvedValueOnce(submittedLeave); // findById + mockQuery.mockResolvedValue([]); // UPDATE + mockQueryOne.mockResolvedValueOnce(rejectedLeave); // findById after update + + const result = await leavesService.reject( + 'leave-uuid-1', + 'Project deadline', + tenantId, + userId + ); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'rejected'"), + expect.any(Array) + ); + }); + + it('should throw ValidationError when leave is not submitted', async () => { + const draftLeave = createMockLeave({ status: 'draft' }); + mockQueryOne.mockResolvedValue(draftLeave); + + await expect( + leavesService.reject('leave-uuid-1', 'Reason', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with correct message', async () => { + const draftLeave = createMockLeave({ status: 'draft' }); + mockQueryOne.mockResolvedValue(draftLeave); + + await expect( + leavesService.reject('leave-uuid-1', 'Reason', tenantId, userId) + ).rejects.toThrow('Solo se pueden rechazar solicitudes enviadas'); + }); + + it('should store rejection reason', async () => { + const submittedLeave = createMockLeave({ status: 'submitted' }); + + mockQueryOne.mockResolvedValueOnce(submittedLeave); + mockQuery.mockResolvedValue([]); + mockQueryOne.mockResolvedValueOnce(createMockLeave({ status: 'rejected' })); + + await leavesService.reject('leave-uuid-1', 'Team needs coverage', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('rejection_reason = $'), + expect.arrayContaining(['Team needs coverage']) + ); + }); + }); + + describe('cancel', () => { + it('should cancel draft leave', async () => { + const draftLeave = createMockLeave({ status: 'draft' }); + const cancelledLeave = createMockLeave({ status: 'cancelled' }); + + mockQueryOne.mockResolvedValueOnce(draftLeave); // findById + mockQuery.mockResolvedValue([]); // UPDATE + mockQueryOne.mockResolvedValueOnce(cancelledLeave); // findById after update + + const result = await leavesService.cancel('leave-uuid-1', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'cancelled'"), + expect.any(Array) + ); + }); + + it('should cancel submitted leave', async () => { + const submittedLeave = createMockLeave({ status: 'submitted' }); + const cancelledLeave = createMockLeave({ status: 'cancelled' }); + + mockQueryOne.mockResolvedValueOnce(submittedLeave); + mockQuery.mockResolvedValue([]); + mockQueryOne.mockResolvedValueOnce(cancelledLeave); + + await leavesService.cancel('leave-uuid-1', tenantId, userId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'cancelled'"), + expect.any(Array) + ); + }); + + it('should cancel approved leave', async () => { + const approvedLeave = createMockLeave({ status: 'approved' }); + const cancelledLeave = createMockLeave({ status: 'cancelled' }); + + mockQueryOne.mockResolvedValueOnce(approvedLeave); + mockQuery.mockResolvedValue([]); + mockQueryOne.mockResolvedValueOnce(cancelledLeave); + + await leavesService.cancel('leave-uuid-1', tenantId, userId); + + expect(mockQuery).toHaveBeenCalled(); + }); + + it('should throw ValidationError when leave is already cancelled', async () => { + const cancelledLeave = createMockLeave({ status: 'cancelled' }); + mockQueryOne.mockResolvedValue(cancelledLeave); + + await expect( + leavesService.cancel('leave-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with correct message for already cancelled', async () => { + const cancelledLeave = createMockLeave({ status: 'cancelled' }); + mockQueryOne.mockResolvedValue(cancelledLeave); + + await expect( + leavesService.cancel('leave-uuid-1', tenantId, userId) + ).rejects.toThrow('La solicitud ya esta cancelada'); + }); + + it('should throw ValidationError when leave is rejected', async () => { + const rejectedLeave = createMockLeave({ status: 'rejected' }); + mockQueryOne.mockResolvedValue(rejectedLeave); + + await expect( + leavesService.cancel('leave-uuid-1', tenantId, userId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with correct message for rejected', async () => { + const rejectedLeave = createMockLeave({ status: 'rejected' }); + mockQueryOne.mockResolvedValue(rejectedLeave); + + await expect( + leavesService.cancel('leave-uuid-1', tenantId, userId) + ).rejects.toThrow('No se puede cancelar una solicitud rechazada'); + }); + }); + + describe('delete (leave)', () => { + it('should delete draft leave', async () => { + const draftLeave = createMockLeave({ status: 'draft' }); + + mockQueryOne.mockResolvedValueOnce(draftLeave); // findById + mockQuery.mockResolvedValue([]); // DELETE + + await leavesService.delete('leave-uuid-1', tenantId); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM hr.leaves'), + expect.any(Array) + ); + }); + + it('should throw NotFoundError when leave not found', async () => { + mockQueryOne.mockResolvedValue(null); + + await expect( + leavesService.delete('nonexistent-id', tenantId) + ).rejects.toThrow(NotFoundError); + }); + + it('should throw ValidationError when leave is not draft', async () => { + const submittedLeave = createMockLeave({ status: 'submitted' }); + mockQueryOne.mockResolvedValue(submittedLeave); + + await expect( + leavesService.delete('leave-uuid-1', tenantId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError with correct message', async () => { + const submittedLeave = createMockLeave({ status: 'submitted' }); + mockQueryOne.mockResolvedValue(submittedLeave); + + await expect( + leavesService.delete('leave-uuid-1', tenantId) + ).rejects.toThrow('Solo se pueden eliminar solicitudes en borrador'); + }); + + it('should throw ValidationError for approved leave', async () => { + const approvedLeave = createMockLeave({ status: 'approved' }); + mockQueryOne.mockResolvedValue(approvedLeave); + + await expect( + leavesService.delete('leave-uuid-1', tenantId) + ).rejects.toThrow(ValidationError); + }); + + it('should throw ValidationError for cancelled leave', async () => { + const cancelledLeave = createMockLeave({ status: 'cancelled' }); + mockQueryOne.mockResolvedValue(cancelledLeave); + + await expect( + leavesService.delete('leave-uuid-1', tenantId) + ).rejects.toThrow(ValidationError); + }); + }); +});