diff --git a/src/modules/assets/__tests__/tool-loan.service.spec.ts b/src/modules/assets/__tests__/tool-loan.service.spec.ts new file mode 100644 index 0000000..3519a9b --- /dev/null +++ b/src/modules/assets/__tests__/tool-loan.service.spec.ts @@ -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 = (): jest.Mocked> => ({ + 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>); + +const createMockQueryBuilder = (): jest.Mocked> => ({ + 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>); + +describe('ToolLoanService', () => { + let service: ToolLoanService; + let loanRepository: jest.Mocked>; + let assetRepository: jest.Mocked>; + + const mockTenantId = 'tenant-123'; + const mockUserId = 'user-456'; + + const mockTool: Partial = { + id: 'tool-1', + tenantId: mockTenantId, + name: 'Taladro Industrial', + assetType: 'tool', + status: 'available', + }; + + const mockLoan: Partial = { + 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(); + assetRepository = createMockRepository(); + + 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, + }); + }); + }); +}); diff --git a/src/modules/documents/__tests__/digital-signature.service.spec.ts b/src/modules/documents/__tests__/digital-signature.service.spec.ts new file mode 100644 index 0000000..cc08d74 --- /dev/null +++ b/src/modules/documents/__tests__/digital-signature.service.spec.ts @@ -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 = (): jest.Mocked> => ({ + 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>); + +const createMockQueryBuilder = (): jest.Mocked> => ({ + 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>); + +describe('DigitalSignatureService', () => { + let service: DigitalSignatureService; + let repository: jest.Mocked>; + + const mockTenantId = 'tenant-123'; + const mockUserId = 'user-456'; + + const mockSignature: Partial = { + 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(); + + 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, + }); + }); + }); +}); diff --git a/src/modules/reports/__tests__/kpi-config.service.spec.ts b/src/modules/reports/__tests__/kpi-config.service.spec.ts new file mode 100644 index 0000000..af5d9d3 --- /dev/null +++ b/src/modules/reports/__tests__/kpi-config.service.spec.ts @@ -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 = (): jest.Mocked> => ({ + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), + createQueryBuilder: jest.fn(), +} as unknown as jest.Mocked>); + +const createMockQueryBuilder = (): jest.Mocked> => ({ + 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>); + +describe('KpiConfigService', () => { + let service: KpiConfigService; + let configRepository: jest.Mocked>; + let valueRepository: jest.Mocked>; + + const mockTenantId = 'tenant-123'; + const mockUserId = 'user-456'; + + const mockKpiConfig: Partial = { + 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(); + valueRepository = createMockRepository(); + + 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(); + 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(); + 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 = { + 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'); + }); + }); +});