diff --git a/src/modules/quality/__tests__/inspection.service.spec.ts b/src/modules/quality/__tests__/inspection.service.spec.ts new file mode 100644 index 0000000..10af9c0 --- /dev/null +++ b/src/modules/quality/__tests__/inspection.service.spec.ts @@ -0,0 +1,450 @@ +/** + * InspectionService Unit Tests + * Module: Quality + * Project: erp-construccion + */ + +import { Repository } from 'typeorm'; +import { InspectionService, CreateInspectionDto, RecordResultDto } from '../services/inspection.service'; +import { Inspection, InspectionStatus } from '../entities/inspection.entity'; +import { InspectionResult } from '../entities/inspection-result.entity'; +import { NonConformity } from '../entities/non-conformity.entity'; +import { Checklist } from '../entities/checklist.entity'; + +// Mock repositories +const createMockRepository = (): jest.Mocked> => ({ + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), +} as unknown as jest.Mocked>); + +describe('InspectionService', () => { + let service: InspectionService; + let inspectionRepository: jest.Mocked>; + let resultRepository: jest.Mocked>; + let nonConformityRepository: jest.Mocked>; + let checklistRepository: jest.Mocked>; + + const mockTenantId = 'tenant-123'; + const mockUserId = 'user-456'; + const mockCtx = { tenantId: mockTenantId, userId: mockUserId }; + + const mockChecklist: Partial = { + id: 'checklist-1', + tenantId: mockTenantId, + name: 'Quality Checklist', + isActive: true, + deletedAt: null, + items: [ + { id: 'item-1', isActive: true } as any, + { id: 'item-2', isActive: true } as any, + ], + }; + + const mockInspection: Partial = { + id: 'inspection-1', + tenantId: mockTenantId, + checklistId: 'checklist-1', + loteId: 'lote-1', + inspectionNumber: 'INS-2601-ABC123', + inspectionDate: new Date('2026-02-03'), + inspectorId: 'inspector-1', + status: 'pending' as InspectionStatus, + totalItems: 2, + }; + + beforeEach(() => { + inspectionRepository = createMockRepository(); + resultRepository = createMockRepository(); + nonConformityRepository = createMockRepository(); + checklistRepository = createMockRepository(); + + service = new InspectionService( + inspectionRepository, + resultRepository, + nonConformityRepository, + checklistRepository + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + const createDto: CreateInspectionDto = { + checklistId: 'checklist-1', + loteId: 'lote-1', + inspectionDate: new Date('2026-02-03'), + inspectorId: 'inspector-1', + notes: 'Test inspection', + }; + + it('should create inspection successfully', async () => { + checklistRepository.findOne.mockResolvedValue(mockChecklist as Checklist); + inspectionRepository.create.mockReturnValue(mockInspection as Inspection); + inspectionRepository.save.mockResolvedValue(mockInspection as Inspection); + + const result = await service.create(mockCtx, createDto); + + expect(checklistRepository.findOne).toHaveBeenCalledWith({ + where: expect.objectContaining({ + id: createDto.checklistId, + tenantId: mockTenantId, + isActive: true, + deletedAt: null, + }), + relations: ['items'], + }); + expect(inspectionRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId: mockTenantId, + checklistId: createDto.checklistId, + loteId: createDto.loteId, + status: 'pending', + }) + ); + expect(result).toEqual(mockInspection); + }); + + it('should throw error when checklist not found', async () => { + checklistRepository.findOne.mockResolvedValue(null); + + await expect(service.create(mockCtx, createDto)).rejects.toThrow( + 'Checklist not found or inactive' + ); + }); + + it('should calculate total items from active checklist items', async () => { + checklistRepository.findOne.mockResolvedValue(mockChecklist as Checklist); + inspectionRepository.create.mockReturnValue(mockInspection as Inspection); + inspectionRepository.save.mockResolvedValue(mockInspection as Inspection); + + await service.create(mockCtx, createDto); + + expect(inspectionRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + totalItems: 2, + }) + ); + }); + }); + + describe('findById', () => { + it('should return inspection when found', async () => { + inspectionRepository.findOne.mockResolvedValue(mockInspection as Inspection); + + const result = await service.findById(mockCtx, 'inspection-1'); + + expect(result).toEqual(mockInspection); + expect(inspectionRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'inspection-1', tenantId: mockTenantId }, + }); + }); + + it('should return null when inspection not found', async () => { + inspectionRepository.findOne.mockResolvedValue(null); + + const result = await service.findById(mockCtx, 'non-existent'); + + expect(result).toBeNull(); + }); + }); + + describe('startInspection', () => { + it('should start pending inspection', async () => { + const pendingInspection = { ...mockInspection, status: 'pending' as InspectionStatus }; + const startedInspection = { ...pendingInspection, status: 'in_progress' as InspectionStatus }; + + inspectionRepository.findOne.mockResolvedValue(pendingInspection as Inspection); + inspectionRepository.save.mockResolvedValue(startedInspection as Inspection); + + const result = await service.startInspection(mockCtx, 'inspection-1'); + + expect(result?.status).toBe('in_progress'); + expect(inspectionRepository.save).toHaveBeenCalled(); + }); + + it('should throw error when inspection is not pending', async () => { + const inProgressInspection = { ...mockInspection, status: 'in_progress' as InspectionStatus }; + inspectionRepository.findOne.mockResolvedValue(inProgressInspection as Inspection); + + await expect(service.startInspection(mockCtx, 'inspection-1')).rejects.toThrow( + 'Can only start pending inspections' + ); + }); + + it('should return null when inspection not found', async () => { + inspectionRepository.findOne.mockResolvedValue(null); + + const result = await service.startInspection(mockCtx, 'non-existent'); + + expect(result).toBeNull(); + }); + }); + + describe('recordResult', () => { + const resultDto: RecordResultDto = { + checklistItemId: 'item-1', + result: 'passed', + observations: 'Looks good', + }; + + it('should record passed result', async () => { + const inProgressInspection = { ...mockInspection, status: 'in_progress' as InspectionStatus }; + const mockResult: Partial = { + id: 'result-1', + tenantId: mockTenantId, + inspectionId: 'inspection-1', + checklistItemId: 'item-1', + result: 'passed', + }; + + inspectionRepository.findOne.mockResolvedValue(inProgressInspection as Inspection); + resultRepository.findOne.mockResolvedValue(null); + resultRepository.create.mockReturnValue(mockResult as InspectionResult); + resultRepository.save.mockResolvedValue(mockResult as InspectionResult); + + const result = await service.recordResult(mockCtx, 'inspection-1', resultDto); + + expect(result.result).toBe('passed'); + expect(nonConformityRepository.create).not.toHaveBeenCalled(); + }); + + it('should create non-conformity when result is failed', async () => { + const failedDto: RecordResultDto = { + checklistItemId: 'item-1', + result: 'failed', + observations: 'Defect found', + }; + const inProgressInspection = { ...mockInspection, status: 'in_progress' as InspectionStatus }; + const mockResult: Partial = { + id: 'result-1', + result: 'failed', + }; + const mockNC: Partial = { + id: 'nc-1', + ncNumber: 'NC-2601-ABCD', + status: 'open', + }; + + inspectionRepository.findOne.mockResolvedValue(inProgressInspection as Inspection); + resultRepository.findOne.mockResolvedValue(null); + resultRepository.create.mockReturnValue(mockResult as InspectionResult); + resultRepository.save.mockResolvedValue(mockResult as InspectionResult); + nonConformityRepository.create.mockReturnValue(mockNC as NonConformity); + nonConformityRepository.save.mockResolvedValue(mockNC as NonConformity); + + const result = await service.recordResult(mockCtx, 'inspection-1', failedDto); + + expect(result.result).toBe('failed'); + expect(nonConformityRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId: mockTenantId, + inspectionId: 'inspection-1', + severity: 'minor', + status: 'open', + }) + ); + }); + + it('should update existing result instead of creating new', async () => { + const existingResult: Partial = { + id: 'result-1', + tenantId: mockTenantId, + inspectionId: 'inspection-1', + checklistItemId: 'item-1', + result: 'failed', + }; + const inProgressInspection = { ...mockInspection, status: 'in_progress' as InspectionStatus }; + + inspectionRepository.findOne.mockResolvedValue(inProgressInspection as Inspection); + resultRepository.findOne.mockResolvedValue(existingResult as InspectionResult); + resultRepository.save.mockResolvedValue({ ...existingResult, result: 'passed' } as InspectionResult); + + await service.recordResult(mockCtx, 'inspection-1', resultDto); + + expect(resultRepository.create).not.toHaveBeenCalled(); + expect(resultRepository.save).toHaveBeenCalled(); + }); + + it('should throw error when inspection not found', async () => { + inspectionRepository.findOne.mockResolvedValue(null); + + await expect(service.recordResult(mockCtx, 'non-existent', resultDto)).rejects.toThrow( + 'Inspection not found' + ); + }); + + it('should throw error when inspection is not in progress', async () => { + const pendingInspection = { ...mockInspection, status: 'pending' as InspectionStatus }; + inspectionRepository.findOne.mockResolvedValue(pendingInspection as Inspection); + + await expect(service.recordResult(mockCtx, 'inspection-1', resultDto)).rejects.toThrow( + 'Can only record results for in-progress inspections' + ); + }); + }); + + describe('completeInspection', () => { + it('should complete inspection and calculate pass rate', async () => { + const inProgressInspection = { + ...mockInspection, + status: 'in_progress' as InspectionStatus, + results: [ + { result: 'passed' }, + { result: 'passed' }, + { result: 'failed' }, + { result: 'not_applicable' }, + ], + }; + const completedInspection = { + ...inProgressInspection, + status: 'completed' as InspectionStatus, + passedItems: 2, + failedItems: 1, + passRate: 66.67, + }; + + inspectionRepository.findOne.mockResolvedValue(inProgressInspection as Inspection); + inspectionRepository.save.mockResolvedValue(completedInspection as unknown as Inspection); + + const result = await service.completeInspection(mockCtx, 'inspection-1'); + + expect(result?.status).toBe('completed'); + expect(result?.passedItems).toBe(2); + expect(result?.failedItems).toBe(1); + expect(inspectionRepository.save).toHaveBeenCalled(); + }); + + it('should throw error when inspection is not in progress', async () => { + const pendingInspection = { ...mockInspection, status: 'pending' as InspectionStatus }; + inspectionRepository.findOne.mockResolvedValue(pendingInspection as Inspection); + + await expect(service.completeInspection(mockCtx, 'inspection-1')).rejects.toThrow( + 'Can only complete in-progress inspections' + ); + }); + }); + + describe('approveInspection', () => { + it('should approve completed inspection without critical NCs', async () => { + const completedInspection = { + ...mockInspection, + status: 'completed' as InspectionStatus, + nonConformities: [ + { severity: 'minor', status: 'verified' }, + ], + }; + const approvedInspection = { + ...completedInspection, + status: 'approved' as InspectionStatus, + approvedById: mockUserId, + }; + + inspectionRepository.findOne.mockResolvedValue(completedInspection as unknown as Inspection); + inspectionRepository.save.mockResolvedValue(approvedInspection as unknown as Inspection); + + const result = await service.approveInspection(mockCtx, 'inspection-1'); + + expect(result?.status).toBe('approved'); + expect(result?.approvedById).toBe(mockUserId); + }); + + it('should throw error when critical NCs are open', async () => { + const completedInspection = { + ...mockInspection, + status: 'completed' as InspectionStatus, + nonConformities: [ + { severity: 'critical', status: 'open' }, + ], + }; + + inspectionRepository.findOne.mockResolvedValue(completedInspection as unknown as Inspection); + + await expect(service.approveInspection(mockCtx, 'inspection-1')).rejects.toThrow( + 'Cannot approve inspection with open critical non-conformities' + ); + }); + + it('should throw error when inspection is not completed', async () => { + const inProgressInspection = { ...mockInspection, status: 'in_progress' as InspectionStatus }; + inspectionRepository.findOne.mockResolvedValue(inProgressInspection as Inspection); + + await expect(service.approveInspection(mockCtx, 'inspection-1')).rejects.toThrow( + 'Can only approve completed inspections' + ); + }); + }); + + describe('rejectInspection', () => { + it('should reject completed inspection with reason', async () => { + const completedInspection = { + ...mockInspection, + status: 'completed' as InspectionStatus, + }; + const rejectedInspection = { + ...completedInspection, + status: 'rejected' as InspectionStatus, + rejectionReason: 'Does not meet standards', + }; + + inspectionRepository.findOne.mockResolvedValue(completedInspection as Inspection); + inspectionRepository.save.mockResolvedValue(rejectedInspection as unknown as Inspection); + + const result = await service.rejectInspection(mockCtx, 'inspection-1', 'Does not meet standards'); + + expect(result?.status).toBe('rejected'); + expect(result?.rejectionReason).toBe('Does not meet standards'); + }); + + it('should throw error when inspection is not completed', async () => { + const inProgressInspection = { ...mockInspection, status: 'in_progress' as InspectionStatus }; + inspectionRepository.findOne.mockResolvedValue(inProgressInspection as Inspection); + + await expect(service.rejectInspection(mockCtx, 'inspection-1', 'reason')).rejects.toThrow( + 'Can only reject completed inspections' + ); + }); + + it('should return null when inspection not found', async () => { + inspectionRepository.findOne.mockResolvedValue(null); + + const result = await service.rejectInspection(mockCtx, 'non-existent', 'reason'); + + expect(result).toBeNull(); + }); + }); + + describe('findWithFilters', () => { + it('should return paginated results with filters', async () => { + const mockQueryBuilder = { + 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().mockResolvedValue([[mockInspection], 1]), + }; + + inspectionRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findWithFilters( + mockCtx, + { status: 'pending' as InspectionStatus }, + 1, + 20 + ); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('insp.status = :status', { status: 'pending' }); + }); + }); +});