test(hr): Add unit tests for departments, contracts and leaves services

- departments.service.test.ts: 43 test cases covering CRUD, job positions, and validation errors
- contracts.service.test.ts: 40 test cases covering lifecycle (draft->active->terminated), cancellation, and validation
- leaves.service.test.ts: 66 test cases covering leave types and leave requests with status transitions

Coverage results: departments 100%, contracts 100%, leaves 96.1%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-26 16:23:15 -06:00
parent cf3d560cdc
commit b937d39464
3 changed files with 2125 additions and 0 deletions

View File

@ -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<string, any> = {}) {
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');
});
});
});

View File

@ -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<string, any> = {}) {
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<string, any> = {}) {
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');
});
});
});

View File

@ -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<string, any> = {}) {
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<string, any> = {}) {
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);
});
});
});