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:
parent
cf3d560cdc
commit
b937d39464
573
src/modules/hr/__tests__/contracts.service.test.ts
Normal file
573
src/modules/hr/__tests__/contracts.service.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
626
src/modules/hr/__tests__/departments.service.test.ts
Normal file
626
src/modules/hr/__tests__/departments.service.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
926
src/modules/hr/__tests__/leaves.service.test.ts
Normal file
926
src/modules/hr/__tests__/leaves.service.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user