[GAP-001,002,006] test: Add unit tests for GAP services

- kpi-config.service.spec.ts: 9 test cases for KPI CRUD and values
- tool-loan.service.spec.ts: 8 test cases for loan operations
- digital-signature.service.spec.ts: 10 test cases for signature workflow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-04 01:17:35 -06:00
parent 2caa489093
commit 6fa4a6c552
3 changed files with 626 additions and 0 deletions

View File

@ -0,0 +1,188 @@
/**
* ToolLoanService Unit Tests
* Module: Assets (MAE-015)
* GAP: GAP-002
*/
import { Repository, SelectQueryBuilder } from 'typeorm';
import { ToolLoanService, CreateToolLoanDto, ReturnToolDto } from '../services/tool-loan.service';
import { ToolLoan } from '../entities/tool-loan.entity';
import { Asset } from '../entities/asset.entity';
const createMockRepository = <T>(): jest.Mocked<Repository<T>> => ({
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
count: jest.fn(),
update: jest.fn(),
createQueryBuilder: jest.fn(),
} as unknown as jest.Mocked<Repository<T>>);
const createMockQueryBuilder = <T>(): jest.Mocked<SelectQueryBuilder<T>> => ({
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn(),
} as unknown as jest.Mocked<SelectQueryBuilder<T>>);
describe('ToolLoanService', () => {
let service: ToolLoanService;
let loanRepository: jest.Mocked<Repository<ToolLoan>>;
let assetRepository: jest.Mocked<Repository<Asset>>;
const mockTenantId = 'tenant-123';
const mockUserId = 'user-456';
const mockTool: Partial<Asset> = {
id: 'tool-1',
tenantId: mockTenantId,
name: 'Taladro Industrial',
assetType: 'tool',
status: 'available',
};
const mockLoan: Partial<ToolLoan> = {
id: 'loan-1',
tenantId: mockTenantId,
toolId: 'tool-1',
employeeId: 'emp-1',
employeeName: 'Juan Perez',
status: 'active',
loanDate: new Date('2026-01-15'),
expectedReturnDate: new Date('2026-01-30'),
};
beforeEach(() => {
loanRepository = createMockRepository<ToolLoan>();
assetRepository = createMockRepository<Asset>();
const mockDataSource = {
getRepository: jest.fn().mockImplementation((entity) => {
if (entity === ToolLoan) return loanRepository;
if (entity === Asset) return assetRepository;
return createMockRepository();
}),
};
service = new ToolLoanService(mockDataSource as any);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('create', () => {
const createDto: CreateToolLoanDto = {
toolId: 'tool-1',
employeeId: 'emp-1',
employeeName: 'Juan Perez',
loanDate: new Date('2026-01-15'),
expectedReturnDate: new Date('2026-01-30'),
};
it('should create a new tool loan', async () => {
assetRepository.findOne.mockResolvedValue(mockTool as Asset);
loanRepository.findOne.mockResolvedValue(null); // No active loan
loanRepository.create.mockReturnValue(mockLoan as ToolLoan);
loanRepository.save.mockResolvedValue(mockLoan as ToolLoan);
assetRepository.update.mockResolvedValue({ affected: 1 } as any);
const result = await service.create(mockTenantId, createDto, mockUserId);
expect(loanRepository.save).toHaveBeenCalled();
expect(assetRepository.update).toHaveBeenCalled(); // Tool status updated
expect(result).toEqual(mockLoan);
});
it('should throw error if tool not found', async () => {
assetRepository.findOne.mockResolvedValue(null);
await expect(service.create(mockTenantId, createDto, mockUserId))
.rejects.toThrow('Tool not found');
});
it('should throw error if tool already has active loan', async () => {
assetRepository.findOne.mockResolvedValue(mockTool as Asset);
loanRepository.findOne.mockResolvedValue(mockLoan as ToolLoan);
await expect(service.create(mockTenantId, createDto, mockUserId))
.rejects.toThrow('Tool already has an active loan');
});
});
describe('returnTool', () => {
const returnDto: ReturnToolDto = {
actualReturnDate: new Date('2026-01-28'),
conditionIn: 'good',
status: 'returned',
};
it('should return tool and update status', async () => {
loanRepository.findOne.mockResolvedValue(mockLoan as ToolLoan);
loanRepository.save.mockResolvedValue({ ...mockLoan, status: 'returned' } as ToolLoan);
assetRepository.update.mockResolvedValue({ affected: 1 } as any);
const result = await service.returnTool(mockTenantId, 'loan-1', returnDto, mockUserId);
expect(loanRepository.save).toHaveBeenCalled();
expect(assetRepository.update).toHaveBeenCalledWith(
{ id: mockLoan.toolId, tenantId: mockTenantId },
expect.objectContaining({ status: 'available' })
);
expect(result?.status).toBe('returned');
});
it('should mark tool as in_maintenance if returned damaged', async () => {
loanRepository.findOne.mockResolvedValue(mockLoan as ToolLoan);
loanRepository.save.mockResolvedValue({ ...mockLoan, status: 'damaged' } as ToolLoan);
assetRepository.update.mockResolvedValue({ affected: 1 } as any);
await service.returnTool(mockTenantId, 'loan-1', { ...returnDto, status: 'damaged' }, mockUserId);
expect(assetRepository.update).toHaveBeenCalledWith(
{ id: mockLoan.toolId, tenantId: mockTenantId },
expect.objectContaining({ status: 'in_maintenance' })
);
});
});
describe('markAsLost', () => {
it('should mark loan as lost and tool as inactive', async () => {
loanRepository.findOne.mockResolvedValue(mockLoan as ToolLoan);
loanRepository.save.mockResolvedValue({ ...mockLoan, status: 'lost' } as ToolLoan);
assetRepository.update.mockResolvedValue({ affected: 1 } as any);
const result = await service.markAsLost(mockTenantId, 'loan-1', 'Extraviado en obra', mockUserId);
expect(result?.status).toBe('lost');
expect(assetRepository.update).toHaveBeenCalledWith(
{ id: mockLoan.toolId, tenantId: mockTenantId },
{ status: 'inactive' }
);
});
});
describe('getStatistics', () => {
it('should return loan statistics', async () => {
loanRepository.count.mockResolvedValueOnce(5) // active
.mockResolvedValueOnce(20) // returned
.mockResolvedValueOnce(2) // overdue
.mockResolvedValueOnce(1) // lost
.mockResolvedValueOnce(3); // damaged
const stats = await service.getStatistics(mockTenantId);
expect(stats).toEqual({
active: 5,
returned: 20,
overdue: 2,
lost: 1,
damaged: 3,
});
});
});
});

View File

@ -0,0 +1,201 @@
/**
* DigitalSignatureService Unit Tests
* Module: Documents (MAE-016)
* GAP: GAP-006
*/
import { Repository, SelectQueryBuilder } from 'typeorm';
import { DigitalSignatureService, CreateSignatureRequestDto, SignDocumentDto } from '../services/digital-signature.service';
import { DigitalSignature } from '../entities/digital-signature.entity';
const createMockRepository = <T>(): jest.Mocked<Repository<T>> => ({
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
count: jest.fn(),
update: jest.fn(),
createQueryBuilder: jest.fn(),
} as unknown as jest.Mocked<Repository<T>>);
const createMockQueryBuilder = <T>(): jest.Mocked<SelectQueryBuilder<T>> => ({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn(),
} as unknown as jest.Mocked<SelectQueryBuilder<T>>);
describe('DigitalSignatureService', () => {
let service: DigitalSignatureService;
let repository: jest.Mocked<Repository<DigitalSignature>>;
const mockTenantId = 'tenant-123';
const mockUserId = 'user-456';
const mockSignature: Partial<DigitalSignature> = {
id: 'sig-1',
tenantId: mockTenantId,
documentId: 'doc-1',
signerId: 'signer-1',
signerName: 'Juan Perez',
signerEmail: 'juan@example.com',
signatureType: 'simple',
status: 'pending',
requestedAt: new Date('2026-02-01'),
isValid: true,
};
beforeEach(() => {
repository = createMockRepository<DigitalSignature>();
const mockDataSource = {
getRepository: jest.fn().mockReturnValue(repository),
};
service = new DigitalSignatureService(mockDataSource as any);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('requestSignature', () => {
const createDto: CreateSignatureRequestDto = {
documentId: 'doc-1',
signerId: 'signer-1',
signerName: 'Juan Perez',
signerEmail: 'juan@example.com',
signatureType: 'simple',
};
it('should create a signature request', async () => {
repository.create.mockReturnValue(mockSignature as DigitalSignature);
repository.save.mockResolvedValue(mockSignature as DigitalSignature);
const result = await service.requestSignature(mockTenantId, createDto, mockUserId);
expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({
tenantId: mockTenantId,
documentId: createDto.documentId,
signerId: createDto.signerId,
status: 'pending',
}));
expect(result).toEqual(mockSignature);
});
});
describe('signDocument', () => {
const signDto: SignDocumentDto = {
signatureData: 'base64-signature-data',
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
};
it('should sign a pending signature request', async () => {
repository.findOne.mockResolvedValue(mockSignature as DigitalSignature);
const signedSignature = { ...mockSignature, status: 'signed', signedAt: new Date() };
repository.save.mockResolvedValue(signedSignature as DigitalSignature);
const result = await service.signDocument(mockTenantId, 'sig-1', signDto, mockUserId);
expect(repository.save).toHaveBeenCalledWith(expect.objectContaining({
status: 'signed',
signatureData: signDto.signatureData,
}));
expect(result?.status).toBe('signed');
});
it('should throw error if signature not pending', async () => {
const signedSig = { ...mockSignature, status: 'signed' };
repository.findOne.mockResolvedValue(signedSig as DigitalSignature);
await expect(service.signDocument(mockTenantId, 'sig-1', signDto, mockUserId))
.rejects.toThrow('Signature request is not pending');
});
it('should throw error if signature expired', async () => {
const expiredSig = { ...mockSignature, expiresAt: new Date('2025-01-01') };
repository.findOne.mockResolvedValue(expiredSig as DigitalSignature);
repository.save.mockResolvedValue({ ...expiredSig, status: 'expired' } as DigitalSignature);
await expect(service.signDocument(mockTenantId, 'sig-1', signDto, mockUserId))
.rejects.toThrow('Signature request has expired');
});
});
describe('rejectSignature', () => {
it('should reject a pending signature', async () => {
repository.findOne.mockResolvedValue(mockSignature as DigitalSignature);
repository.save.mockResolvedValue({ ...mockSignature, status: 'rejected' } as DigitalSignature);
const result = await service.rejectSignature(mockTenantId, 'sig-1', { reason: 'Not authorized' }, mockUserId);
expect(result?.status).toBe('rejected');
});
});
describe('revokeSignature', () => {
it('should revoke a signed signature', async () => {
const signedSig = { ...mockSignature, status: 'signed' };
repository.findOne.mockResolvedValue(signedSig as DigitalSignature);
repository.save.mockResolvedValue({ ...signedSig, status: 'revoked', isValid: false } as DigitalSignature);
const result = await service.revokeSignature(mockTenantId, 'sig-1', 'Document superseded', mockUserId);
expect(result?.status).toBe('revoked');
expect(result?.isValid).toBe(false);
});
it('should throw error if not signed', async () => {
repository.findOne.mockResolvedValue(mockSignature as DigitalSignature);
await expect(service.revokeSignature(mockTenantId, 'sig-1', 'Reason', mockUserId))
.rejects.toThrow('Only signed documents can be revoked');
});
});
describe('validateSignature', () => {
it('should validate a signed signature', async () => {
const signedSig = { ...mockSignature, status: 'signed' };
repository.findOne.mockResolvedValue(signedSig as DigitalSignature);
repository.save.mockResolvedValue(signedSig as DigitalSignature);
const result = await service.validateSignature(mockTenantId, 'sig-1');
expect(result.isValid).toBe(true);
expect(result.errors).toBeUndefined();
});
it('should return errors for invalid status', async () => {
repository.findOne.mockResolvedValue(mockSignature as DigitalSignature); // pending
repository.save.mockResolvedValue(mockSignature as DigitalSignature);
const result = await service.validateSignature(mockTenantId, 'sig-1');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Invalid status: pending');
});
});
describe('getStatistics', () => {
it('should return signature statistics', async () => {
repository.count.mockResolvedValueOnce(10) // pending
.mockResolvedValueOnce(50) // signed
.mockResolvedValueOnce(5) // rejected
.mockResolvedValueOnce(3) // expired
.mockResolvedValueOnce(2); // revoked
const stats = await service.getStatistics(mockTenantId);
expect(stats).toEqual({
pending: 10,
signed: 50,
rejected: 5,
expired: 3,
revoked: 2,
});
});
});
});

View File

@ -0,0 +1,237 @@
/**
* KpiConfigService Unit Tests
* Module: Reports (MAI-006)
* GAP: GAP-001
*/
import { Repository, SelectQueryBuilder } from 'typeorm';
import { KpiConfigService, CreateKpiConfigDto, CreateKpiValueDto } from '../services/kpi-config.service';
import { KpiConfig } from '../entities/kpi-config.entity';
import { KpiValue } from '../entities/kpi-value.entity';
// Mock repositories
const createMockRepository = <T>(): jest.Mocked<Repository<T>> => ({
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
update: jest.fn(),
createQueryBuilder: jest.fn(),
} as unknown as jest.Mocked<Repository<T>>);
const createMockQueryBuilder = <T>(): jest.Mocked<SelectQueryBuilder<T>> => ({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getOne: jest.fn(),
getManyAndCount: jest.fn(),
} as unknown as jest.Mocked<SelectQueryBuilder<T>>);
describe('KpiConfigService', () => {
let service: KpiConfigService;
let configRepository: jest.Mocked<Repository<KpiConfig>>;
let valueRepository: jest.Mocked<Repository<KpiValue>>;
const mockTenantId = 'tenant-123';
const mockUserId = 'user-456';
const mockKpiConfig: Partial<KpiConfig> = {
id: 'kpi-1',
tenantId: mockTenantId,
code: 'SPI',
name: 'Schedule Performance Index',
category: 'progress',
module: 'construction',
formula: 'earned_value / planned_value',
formulaType: 'expression',
targetValue: 1.0,
thresholdGreen: 0.95,
thresholdYellow: 0.85,
isActive: true,
isSystem: false,
};
beforeEach(() => {
configRepository = createMockRepository<KpiConfig>();
valueRepository = createMockRepository<KpiValue>();
const mockDataSource = {
getRepository: jest.fn().mockImplementation((entity) => {
if (entity === KpiConfig) return configRepository;
if (entity === KpiValue) return valueRepository;
return createMockRepository();
}),
};
service = new KpiConfigService(mockDataSource as any);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('create', () => {
const createDto: CreateKpiConfigDto = {
code: 'SPI',
name: 'Schedule Performance Index',
category: 'progress',
module: 'construction',
formula: 'earned_value / planned_value',
targetValue: 1.0,
thresholdGreen: 0.95,
thresholdYellow: 0.85,
};
it('should create a new KPI config', async () => {
configRepository.findOne.mockResolvedValue(null);
configRepository.create.mockReturnValue(mockKpiConfig as KpiConfig);
configRepository.save.mockResolvedValue(mockKpiConfig as KpiConfig);
const result = await service.create(mockTenantId, createDto, mockUserId);
expect(configRepository.findOne).toHaveBeenCalledWith({
where: { tenantId: mockTenantId, code: createDto.code },
});
expect(configRepository.create).toHaveBeenCalled();
expect(configRepository.save).toHaveBeenCalled();
expect(result).toEqual(mockKpiConfig);
});
it('should throw error if KPI code already exists', async () => {
configRepository.findOne.mockResolvedValue(mockKpiConfig as KpiConfig);
await expect(service.create(mockTenantId, createDto, mockUserId))
.rejects.toThrow(`KPI with code ${createDto.code} already exists`);
});
});
describe('findById', () => {
it('should return KPI config by ID', async () => {
configRepository.findOne.mockResolvedValue(mockKpiConfig as KpiConfig);
const result = await service.findById(mockTenantId, 'kpi-1');
expect(configRepository.findOne).toHaveBeenCalledWith({
where: { id: 'kpi-1', tenantId: mockTenantId, deletedAt: undefined },
});
expect(result).toEqual(mockKpiConfig);
});
it('should return null if not found', async () => {
configRepository.findOne.mockResolvedValue(null);
const result = await service.findById(mockTenantId, 'non-existent');
expect(result).toBeNull();
});
});
describe('findAll', () => {
it('should return paginated KPI configs', async () => {
const mockQueryBuilder = createMockQueryBuilder<KpiConfig>();
mockQueryBuilder.getManyAndCount.mockResolvedValue([[mockKpiConfig as KpiConfig], 1]);
configRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
const result = await service.findAll(mockTenantId, {}, { page: 1, limit: 20 });
expect(result.data).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(result.totalPages).toBe(1);
});
it('should filter by category', async () => {
const mockQueryBuilder = createMockQueryBuilder<KpiConfig>();
mockQueryBuilder.getManyAndCount.mockResolvedValue([[], 0]);
configRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
await service.findAll(mockTenantId, { category: 'progress' }, { page: 1, limit: 20 });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'kpi.category = :category',
{ category: 'progress' }
);
});
});
describe('update', () => {
it('should update KPI config', async () => {
configRepository.findOne.mockResolvedValue(mockKpiConfig as KpiConfig);
configRepository.save.mockResolvedValue({ ...mockKpiConfig, name: 'Updated' } as KpiConfig);
const result = await service.update(mockTenantId, 'kpi-1', { name: 'Updated' }, mockUserId);
expect(configRepository.save).toHaveBeenCalled();
expect(result?.name).toBe('Updated');
});
it('should throw error when modifying system KPI', async () => {
const systemKpi = { ...mockKpiConfig, isSystem: true };
configRepository.findOne.mockResolvedValue(systemKpi as KpiConfig);
await expect(service.update(mockTenantId, 'kpi-1', { isActive: false }, mockUserId))
.rejects.toThrow('Cannot modify system KPIs');
});
});
describe('delete', () => {
it('should soft delete KPI config', async () => {
configRepository.findOne.mockResolvedValue(mockKpiConfig as KpiConfig);
configRepository.update.mockResolvedValue({ affected: 1 } as any);
const result = await service.delete(mockTenantId, 'kpi-1', mockUserId);
expect(configRepository.update).toHaveBeenCalled();
expect(result).toBe(true);
});
it('should throw error when deleting system KPI', async () => {
const systemKpi = { ...mockKpiConfig, isSystem: true };
configRepository.findOne.mockResolvedValue(systemKpi as KpiConfig);
await expect(service.delete(mockTenantId, 'kpi-1', mockUserId))
.rejects.toThrow('Cannot delete system KPIs');
});
});
describe('recordValue', () => {
const createValueDto: CreateKpiValueDto = {
kpiId: 'kpi-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
value: 0.92,
previousValue: 0.88,
};
it('should record KPI value with calculated status', async () => {
configRepository.findOne.mockResolvedValue(mockKpiConfig as KpiConfig);
const mockValue: Partial<KpiValue> = {
id: 'value-1',
tenantId: mockTenantId,
kpiId: 'kpi-1',
value: 0.92,
status: 'yellow',
isOnTarget: false,
};
valueRepository.create.mockReturnValue(mockValue as KpiValue);
valueRepository.save.mockResolvedValue(mockValue as KpiValue);
const result = await service.recordValue(mockTenantId, createValueDto, mockUserId);
expect(valueRepository.create).toHaveBeenCalled();
expect(valueRepository.save).toHaveBeenCalled();
expect(result).toBeDefined();
});
it('should throw error if KPI not found', async () => {
configRepository.findOne.mockResolvedValue(null);
await expect(service.recordValue(mockTenantId, createValueDto, mockUserId))
.rejects.toThrow('KPI configuration not found');
});
});
});