diff --git a/src/modules/goals/__tests__/assignments.controller.spec.ts b/src/modules/goals/__tests__/assignments.controller.spec.ts new file mode 100644 index 0000000..0d31b32 --- /dev/null +++ b/src/modules/goals/__tests__/assignments.controller.spec.ts @@ -0,0 +1,336 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AssignmentsController } from '../controllers/assignments.controller'; +import { AssignmentsService } from '../services/assignments.service'; +import { AssigneeType, AssignmentStatus } from '../entities/assignment.entity'; +import { ProgressSource } from '../entities/progress-log.entity'; +import { + CreateAssignmentDto, + UpdateAssignmentDto, + UpdateAssignmentStatusDto, + UpdateProgressDto, + AssignmentFiltersDto, +} from '../dto/assignment.dto'; + +describe('AssignmentsController', () => { + let controller: AssignmentsController; + let service: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockAssignmentId = '550e8400-e29b-41d4-a716-446655440003'; + const mockDefinitionId = '550e8400-e29b-41d4-a716-446655440004'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockAssignment = { + id: mockAssignmentId, + tenantId: mockTenantId, + definitionId: mockDefinitionId, + assigneeType: AssigneeType.USER, + userId: mockUserId, + teamId: null, + customTarget: null, + currentValue: 5000, + progressPercentage: 50, + lastUpdatedAt: new Date(), + status: AssignmentStatus.ACTIVE, + achievedAt: null, + notes: null, + createdAt: new Date(), + updatedAt: new Date(), + definition: null as any, + progressLogs: [], + milestoneNotifications: [], + }; + + const mockGoalsSummary = { + totalAssignments: 5, + activeAssignments: 3, + achievedAssignments: 2, + failedAssignments: 0, + averageProgress: 65, + atRiskCount: 1, + }; + + const mockCompletionReport = { + totalGoals: 10, + achievedGoals: 6, + failedGoals: 2, + activeGoals: 2, + completionRate: 75, + averageProgress: 70, + }; + + beforeEach(async () => { + const mockService = { + findAll: jest.fn(), + findOne: jest.fn(), + findByDefinition: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateStatus: jest.fn(), + updateProgress: jest.fn(), + getProgressHistory: jest.fn(), + remove: jest.fn(), + getMyGoals: jest.fn(), + getMyGoalsSummary: jest.fn(), + getCompletionReport: jest.fn(), + getUserReport: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AssignmentsController], + providers: [{ provide: AssignmentsService, useValue: mockService }], + }).compile(); + + controller = module.get(AssignmentsController); + service = module.get(AssignmentsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('CRUD Operations', () => { + describe('create', () => { + it('should create a new assignment', async () => { + const createDto: CreateAssignmentDto = { + definitionId: mockDefinitionId, + assigneeType: AssigneeType.USER, + userId: mockUserId, + }; + service.create.mockResolvedValue(mockAssignment); + + const result = await controller.create(mockRequestUser, createDto); + + expect(service.create).toHaveBeenCalledWith(mockTenantId, createDto); + expect(result.userId).toBe(mockUserId); + }); + }); + + describe('findAll', () => { + it('should return all assignments', async () => { + const filters: AssignmentFiltersDto = {}; + service.findAll.mockResolvedValue({ + items: [mockAssignment], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }); + + const result = await controller.findAll(mockRequestUser, filters); + + expect(service.findAll).toHaveBeenCalledWith(mockTenantId, filters); + expect(result.items).toHaveLength(1); + }); + + it('should filter by status', async () => { + const filters: AssignmentFiltersDto = { status: AssignmentStatus.ACTIVE }; + service.findAll.mockResolvedValue({ + items: [mockAssignment], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }); + + await controller.findAll(mockRequestUser, filters); + + expect(service.findAll).toHaveBeenCalledWith(mockTenantId, filters); + }); + }); + + describe('findByDefinition', () => { + it('should return assignments for a specific goal', async () => { + service.findByDefinition.mockResolvedValue([mockAssignment]); + + const result = await controller.findByDefinition(mockRequestUser, mockDefinitionId); + + expect(service.findByDefinition).toHaveBeenCalledWith(mockTenantId, mockDefinitionId); + expect(result).toHaveLength(1); + }); + }); + + describe('findOne', () => { + it('should return an assignment by id', async () => { + service.findOne.mockResolvedValue(mockAssignment); + + const result = await controller.findOne(mockRequestUser, mockAssignmentId); + + expect(service.findOne).toHaveBeenCalledWith(mockTenantId, mockAssignmentId); + expect(result).toEqual(mockAssignment); + }); + }); + + describe('update', () => { + it('should update an assignment', async () => { + const updateDto: UpdateAssignmentDto = { customTarget: 15000 }; + service.update.mockResolvedValue({ ...mockAssignment, customTarget: 15000 }); + + const result = await controller.update(mockRequestUser, mockAssignmentId, updateDto); + + expect(service.update).toHaveBeenCalledWith(mockTenantId, mockAssignmentId, updateDto); + expect(result.customTarget).toBe(15000); + }); + }); + + describe('updateStatus', () => { + it('should update assignment status', async () => { + const statusDto: UpdateAssignmentStatusDto = { status: AssignmentStatus.ACHIEVED }; + service.updateStatus.mockResolvedValue({ + ...mockAssignment, + status: AssignmentStatus.ACHIEVED, + achievedAt: new Date(), + }); + + const result = await controller.updateStatus(mockRequestUser, mockAssignmentId, statusDto); + + expect(service.updateStatus).toHaveBeenCalledWith(mockTenantId, mockAssignmentId, statusDto); + expect(result.status).toBe(AssignmentStatus.ACHIEVED); + }); + }); + + describe('updateProgress', () => { + it('should update assignment progress', async () => { + const progressDto: UpdateProgressDto = { value: 7500, source: ProgressSource.MANUAL }; + service.updateProgress.mockResolvedValue({ + ...mockAssignment, + currentValue: 7500, + progressPercentage: 75, + }); + + const result = await controller.updateProgress(mockRequestUser, mockAssignmentId, progressDto); + + expect(service.updateProgress).toHaveBeenCalledWith( + mockTenantId, + mockAssignmentId, + mockUserId, + progressDto, + ); + expect(result.currentValue).toBe(7500); + }); + }); + + describe('getProgressHistory', () => { + it('should return progress history', async () => { + const mockHistory = [ + { id: 'log-1', assignmentId: mockAssignmentId, previousValue: 0, newValue: 5000, changeAmount: 5000, source: ProgressSource.MANUAL, sourceReference: null, notes: null, loggedBy: mockUserId, loggedAt: new Date() }, + { id: 'log-2', assignmentId: mockAssignmentId, previousValue: 5000, newValue: 7500, changeAmount: 2500, source: ProgressSource.MANUAL, sourceReference: null, notes: null, loggedBy: mockUserId, loggedAt: new Date() }, + ]; + service.getProgressHistory.mockResolvedValue(mockHistory as any); + + const result = await controller.getProgressHistory(mockRequestUser, mockAssignmentId); + + expect(service.getProgressHistory).toHaveBeenCalledWith(mockTenantId, mockAssignmentId); + expect(result).toHaveLength(2); + }); + }); + + describe('remove', () => { + it('should remove an assignment', async () => { + service.remove.mockResolvedValue(undefined); + + await controller.remove(mockRequestUser, mockAssignmentId); + + expect(service.remove).toHaveBeenCalledWith(mockTenantId, mockAssignmentId); + }); + }); + }); + + describe('My Goals', () => { + describe('getMyGoals', () => { + it('should return current user goals', async () => { + service.getMyGoals.mockResolvedValue([mockAssignment]); + + const result = await controller.getMyGoals(mockRequestUser); + + expect(service.getMyGoals).toHaveBeenCalledWith(mockTenantId, mockUserId); + expect(result).toHaveLength(1); + }); + }); + + describe('getMyGoalsSummary', () => { + it('should return goals summary', async () => { + service.getMyGoalsSummary.mockResolvedValue(mockGoalsSummary); + + const result = await controller.getMyGoalsSummary(mockRequestUser); + + expect(service.getMyGoalsSummary).toHaveBeenCalledWith(mockTenantId, mockUserId); + expect(result.totalAssignments).toBe(5); + expect(result.averageProgress).toBe(65); + }); + }); + + describe('updateMyProgress', () => { + it('should update current user goal progress', async () => { + const progressDto: UpdateProgressDto = { value: 6000 }; + service.updateProgress.mockResolvedValue({ + ...mockAssignment, + currentValue: 6000, + progressPercentage: 60, + }); + + const result = await controller.updateMyProgress(mockRequestUser, mockAssignmentId, progressDto); + + expect(service.updateProgress).toHaveBeenCalledWith( + mockTenantId, + mockAssignmentId, + mockUserId, + progressDto, + ); + expect(result.currentValue).toBe(6000); + }); + }); + }); + + describe('Reports', () => { + describe('getCompletionReport', () => { + it('should return completion report without dates', async () => { + service.getCompletionReport.mockResolvedValue(mockCompletionReport); + + const result = await controller.getCompletionReport(mockRequestUser); + + expect(service.getCompletionReport).toHaveBeenCalledWith(mockTenantId, undefined, undefined); + expect(result.completionRate).toBe(75); + }); + + it('should return completion report with date range', async () => { + service.getCompletionReport.mockResolvedValue(mockCompletionReport); + + const result = await controller.getCompletionReport( + mockRequestUser, + '2026-01-01', + '2026-01-31', + ); + + expect(service.getCompletionReport).toHaveBeenCalledWith( + mockTenantId, + new Date('2026-01-01'), + new Date('2026-01-31'), + ); + expect(result.totalGoals).toBe(10); + }); + }); + + describe('getUserReport', () => { + it('should return report by user', async () => { + const mockUserReport = [ + { userId: mockUserId, userName: 'test@example.com', totalAssignments: 5, achieved: 3, failed: 1, active: 1, averageProgress: 70 }, + ]; + service.getUserReport.mockResolvedValue(mockUserReport); + + const result = await controller.getUserReport(mockRequestUser); + + expect(service.getUserReport).toHaveBeenCalledWith(mockTenantId); + expect(result).toHaveLength(1); + expect(result[0].totalAssignments).toBe(5); + }); + }); + }); +}); diff --git a/src/modules/goals/__tests__/assignments.service.spec.ts b/src/modules/goals/__tests__/assignments.service.spec.ts new file mode 100644 index 0000000..784c635 --- /dev/null +++ b/src/modules/goals/__tests__/assignments.service.spec.ts @@ -0,0 +1,355 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { AssignmentsService } from '../services/assignments.service'; +import { AssignmentEntity, AssigneeType, AssignmentStatus } from '../entities/assignment.entity'; +import { ProgressLogEntity, ProgressSource } from '../entities/progress-log.entity'; +import { MilestoneNotificationEntity } from '../entities/milestone-notification.entity'; +import { DefinitionEntity, GoalStatus, GoalType, MetricType, PeriodType, DataSource } from '../entities/definition.entity'; +import { CreateAssignmentDto, UpdateProgressDto, AssignmentFiltersDto } from '../dto/assignment.dto'; + +describe('AssignmentsService', () => { + let service: AssignmentsService; + let assignmentRepo: jest.Mocked>; + let progressLogRepo: jest.Mocked>; + let milestoneRepo: jest.Mocked>; + let definitionRepo: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockAssignmentId = '550e8400-e29b-41d4-a716-446655440003'; + const mockDefinitionId = '550e8400-e29b-41d4-a716-446655440004'; + + const mockDefinition: DefinitionEntity = { + id: mockDefinitionId, + tenantId: mockTenantId, + name: 'Monthly Sales Target', + description: null, + category: 'Sales', + type: GoalType.TARGET, + metric: MetricType.CURRENCY, + targetValue: 10000, + unit: 'USD', + period: PeriodType.MONTHLY, + startsAt: new Date('2026-01-01'), + endsAt: new Date('2026-01-31'), + source: DataSource.SALES, + sourceConfig: {}, + milestones: [{ percentage: 50, notify: true }, { percentage: 100, notify: true }], + status: GoalStatus.ACTIVE, + tags: [], + createdAt: new Date(), + updatedAt: new Date(), + createdBy: mockUserId, + assignments: [], + }; + + const mockAssignment: AssignmentEntity = { + id: mockAssignmentId, + tenantId: mockTenantId, + definitionId: mockDefinitionId, + assigneeType: AssigneeType.USER, + userId: mockUserId, + teamId: null, + customTarget: null, + currentValue: 5000, + progressPercentage: 50, + lastUpdatedAt: new Date(), + status: AssignmentStatus.ACTIVE, + achievedAt: null, + notes: null, + createdAt: new Date(), + updatedAt: new Date(), + definition: mockDefinition, + progressLogs: [], + milestoneNotifications: [], + }; + + beforeEach(async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + addGroupBy: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockAssignment], 1]), + getMany: jest.fn().mockResolvedValue([mockAssignment]), + getRawMany: jest.fn().mockResolvedValue([]), + }; + + const mockAssignmentRepoObj = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + createQueryBuilder: jest.fn(() => mockQueryBuilder), + }; + + const mockProgressLogRepoObj = { + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockMilestoneRepoObj = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockDefinitionRepoObj = { + findOne: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssignmentsService, + { provide: getRepositoryToken(AssignmentEntity), useValue: mockAssignmentRepoObj }, + { provide: getRepositoryToken(ProgressLogEntity), useValue: mockProgressLogRepoObj }, + { provide: getRepositoryToken(MilestoneNotificationEntity), useValue: mockMilestoneRepoObj }, + { provide: getRepositoryToken(DefinitionEntity), useValue: mockDefinitionRepoObj }, + ], + }).compile(); + + service = module.get(AssignmentsService); + assignmentRepo = module.get(getRepositoryToken(AssignmentEntity)); + progressLogRepo = module.get(getRepositoryToken(ProgressLogEntity)); + milestoneRepo = module.get(getRepositoryToken(MilestoneNotificationEntity)); + definitionRepo = module.get(getRepositoryToken(DefinitionEntity)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create a new assignment', async () => { + const createDto: CreateAssignmentDto = { + definitionId: mockDefinitionId, + assigneeType: AssigneeType.USER, + userId: mockUserId, + }; + + definitionRepo.findOne.mockResolvedValue(mockDefinition); + assignmentRepo.create.mockReturnValue(mockAssignment); + assignmentRepo.save.mockResolvedValue(mockAssignment); + + const result = await service.create(mockTenantId, createDto); + + expect(definitionRepo.findOne).toHaveBeenCalledWith({ + where: { id: mockDefinitionId, tenantId: mockTenantId }, + }); + expect(result.userId).toBe(mockUserId); + }); + + it('should throw NotFoundException if definition not found', async () => { + definitionRepo.findOne.mockResolvedValue(null); + + await expect( + service.create(mockTenantId, { definitionId: 'invalid', assigneeType: AssigneeType.USER }), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException if user assignment without userId', async () => { + definitionRepo.findOne.mockResolvedValue(mockDefinition); + + await expect( + service.create(mockTenantId, { definitionId: mockDefinitionId, assigneeType: AssigneeType.USER }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException if team assignment without teamId', async () => { + definitionRepo.findOne.mockResolvedValue(mockDefinition); + + await expect( + service.create(mockTenantId, { definitionId: mockDefinitionId, assigneeType: AssigneeType.TEAM }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('findAll', () => { + it('should return paginated assignments', async () => { + const filters: AssignmentFiltersDto = {}; + + const result = await service.findAll(mockTenantId, filters); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('should filter by definitionId', async () => { + const filters: AssignmentFiltersDto = { definitionId: mockDefinitionId }; + + await service.findAll(mockTenantId, filters); + + expect(assignmentRepo.createQueryBuilder).toHaveBeenCalled(); + }); + + it('should filter by status', async () => { + const filters: AssignmentFiltersDto = { status: AssignmentStatus.ACTIVE }; + + await service.findAll(mockTenantId, filters); + + expect(assignmentRepo.createQueryBuilder).toHaveBeenCalled(); + }); + }); + + describe('findOne', () => { + it('should return an assignment by id', async () => { + assignmentRepo.findOne.mockResolvedValue(mockAssignment); + + const result = await service.findOne(mockTenantId, mockAssignmentId); + + expect(assignmentRepo.findOne).toHaveBeenCalledWith({ + where: { id: mockAssignmentId, tenantId: mockTenantId }, + relations: ['definition', 'user'], + }); + expect(result).toEqual(mockAssignment); + }); + + it('should throw NotFoundException if not found', async () => { + assignmentRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'non-existent')).rejects.toThrow(NotFoundException); + }); + }); + + describe('updateProgress', () => { + it('should update progress and log the change', async () => { + const progressDto: UpdateProgressDto = { value: 7500 }; + assignmentRepo.findOne.mockResolvedValue(mockAssignment); + progressLogRepo.create.mockReturnValue({} as ProgressLogEntity); + progressLogRepo.save.mockResolvedValue({} as ProgressLogEntity); + milestoneRepo.findOne.mockResolvedValue(null); + assignmentRepo.save.mockResolvedValue({ + ...mockAssignment, + currentValue: 7500, + progressPercentage: 75, + }); + + const result = await service.updateProgress(mockTenantId, mockAssignmentId, mockUserId, progressDto); + + expect(progressLogRepo.create).toHaveBeenCalled(); + expect(progressLogRepo.save).toHaveBeenCalled(); + expect(result.currentValue).toBe(7500); + }); + + it('should auto-achieve when progress reaches 100%', async () => { + const progressDto: UpdateProgressDto = { value: 10000 }; + assignmentRepo.findOne.mockResolvedValue(mockAssignment); + progressLogRepo.create.mockReturnValue({} as ProgressLogEntity); + progressLogRepo.save.mockResolvedValue({} as ProgressLogEntity); + milestoneRepo.findOne.mockResolvedValue(null); + assignmentRepo.save.mockResolvedValue({ + ...mockAssignment, + currentValue: 10000, + progressPercentage: 100, + status: AssignmentStatus.ACHIEVED, + achievedAt: new Date(), + }); + + const result = await service.updateProgress(mockTenantId, mockAssignmentId, mockUserId, progressDto); + + expect(result.status).toBe(AssignmentStatus.ACHIEVED); + }); + }); + + describe('getProgressHistory', () => { + it('should return progress history', async () => { + const mockLogs = [ + { id: 'log-1', previousValue: 0, newValue: 5000 }, + { id: 'log-2', previousValue: 5000, newValue: 7500 }, + ]; + progressLogRepo.find.mockResolvedValue(mockLogs as ProgressLogEntity[]); + + const result = await service.getProgressHistory(mockTenantId, mockAssignmentId); + + expect(progressLogRepo.find).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId, assignmentId: mockAssignmentId }, + order: { loggedAt: 'DESC' }, + }); + expect(result).toHaveLength(2); + }); + }); + + describe('getMyGoals', () => { + it('should return user goals', async () => { + assignmentRepo.find.mockResolvedValue([mockAssignment]); + + const result = await service.getMyGoals(mockTenantId, mockUserId); + + expect(assignmentRepo.find).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId, userId: mockUserId }, + relations: ['definition'], + order: { createdAt: 'DESC' }, + }); + expect(result).toHaveLength(1); + }); + }); + + describe('getMyGoalsSummary', () => { + it('should return goals summary', async () => { + const mockAssignments = [ + { ...mockAssignment, status: AssignmentStatus.ACTIVE, progressPercentage: 50 }, + { ...mockAssignment, id: 'a2', status: AssignmentStatus.ACHIEVED, progressPercentage: 100 }, + ]; + assignmentRepo.find.mockResolvedValue(mockAssignments as AssignmentEntity[]); + + const result = await service.getMyGoalsSummary(mockTenantId, mockUserId); + + expect(result.totalAssignments).toBe(2); + expect(result.activeAssignments).toBe(1); + expect(result.achievedAssignments).toBe(1); + }); + }); + + describe('getCompletionReport', () => { + it('should return completion report', async () => { + const mockQueryBuilder = assignmentRepo.createQueryBuilder(); + (mockQueryBuilder.getMany as jest.Mock).mockResolvedValue([ + { ...mockAssignment, status: AssignmentStatus.ACHIEVED, progressPercentage: 100 }, + { ...mockAssignment, id: 'a2', status: AssignmentStatus.FAILED, progressPercentage: 40 }, + ]); + + const result = await service.getCompletionReport(mockTenantId); + + expect(result.totalGoals).toBe(2); + expect(result.achievedGoals).toBe(1); + expect(result.failedGoals).toBe(1); + }); + }); + + describe('getUserReport', () => { + it('should return report grouped by user', async () => { + const mockResult = [ + { userId: mockUserId, userName: 'test@example.com', totalAssignments: '2', achieved: '1', failed: '0', active: '1', averageProgress: '75.00' }, + ]; + const mockQueryBuilder = assignmentRepo.createQueryBuilder(); + (mockQueryBuilder.getRawMany as jest.Mock).mockResolvedValue(mockResult); + + const result = await service.getUserReport(mockTenantId); + + expect(result).toHaveLength(1); + expect(result[0].totalAssignments).toBe(2); + }); + }); + + describe('remove', () => { + it('should remove an assignment', async () => { + assignmentRepo.findOne.mockResolvedValue(mockAssignment); + assignmentRepo.remove.mockResolvedValue(mockAssignment); + + await service.remove(mockTenantId, mockAssignmentId); + + expect(assignmentRepo.remove).toHaveBeenCalledWith(mockAssignment); + }); + }); +}); diff --git a/src/modules/goals/__tests__/definitions.controller.spec.ts b/src/modules/goals/__tests__/definitions.controller.spec.ts new file mode 100644 index 0000000..4c71b2a --- /dev/null +++ b/src/modules/goals/__tests__/definitions.controller.spec.ts @@ -0,0 +1,218 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DefinitionsController } from '../controllers/definitions.controller'; +import { DefinitionsService } from '../services/definitions.service'; +import { + GoalType, + MetricType, + PeriodType, + GoalStatus, + DataSource, +} from '../entities/definition.entity'; +import { + CreateDefinitionDto, + UpdateDefinitionDto, + UpdateDefinitionStatusDto, + DefinitionFiltersDto, +} from '../dto/definition.dto'; + +describe('DefinitionsController', () => { + let controller: DefinitionsController; + let service: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockDefinitionId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockDefinition = { + id: mockDefinitionId, + tenantId: mockTenantId, + name: 'Monthly Sales Target', + description: 'Achieve $50,000 in sales', + category: 'Sales', + type: GoalType.TARGET, + metric: MetricType.CURRENCY, + targetValue: 50000, + unit: 'USD', + period: PeriodType.MONTHLY, + startsAt: new Date('2026-01-01'), + endsAt: new Date('2026-01-31'), + source: DataSource.SALES, + sourceConfig: {}, + milestones: [{ percentage: 50, notify: true }], + tags: ['sales'], + status: GoalStatus.ACTIVE, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: mockUserId, + assignments: [], + }; + + beforeEach(async () => { + const mockService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateStatus: jest.fn(), + activate: jest.fn(), + duplicate: jest.fn(), + remove: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [DefinitionsController], + providers: [{ provide: DefinitionsService, useValue: mockService }], + }).compile(); + + controller = module.get(DefinitionsController); + service = module.get(DefinitionsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create a new definition', async () => { + const createDto: CreateDefinitionDto = { + name: 'New Goal', + targetValue: 10000, + startsAt: new Date('2026-02-01'), + endsAt: new Date('2026-02-28'), + }; + service.create.mockResolvedValue({ ...mockDefinition, ...createDto }); + + const result = await controller.create(mockRequestUser, createDto); + + expect(service.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + expect(result.name).toBe('New Goal'); + }); + }); + + describe('findAll', () => { + it('should return all definitions', async () => { + const filters: DefinitionFiltersDto = {}; + service.findAll.mockResolvedValue({ + items: [mockDefinition], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }); + + const result = await controller.findAll(mockRequestUser, filters); + + expect(service.findAll).toHaveBeenCalledWith(mockTenantId, filters); + expect(result.items).toHaveLength(1); + }); + + it('should filter by status', async () => { + const filters: DefinitionFiltersDto = { status: GoalStatus.ACTIVE }; + service.findAll.mockResolvedValue({ + items: [mockDefinition], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }); + + await controller.findAll(mockRequestUser, filters); + + expect(service.findAll).toHaveBeenCalledWith(mockTenantId, filters); + }); + + it('should filter by period', async () => { + const filters: DefinitionFiltersDto = { period: PeriodType.MONTHLY }; + service.findAll.mockResolvedValue({ + items: [mockDefinition], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }); + + await controller.findAll(mockRequestUser, filters); + + expect(service.findAll).toHaveBeenCalledWith(mockTenantId, filters); + }); + }); + + describe('findOne', () => { + it('should return a definition by id', async () => { + service.findOne.mockResolvedValue(mockDefinition); + + const result = await controller.findOne(mockRequestUser, mockDefinitionId); + + expect(service.findOne).toHaveBeenCalledWith(mockTenantId, mockDefinitionId); + expect(result).toEqual(mockDefinition); + }); + }); + + describe('update', () => { + it('should update a definition', async () => { + const updateDto: UpdateDefinitionDto = { name: 'Updated Goal' }; + service.update.mockResolvedValue({ ...mockDefinition, name: 'Updated Goal' }); + + const result = await controller.update(mockRequestUser, mockDefinitionId, updateDto); + + expect(service.update).toHaveBeenCalledWith(mockTenantId, mockDefinitionId, updateDto); + expect(result.name).toBe('Updated Goal'); + }); + }); + + describe('updateStatus', () => { + it('should update definition status', async () => { + const statusDto: UpdateDefinitionStatusDto = { status: GoalStatus.PAUSED }; + service.updateStatus.mockResolvedValue({ ...mockDefinition, status: GoalStatus.PAUSED }); + + const result = await controller.updateStatus(mockRequestUser, mockDefinitionId, statusDto); + + expect(service.updateStatus).toHaveBeenCalledWith(mockTenantId, mockDefinitionId, statusDto); + expect(result.status).toBe(GoalStatus.PAUSED); + }); + }); + + describe('activate', () => { + it('should activate a definition', async () => { + service.activate.mockResolvedValue({ ...mockDefinition, status: GoalStatus.ACTIVE }); + + const result = await controller.activate(mockRequestUser, mockDefinitionId); + + expect(service.activate).toHaveBeenCalledWith(mockTenantId, mockDefinitionId); + expect(result.status).toBe(GoalStatus.ACTIVE); + }); + }); + + describe('duplicate', () => { + it('should duplicate a definition', async () => { + service.duplicate.mockResolvedValue({ + ...mockDefinition, + id: 'new-id', + name: 'Monthly Sales Target (Copy)', + status: GoalStatus.DRAFT, + }); + + const result = await controller.duplicate(mockRequestUser, mockDefinitionId); + + expect(service.duplicate).toHaveBeenCalledWith(mockTenantId, mockDefinitionId, mockUserId); + expect(result.name).toContain('(Copy)'); + }); + }); + + describe('remove', () => { + it('should remove a definition', async () => { + service.remove.mockResolvedValue(undefined); + + await controller.remove(mockRequestUser, mockDefinitionId); + + expect(service.remove).toHaveBeenCalledWith(mockTenantId, mockDefinitionId); + }); + }); +}); diff --git a/src/modules/goals/__tests__/definitions.service.spec.ts b/src/modules/goals/__tests__/definitions.service.spec.ts new file mode 100644 index 0000000..b25a016 --- /dev/null +++ b/src/modules/goals/__tests__/definitions.service.spec.ts @@ -0,0 +1,283 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { DefinitionsService } from '../services/definitions.service'; +import { + DefinitionEntity, + GoalType, + MetricType, + PeriodType, + DataSource, + GoalStatus, +} from '../entities/definition.entity'; +import { CreateDefinitionDto, UpdateDefinitionDto, DefinitionFiltersDto } from '../dto/definition.dto'; + +describe('DefinitionsService', () => { + let service: DefinitionsService; + let repository: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockDefinitionId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockDefinition: DefinitionEntity = { + id: mockDefinitionId, + tenantId: mockTenantId, + name: 'Monthly Sales Target', + description: 'Achieve $50,000 in sales', + category: 'Sales', + type: GoalType.TARGET, + metric: MetricType.CURRENCY, + targetValue: 50000, + unit: 'USD', + period: PeriodType.MONTHLY, + startsAt: new Date('2026-01-01'), + endsAt: new Date('2026-01-31'), + source: DataSource.SALES, + sourceConfig: { module: 'sales', entity: 'opportunity', aggregation: 'sum', field: 'value' }, + milestones: [{ percentage: 50, notify: true }, { percentage: 100, notify: true }], + status: GoalStatus.ACTIVE, + tags: ['sales', 'monthly'], + createdAt: new Date(), + updatedAt: new Date(), + createdBy: mockUserId, + assignments: [], + }; + + beforeEach(async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + loadRelationCountAndMap: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockDefinition], 1]), + }; + + const mockRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + createQueryBuilder: jest.fn(() => mockQueryBuilder), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DefinitionsService, + { provide: getRepositoryToken(DefinitionEntity), useValue: mockRepo }, + ], + }).compile(); + + service = module.get(DefinitionsService); + repository = module.get(getRepositoryToken(DefinitionEntity)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create a new definition', async () => { + const createDto: CreateDefinitionDto = { + name: 'New Goal', + targetValue: 10000, + startsAt: new Date('2026-02-01'), + endsAt: new Date('2026-02-28'), + }; + + repository.create.mockReturnValue({ ...mockDefinition, ...createDto }); + repository.save.mockResolvedValue({ ...mockDefinition, ...createDto }); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(repository.create).toHaveBeenCalledWith({ + tenantId: mockTenantId, + createdBy: mockUserId, + ...createDto, + }); + expect(result.name).toBe('New Goal'); + }); + + it('should throw BadRequestException if end date before start date', async () => { + const createDto: CreateDefinitionDto = { + name: 'Invalid Goal', + targetValue: 10000, + startsAt: new Date('2026-02-28'), + endsAt: new Date('2026-02-01'), + }; + + await expect(service.create(mockTenantId, mockUserId, createDto)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('findAll', () => { + it('should return paginated definitions', async () => { + const filters: DefinitionFiltersDto = {}; + + const result = await service.findAll(mockTenantId, filters); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + + it('should filter by status', async () => { + const filters: DefinitionFiltersDto = { status: GoalStatus.ACTIVE }; + + await service.findAll(mockTenantId, filters); + + const queryBuilder = repository.createQueryBuilder(); + expect(queryBuilder.andWhere).toHaveBeenCalled(); + }); + + it('should filter by period', async () => { + const filters: DefinitionFiltersDto = { period: PeriodType.MONTHLY }; + + await service.findAll(mockTenantId, filters); + + expect(repository.createQueryBuilder).toHaveBeenCalled(); + }); + + it('should support search by name', async () => { + const filters: DefinitionFiltersDto = { search: 'Sales' }; + + await service.findAll(mockTenantId, filters); + + expect(repository.createQueryBuilder).toHaveBeenCalled(); + }); + }); + + describe('findOne', () => { + it('should return a definition by id', async () => { + repository.findOne.mockResolvedValue(mockDefinition); + + const result = await service.findOne(mockTenantId, mockDefinitionId); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { id: mockDefinitionId, tenantId: mockTenantId }, + relations: ['creator'], + }); + expect(result).toEqual(mockDefinition); + }); + + it('should throw NotFoundException if not found', async () => { + repository.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'non-existent')).rejects.toThrow(NotFoundException); + }); + }); + + describe('update', () => { + it('should update a definition', async () => { + const updateDto: UpdateDefinitionDto = { name: 'Updated Goal' }; + repository.findOne.mockResolvedValue(mockDefinition); + repository.save.mockResolvedValue({ ...mockDefinition, name: 'Updated Goal' }); + + const result = await service.update(mockTenantId, mockDefinitionId, updateDto); + + expect(result.name).toBe('Updated Goal'); + }); + + it('should throw BadRequestException if start date after existing end date', async () => { + const updateDto: UpdateDefinitionDto = { startsAt: new Date('2026-12-01') }; + repository.findOne.mockResolvedValue(mockDefinition); + + await expect(service.update(mockTenantId, mockDefinitionId, updateDto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException if end date before existing start date', async () => { + const updateDto: UpdateDefinitionDto = { endsAt: new Date('2025-01-01') }; + repository.findOne.mockResolvedValue(mockDefinition); + + await expect(service.update(mockTenantId, mockDefinitionId, updateDto)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('updateStatus', () => { + it('should update definition status', async () => { + repository.findOne.mockResolvedValue(mockDefinition); + repository.save.mockResolvedValue({ ...mockDefinition, status: GoalStatus.PAUSED }); + + const result = await service.updateStatus(mockTenantId, mockDefinitionId, { + status: GoalStatus.PAUSED, + }); + + expect(result.status).toBe(GoalStatus.PAUSED); + }); + }); + + describe('activate', () => { + it('should activate a definition', async () => { + const draftDefinition = { ...mockDefinition, status: GoalStatus.DRAFT }; + repository.findOne.mockResolvedValue(draftDefinition); + repository.save.mockResolvedValue({ ...draftDefinition, status: GoalStatus.ACTIVE }); + + const result = await service.activate(mockTenantId, mockDefinitionId); + + expect(result.status).toBe(GoalStatus.ACTIVE); + }); + }); + + describe('duplicate', () => { + it('should duplicate a definition', async () => { + repository.findOne.mockResolvedValue(mockDefinition); + repository.create.mockReturnValue({ + ...mockDefinition, + id: 'new-id', + name: 'Monthly Sales Target (Copy)', + status: GoalStatus.DRAFT, + }); + repository.save.mockResolvedValue({ + ...mockDefinition, + id: 'new-id', + name: 'Monthly Sales Target (Copy)', + status: GoalStatus.DRAFT, + }); + + const result = await service.duplicate(mockTenantId, mockDefinitionId, mockUserId); + + expect(result.name).toContain('(Copy)'); + expect(result.status).toBe(GoalStatus.DRAFT); + }); + }); + + describe('remove', () => { + it('should remove a definition', async () => { + repository.findOne.mockResolvedValue(mockDefinition); + repository.remove.mockResolvedValue(mockDefinition); + + await service.remove(mockTenantId, mockDefinitionId); + + expect(repository.remove).toHaveBeenCalledWith(mockDefinition); + }); + }); + + describe('getActiveGoals', () => { + it('should return active goals within date range', async () => { + repository.find.mockResolvedValue([mockDefinition]); + + const result = await service.getActiveGoals(mockTenantId); + + expect(repository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + tenantId: mockTenantId, + status: GoalStatus.ACTIVE, + }), + }), + ); + expect(result).toHaveLength(1); + }); + }); +}); diff --git a/src/modules/mlm/__tests__/commissions.controller.spec.ts b/src/modules/mlm/__tests__/commissions.controller.spec.ts new file mode 100644 index 0000000..9ced925 --- /dev/null +++ b/src/modules/mlm/__tests__/commissions.controller.spec.ts @@ -0,0 +1,205 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CommissionsController } from '../controllers/commissions.controller'; +import { CommissionsService } from '../services/commissions.service'; +import { + CalculateCommissionsDto, + UpdateCommissionStatusDto, + CommissionFiltersDto, +} from '../dto/commission.dto'; +import { CommissionType, CommissionStatus } from '../entities/commission.entity'; + +describe('CommissionsController', () => { + let controller: CommissionsController; + let service: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockCommissionId = '550e8400-e29b-41d4-a716-446655440003'; + const mockNodeId = '550e8400-e29b-41d4-a716-446655440004'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockCommission = { + id: mockCommissionId, + tenantId: mockTenantId, + nodeId: mockNodeId, + sourceNodeId: 'source-node-id', + type: CommissionType.LEVEL, + level: 1, + sourceAmount: 1000, + rateApplied: 0.1, + commissionAmount: 100, + currency: 'USD', + periodId: null, + sourceReference: 'order-123', + status: CommissionStatus.PENDING, + paidAt: null, + notes: null, + createdAt: new Date(), + updatedAt: new Date(), + node: null as any, + sourceNode: null as any, + }; + + beforeEach(async () => { + const mockService = { + findAll: jest.fn(), + findOne: jest.fn(), + calculateCommissions: jest.fn(), + updateStatus: jest.fn(), + getCommissionsByLevel: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [CommissionsController], + providers: [{ provide: CommissionsService, useValue: mockService }], + }).compile(); + + controller = module.get(CommissionsController); + service = module.get(CommissionsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return all commissions', async () => { + const filters: CommissionFiltersDto = {}; + service.findAll.mockResolvedValue({ items: [mockCommission], total: 1 }); + + const result = await controller.findAll(mockRequestUser, filters); + + expect(service.findAll).toHaveBeenCalledWith(mockTenantId, filters); + expect(result.items).toHaveLength(1); + }); + + it('should filter by nodeId', async () => { + const filters: CommissionFiltersDto = { nodeId: mockNodeId }; + service.findAll.mockResolvedValue({ items: [mockCommission], total: 1 }); + + await controller.findAll(mockRequestUser, filters); + + expect(service.findAll).toHaveBeenCalledWith(mockTenantId, filters); + }); + + it('should filter by type', async () => { + const filters: CommissionFiltersDto = { type: CommissionType.LEVEL }; + service.findAll.mockResolvedValue({ items: [mockCommission], total: 1 }); + + await controller.findAll(mockRequestUser, filters); + + expect(service.findAll).toHaveBeenCalledWith(mockTenantId, filters); + }); + + it('should filter by status', async () => { + const filters: CommissionFiltersDto = { status: CommissionStatus.PENDING }; + service.findAll.mockResolvedValue({ items: [mockCommission], total: 1 }); + + await controller.findAll(mockRequestUser, filters); + + expect(service.findAll).toHaveBeenCalledWith(mockTenantId, filters); + }); + }); + + describe('getByLevel', () => { + it('should return commissions grouped by level', async () => { + const mockResult = [ + { level: 1, count: 5, totalAmount: 500 }, + { level: 2, count: 3, totalAmount: 150 }, + ]; + service.getCommissionsByLevel.mockResolvedValue(mockResult); + + const result = await controller.getByLevel(mockRequestUser); + + expect(service.getCommissionsByLevel).toHaveBeenCalledWith(mockTenantId, undefined); + expect(result).toEqual(mockResult); + }); + + it('should filter by nodeId', async () => { + const mockResult = [{ level: 1, count: 2, totalAmount: 200 }]; + service.getCommissionsByLevel.mockResolvedValue(mockResult); + + const result = await controller.getByLevel(mockRequestUser, mockNodeId); + + expect(service.getCommissionsByLevel).toHaveBeenCalledWith(mockTenantId, mockNodeId); + expect(result).toEqual(mockResult); + }); + }); + + describe('findOne', () => { + it('should return a commission by id', async () => { + service.findOne.mockResolvedValue(mockCommission); + + const result = await controller.findOne(mockRequestUser, mockCommissionId); + + expect(service.findOne).toHaveBeenCalledWith(mockTenantId, mockCommissionId); + expect(result).toEqual(mockCommission); + }); + }); + + describe('calculateCommissions', () => { + it('should calculate commissions for a sale', async () => { + const dto: CalculateCommissionsDto = { + sourceNodeId: 'source-node', + amount: 1000, + currency: 'USD', + }; + service.calculateCommissions.mockResolvedValue([mockCommission]); + + const result = await controller.calculateCommissions(mockRequestUser, dto); + + expect(service.calculateCommissions).toHaveBeenCalledWith(mockTenantId, dto); + expect(result).toHaveLength(1); + }); + + it('should calculate commissions with source reference', async () => { + const dto: CalculateCommissionsDto = { + sourceNodeId: 'source-node', + amount: 500, + sourceReference: 'order-456', + }; + service.calculateCommissions.mockResolvedValue([ + { ...mockCommission, sourceAmount: 500, commissionAmount: 50 }, + ]); + + const result = await controller.calculateCommissions(mockRequestUser, dto); + + expect(service.calculateCommissions).toHaveBeenCalledWith(mockTenantId, dto); + expect(result[0].sourceAmount).toBe(500); + }); + }); + + describe('updateStatus', () => { + it('should update commission status to PAID', async () => { + const dto: UpdateCommissionStatusDto = { status: CommissionStatus.PAID }; + service.updateStatus.mockResolvedValue({ + ...mockCommission, + status: CommissionStatus.PAID, + paidAt: new Date(), + }); + + const result = await controller.updateStatus(mockRequestUser, mockCommissionId, dto); + + expect(service.updateStatus).toHaveBeenCalledWith(mockTenantId, mockCommissionId, dto); + expect(result.status).toBe(CommissionStatus.PAID); + }); + + it('should update commission status to CANCELLED', async () => { + const dto: UpdateCommissionStatusDto = { status: CommissionStatus.CANCELLED }; + service.updateStatus.mockResolvedValue({ + ...mockCommission, + status: CommissionStatus.CANCELLED, + }); + + const result = await controller.updateStatus(mockRequestUser, mockCommissionId, dto); + + expect(result.status).toBe(CommissionStatus.CANCELLED); + }); + }); +}); diff --git a/src/modules/mlm/__tests__/commissions.service.spec.ts b/src/modules/mlm/__tests__/commissions.service.spec.ts new file mode 100644 index 0000000..46687b2 --- /dev/null +++ b/src/modules/mlm/__tests__/commissions.service.spec.ts @@ -0,0 +1,337 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { CommissionsService } from '../services/commissions.service'; +import { CommissionEntity, CommissionType, CommissionStatus } from '../entities/commission.entity'; +import { BonusEntity, BonusType } from '../entities/bonus.entity'; +import { NodeEntity, NodeStatus } from '../entities/node.entity'; +import { StructureEntity, StructureType } from '../entities/structure.entity'; +import { CommissionFiltersDto, CalculateCommissionsDto } from '../dto/commission.dto'; + +describe('CommissionsService', () => { + let service: CommissionsService; + let commissionRepository: jest.Mocked>; + let bonusRepository: jest.Mocked>; + let nodeRepository: jest.Mocked>; + let structureRepository: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockNodeId = '550e8400-e29b-41d4-a716-446655440002'; + const mockCommissionId = '550e8400-e29b-41d4-a716-446655440003'; + const mockStructureId = '550e8400-e29b-41d4-a716-446655440004'; + + const mockCommission = { + id: mockCommissionId, + tenantId: mockTenantId, + nodeId: mockNodeId, + sourceNodeId: 'source-node-id', + type: CommissionType.LEVEL, + level: 1, + sourceAmount: 1000, + rateApplied: 0.1, + commissionAmount: 100, + currency: 'USD', + periodId: null, + sourceReference: 'order-123', + status: CommissionStatus.PENDING, + paidAt: null, + notes: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + node: null as any, + sourceNode: null as any, + } as CommissionEntity; + + const mockNode = { + id: mockNodeId, + tenantId: mockTenantId, + structureId: mockStructureId, + userId: 'user-001', + parentId: null, + sponsorId: null, + position: 1, + path: 'root.parent.node', + depth: 2, + rankId: null, + highestRankId: null, + personalVolume: 1000, + groupVolume: 5000, + directReferrals: 5, + totalDownline: 10, + totalEarnings: 500, + status: NodeStatus.ACTIVE, + inviteCode: 'ABC123', + joinedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + structure: null as any, + parent: null, + sponsor: null, + children: [], + referrals: [], + rank: null, + highestRank: null, + commissionsReceived: [], + commissionsGenerated: [], + bonuses: [], + rankHistory: [], + } as NodeEntity; + + const mockStructure = { + id: mockStructureId, + tenantId: mockTenantId, + name: 'Test Structure', + description: null, + type: StructureType.UNILEVEL, + levelRates: [ + { level: 1, rate: 0.1 }, + { level: 2, rate: 0.05 }, + ], + matchingRates: [], + config: { maxDepth: 10 }, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: null, + nodes: [], + ranks: [], + } as StructureEntity; + + beforeEach(async () => { + const mockCommissionRepo = { + find: jest.fn(), + findOne: jest.fn(), + findAndCount: jest.fn(), + create: jest.fn(), + save: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + getRawOne: jest.fn().mockResolvedValue({ total: '100', pending: '50', paid: '50' }), + })), + }; + + const mockBonusRepo = { + create: jest.fn(), + save: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ total: '50' }), + })), + }; + + const mockNodeRepo = { + findOne: jest.fn(), + increment: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + })), + }; + + const mockStructureRepo = { + findOne: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CommissionsService, + { provide: getRepositoryToken(CommissionEntity), useValue: mockCommissionRepo }, + { provide: getRepositoryToken(BonusEntity), useValue: mockBonusRepo }, + { provide: getRepositoryToken(NodeEntity), useValue: mockNodeRepo }, + { provide: getRepositoryToken(StructureEntity), useValue: mockStructureRepo }, + ], + }).compile(); + + service = module.get(CommissionsService); + commissionRepository = module.get(getRepositoryToken(CommissionEntity)); + bonusRepository = module.get(getRepositoryToken(BonusEntity)); + nodeRepository = module.get(getRepositoryToken(NodeEntity)); + structureRepository = module.get(getRepositoryToken(StructureEntity)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated commissions', async () => { + commissionRepository.findAndCount.mockResolvedValue([[mockCommission], 1]); + + const result = await service.findAll(mockTenantId); + + expect(commissionRepository.findAndCount).toHaveBeenCalled(); + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('should filter by nodeId', async () => { + const filters: CommissionFiltersDto = { nodeId: mockNodeId }; + commissionRepository.findAndCount.mockResolvedValue([[mockCommission], 1]); + + await service.findAll(mockTenantId, filters); + + expect(commissionRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ nodeId: mockNodeId }), + }), + ); + }); + + it('should filter by type', async () => { + const filters: CommissionFiltersDto = { type: CommissionType.LEVEL }; + commissionRepository.findAndCount.mockResolvedValue([[mockCommission], 1]); + + await service.findAll(mockTenantId, filters); + + expect(commissionRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ type: CommissionType.LEVEL }), + }), + ); + }); + + it('should filter by status', async () => { + const filters: CommissionFiltersDto = { status: CommissionStatus.PENDING }; + commissionRepository.findAndCount.mockResolvedValue([[mockCommission], 1]); + + await service.findAll(mockTenantId, filters); + + expect(commissionRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: CommissionStatus.PENDING }), + }), + ); + }); + }); + + describe('findOne', () => { + it('should return a commission by id', async () => { + commissionRepository.findOne.mockResolvedValue(mockCommission); + + const result = await service.findOne(mockTenantId, mockCommissionId); + + expect(commissionRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockCommissionId, tenantId: mockTenantId }, + }); + expect(result).toEqual(mockCommission); + }); + + it('should throw NotFoundException if commission not found', async () => { + commissionRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'non-existent')).rejects.toThrow(NotFoundException); + }); + }); + + describe('calculateCommissions', () => { + it('should calculate level commissions', async () => { + const dto: CalculateCommissionsDto = { + sourceNodeId: 'source-node', + amount: 1000, + currency: 'USD', + }; + + nodeRepository.findOne.mockResolvedValue(mockNode); + structureRepository.findOne.mockResolvedValue(mockStructure); + commissionRepository.create.mockReturnValue(mockCommission); + commissionRepository.save.mockResolvedValue([mockCommission] as any); + + const result = await service.calculateCommissions(mockTenantId, dto); + + expect(nodeRepository.findOne).toHaveBeenCalled(); + expect(structureRepository.findOne).toHaveBeenCalled(); + }); + + it('should throw NotFoundException if source node not found', async () => { + nodeRepository.findOne.mockResolvedValue(null); + + await expect( + service.calculateCommissions(mockTenantId, { sourceNodeId: 'invalid', amount: 100 }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('updateStatus', () => { + it('should update commission status to PAID', async () => { + commissionRepository.findOne.mockResolvedValue(mockCommission); + commissionRepository.save.mockResolvedValue({ + ...mockCommission, + status: CommissionStatus.PAID, + paidAt: expect.any(Date), + }); + + const result = await service.updateStatus(mockTenantId, mockCommissionId, { + status: CommissionStatus.PAID, + }); + + expect(result.status).toBe(CommissionStatus.PAID); + }); + }); + + describe('getCommissionsByLevel', () => { + it('should return commissions grouped by level', async () => { + const mockResult = [{ level: '1', count: '5', totalAmount: '500' }]; + (commissionRepository.createQueryBuilder as jest.Mock).mockReturnValue({ + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue(mockResult), + }); + + const result = await service.getCommissionsByLevel(mockTenantId); + + expect(result).toEqual([{ level: 1, count: 5, totalAmount: 500 }]); + }); + }); + + describe('getEarningsSummary', () => { + it('should return earnings summary', async () => { + const result = await service.getEarningsSummary(mockTenantId, mockNodeId); + + expect(result.totalCommissions).toBeDefined(); + expect(result.totalBonuses).toBeDefined(); + expect(result.totalEarnings).toBeDefined(); + }); + }); + + describe('createBonus', () => { + it('should create a bonus', async () => { + const mockBonus = { + id: 'bonus-id', + tenantId: mockTenantId, + nodeId: mockNodeId, + type: BonusType.RANK_ACHIEVEMENT, + amount: 100, + status: CommissionStatus.PENDING, + }; + + bonusRepository.create.mockReturnValue(mockBonus as BonusEntity); + bonusRepository.save.mockResolvedValue(mockBonus as BonusEntity); + + const result = await service.createBonus( + mockTenantId, + mockNodeId, + BonusType.RANK_ACHIEVEMENT, + 100, + ); + + expect(bonusRepository.create).toHaveBeenCalled(); + expect(bonusRepository.save).toHaveBeenCalled(); + expect(nodeRepository.increment).toHaveBeenCalledWith({ id: mockNodeId }, 'totalEarnings', 100); + }); + }); +}); diff --git a/src/modules/mlm/__tests__/nodes.controller.spec.ts b/src/modules/mlm/__tests__/nodes.controller.spec.ts new file mode 100644 index 0000000..4a552b3 --- /dev/null +++ b/src/modules/mlm/__tests__/nodes.controller.spec.ts @@ -0,0 +1,337 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NodesController } from '../controllers/nodes.controller'; +import { NodesService } from '../services/nodes.service'; +import { CommissionsService } from '../services/commissions.service'; +import { CreateNodeDto, UpdateNodeDto, UpdateNodeStatusDto, NodeFiltersDto } from '../dto/node.dto'; +import { NodeStatus } from '../entities/node.entity'; + +describe('NodesController', () => { + let controller: NodesController; + let nodesService: jest.Mocked; + let commissionsService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockNodeId = '550e8400-e29b-41d4-a716-446655440003'; + const mockStructureId = '550e8400-e29b-41d4-a716-446655440004'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockNode = { + id: mockNodeId, + tenantId: mockTenantId, + structureId: mockStructureId, + userId: mockUserId, + parentId: null, + sponsorId: null, + position: 1, + depth: 0, + path: 'root', + inviteCode: 'ABC123', + personalVolume: 1000, + groupVolume: 5000, + directReferrals: 5, + totalDownline: 15, + totalEarnings: 500, + rankId: null, + highestRankId: null, + status: NodeStatus.ACTIVE, + joinedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + structure: null as any, + parent: null, + sponsor: null, + children: [], + referrals: [], + rank: null, + highestRank: null, + commissionsReceived: [], + commissionsGenerated: [], + bonuses: [], + rankHistory: [], + }; + + const mockNetworkSummary = { + node: mockNode, + currentRank: { id: 'rank-1', name: 'Gold', level: 3 }, + nextRank: { id: 'rank-2', name: 'Platinum', level: 4, progress: 0.75 }, + totalDownline: 15, + directReferrals: 5, + activeDownline: 10, + personalVolume: 1000, + groupVolume: 5000, + totalEarnings: 500, + }; + + const mockEarningsSummary = { + totalCommissions: 500, + totalBonuses: 100, + totalEarnings: 600, + pendingAmount: 50, + paidAmount: 450, + byLevel: [], + }; + + beforeEach(async () => { + const mockNodesService = { + findAll: jest.fn(), + findOne: jest.fn(), + findByUserId: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateStatus: jest.fn(), + getDownline: jest.fn(), + getUpline: jest.fn(), + getTree: jest.fn(), + getMyNetworkSummary: jest.fn(), + generateInviteLink: jest.fn(), + }; + + const mockCommissionsService = { + getEarningsSummary: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [NodesController], + providers: [ + { provide: NodesService, useValue: mockNodesService }, + { provide: CommissionsService, useValue: mockCommissionsService }, + ], + }).compile(); + + controller = module.get(NodesController); + nodesService = module.get(NodesService); + commissionsService = module.get(CommissionsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('CRUD Operations', () => { + describe('create', () => { + it('should create a new node', async () => { + const createDto: CreateNodeDto = { + structureId: mockStructureId, + userId: 'new-user-id', + }; + nodesService.create.mockResolvedValue({ ...mockNode, userId: 'new-user-id' }); + + const result = await controller.create(mockRequestUser, createDto); + + expect(nodesService.create).toHaveBeenCalledWith(mockTenantId, createDto); + expect(result.userId).toBe('new-user-id'); + }); + }); + + describe('findAll', () => { + it('should return all nodes', async () => { + const filters: NodeFiltersDto = {}; + nodesService.findAll.mockResolvedValue({ items: [mockNode], total: 1 }); + + const result = await controller.findAll(mockRequestUser, filters); + + expect(nodesService.findAll).toHaveBeenCalledWith(mockTenantId, filters); + expect(result.items).toHaveLength(1); + }); + + it('should apply filters', async () => { + const filters: NodeFiltersDto = { structureId: mockStructureId, status: NodeStatus.ACTIVE }; + nodesService.findAll.mockResolvedValue({ items: [mockNode], total: 1 }); + + await controller.findAll(mockRequestUser, filters); + + expect(nodesService.findAll).toHaveBeenCalledWith(mockTenantId, filters); + }); + }); + + describe('findOne', () => { + it('should return a node by id', async () => { + nodesService.findOne.mockResolvedValue(mockNode); + + const result = await controller.findOne(mockRequestUser, mockNodeId); + + expect(nodesService.findOne).toHaveBeenCalledWith(mockTenantId, mockNodeId); + expect(result).toEqual(mockNode); + }); + }); + + describe('update', () => { + it('should update a node', async () => { + const updateDto: UpdateNodeDto = { position: 2 }; + nodesService.update.mockResolvedValue({ ...mockNode, position: 2 }); + + const result = await controller.update(mockRequestUser, mockNodeId, updateDto); + + expect(nodesService.update).toHaveBeenCalledWith(mockTenantId, mockNodeId, updateDto); + expect(result.position).toBe(2); + }); + }); + + describe('updateStatus', () => { + it('should update node status', async () => { + const statusDto: UpdateNodeStatusDto = { status: NodeStatus.INACTIVE }; + nodesService.updateStatus.mockResolvedValue({ ...mockNode, status: NodeStatus.INACTIVE }); + + const result = await controller.updateStatus(mockRequestUser, mockNodeId, statusDto); + + expect(nodesService.updateStatus).toHaveBeenCalledWith(mockTenantId, mockNodeId, statusDto); + expect(result.status).toBe(NodeStatus.INACTIVE); + }); + }); + }); + + describe('Network Navigation', () => { + describe('getDownline', () => { + it('should return downline nodes', async () => { + nodesService.getDownline.mockResolvedValue([mockNode]); + + const result = await controller.getDownline(mockRequestUser, mockNodeId); + + expect(nodesService.getDownline).toHaveBeenCalledWith(mockTenantId, mockNodeId, undefined); + expect(result).toHaveLength(1); + }); + + it('should accept maxDepth parameter', async () => { + nodesService.getDownline.mockResolvedValue([mockNode]); + + await controller.getDownline(mockRequestUser, mockNodeId, 5); + + expect(nodesService.getDownline).toHaveBeenCalledWith(mockTenantId, mockNodeId, 5); + }); + }); + + describe('getUpline', () => { + it('should return upline nodes', async () => { + nodesService.getUpline.mockResolvedValue([mockNode]); + + const result = await controller.getUpline(mockRequestUser, mockNodeId); + + expect(nodesService.getUpline).toHaveBeenCalledWith(mockTenantId, mockNodeId); + expect(result).toHaveLength(1); + }); + }); + + describe('getTree', () => { + it('should return tree structure', async () => { + const mockTree = { id: mockNodeId, userId: mockUserId, depth: 0, position: 1, personalVolume: 1000, groupVolume: 5000, rankName: 'Gold', status: 'active', children: [] }; + nodesService.getTree.mockResolvedValue(mockTree as any); + + const result = await controller.getTree(mockRequestUser, mockNodeId); + + expect(nodesService.getTree).toHaveBeenCalledWith(mockTenantId, mockNodeId, 3); + expect(result).toEqual(mockTree); + }); + + it('should accept custom maxDepth', async () => { + const mockTree = { id: mockNodeId, userId: mockUserId, depth: 0, position: 1, personalVolume: 1000, groupVolume: 5000, rankName: 'Gold', status: 'active', children: [] }; + nodesService.getTree.mockResolvedValue(mockTree as any); + + await controller.getTree(mockRequestUser, mockNodeId, 5); + + expect(nodesService.getTree).toHaveBeenCalledWith(mockTenantId, mockNodeId, 5); + }); + }); + }); + + describe('My Network', () => { + describe('getMyDashboard', () => { + it('should return current user dashboard', async () => { + nodesService.getMyNetworkSummary.mockResolvedValue(mockNetworkSummary as any); + + const result = await controller.getMyDashboard(mockRequestUser); + + expect(nodesService.getMyNetworkSummary).toHaveBeenCalledWith(mockTenantId, mockUserId); + expect(result).toEqual(mockNetworkSummary); + }); + }); + + describe('getMyNetwork', () => { + it('should return current user network tree', async () => { + const mockTree = { id: mockNodeId, userId: mockUserId, depth: 0, position: 1, personalVolume: 1000, groupVolume: 5000, rankName: 'Gold', status: 'active', children: [] }; + nodesService.findByUserId.mockResolvedValue(mockNode as any); + nodesService.getTree.mockResolvedValue(mockTree as any); + + const result = await controller.getMyNetwork(mockRequestUser); + + expect(nodesService.findByUserId).toHaveBeenCalledWith(mockTenantId, mockUserId); + expect(nodesService.getTree).toHaveBeenCalledWith(mockTenantId, mockNodeId, 3); + expect(result).toEqual(mockTree); + }); + + it('should return null if user has no node', async () => { + nodesService.findByUserId.mockResolvedValue(null); + + const result = await controller.getMyNetwork(mockRequestUser); + + expect(result).toBeNull(); + expect(nodesService.getTree).not.toHaveBeenCalled(); + }); + }); + + describe('getMyEarnings', () => { + it('should return current user earnings', async () => { + nodesService.findByUserId.mockResolvedValue(mockNode as any); + commissionsService.getEarningsSummary.mockResolvedValue(mockEarningsSummary as any); + + const result = await controller.getMyEarnings(mockRequestUser); + + expect(nodesService.findByUserId).toHaveBeenCalledWith(mockTenantId, mockUserId); + expect(commissionsService.getEarningsSummary).toHaveBeenCalledWith(mockTenantId, mockNodeId); + expect(result).toEqual(mockEarningsSummary); + }); + + it('should return null if user has no node', async () => { + nodesService.findByUserId.mockResolvedValue(null); + + const result = await controller.getMyEarnings(mockRequestUser); + + expect(result).toBeNull(); + expect(commissionsService.getEarningsSummary).not.toHaveBeenCalled(); + }); + }); + + describe('getMyRank', () => { + it('should return current user rank info', async () => { + nodesService.getMyNetworkSummary.mockResolvedValue(mockNetworkSummary as any); + + const result = await controller.getMyRank(mockRequestUser); + + expect(nodesService.getMyNetworkSummary).toHaveBeenCalledWith(mockTenantId, mockUserId); + expect(result).toEqual({ + currentRank: mockNetworkSummary.currentRank, + nextRank: mockNetworkSummary.nextRank, + }); + }); + }); + + describe('generateInviteLink', () => { + it('should generate invite link', async () => { + const inviteLink = { inviteCode: 'XYZ789', inviteUrl: 'https://app.com/join/XYZ789' }; + nodesService.findByUserId.mockResolvedValue(mockNode as any); + nodesService.generateInviteLink.mockResolvedValue(inviteLink); + + const result = await controller.generateInviteLink(mockRequestUser); + + expect(nodesService.findByUserId).toHaveBeenCalledWith(mockTenantId, mockUserId); + expect(nodesService.generateInviteLink).toHaveBeenCalledWith(mockTenantId, mockNodeId); + expect(result).toEqual(inviteLink); + }); + + it('should return null if user has no node', async () => { + nodesService.findByUserId.mockResolvedValue(null); + + const result = await controller.generateInviteLink(mockRequestUser); + + expect(result).toBeNull(); + expect(nodesService.generateInviteLink).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/modules/mlm/__tests__/nodes.service.spec.ts b/src/modules/mlm/__tests__/nodes.service.spec.ts new file mode 100644 index 0000000..7395351 --- /dev/null +++ b/src/modules/mlm/__tests__/nodes.service.spec.ts @@ -0,0 +1,300 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { NodesService } from '../services/nodes.service'; +import { NodeEntity, NodeStatus } from '../entities/node.entity'; +import { StructureEntity, StructureType } from '../entities/structure.entity'; +import { RankEntity } from '../entities/rank.entity'; +import { CreateNodeDto, NodeFiltersDto } from '../dto/node.dto'; + +describe('NodesService', () => { + let service: NodesService; + let nodeRepository: jest.Mocked>; + let structureRepository: jest.Mocked>; + let rankRepository: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockStructureId = '550e8400-e29b-41d4-a716-446655440002'; + const mockNodeId = '550e8400-e29b-41d4-a716-446655440003'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440004'; + + const mockStructure: StructureEntity = { + id: mockStructureId, + tenantId: mockTenantId, + name: 'Test Structure', + description: null, + type: StructureType.UNILEVEL, + config: { maxDepth: 10 }, + levelRates: [{ level: 1, rate: 0.1 }], + matchingRates: [], + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: null, + nodes: [], + ranks: [], + }; + + const mockNode = { + id: mockNodeId, + tenantId: mockTenantId, + structureId: mockStructureId, + userId: mockUserId, + parentId: null, + sponsorId: null, + position: 1, + depth: 0, + path: mockNodeId.replace(/-/g, '_'), + inviteCode: 'ABC123', + personalVolume: 1000, + groupVolume: 5000, + directReferrals: 5, + totalDownline: 15, + totalEarnings: 500, + rankId: null, + highestRankId: null, + status: NodeStatus.ACTIVE, + joinedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + structure: null as any, + parent: null, + sponsor: null, + children: [], + referrals: [], + rank: null as any, + highestRank: null as any, + commissionsReceived: [], + commissionsGenerated: [], + bonuses: [], + rankHistory: [], + } as NodeEntity; + + beforeEach(async () => { + const mockNodeRepo = { + find: jest.fn(), + findOne: jest.fn(), + findAndCount: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + count: jest.fn(), + increment: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + })), + }; + + const mockStructureRepo = { + findOne: jest.fn(), + }; + + const mockRankRepo = { + findOne: jest.fn(), + find: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NodesService, + { provide: getRepositoryToken(NodeEntity), useValue: mockNodeRepo }, + { provide: getRepositoryToken(StructureEntity), useValue: mockStructureRepo }, + { provide: getRepositoryToken(RankEntity), useValue: mockRankRepo }, + ], + }).compile(); + + service = module.get(NodesService); + nodeRepository = module.get(getRepositoryToken(NodeEntity)); + structureRepository = module.get(getRepositoryToken(StructureEntity)); + rankRepository = module.get(getRepositoryToken(RankEntity)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated nodes', async () => { + nodeRepository.findAndCount.mockResolvedValue([[mockNode], 1]); + + const result = await service.findAll(mockTenantId); + + expect(nodeRepository.findAndCount).toHaveBeenCalled(); + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('should filter by structureId', async () => { + const filters: NodeFiltersDto = { structureId: mockStructureId }; + nodeRepository.findAndCount.mockResolvedValue([[mockNode], 1]); + + await service.findAll(mockTenantId, filters); + + expect(nodeRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ structureId: mockStructureId }), + }), + ); + }); + + it('should filter by status', async () => { + const filters: NodeFiltersDto = { status: NodeStatus.ACTIVE }; + nodeRepository.findAndCount.mockResolvedValue([[mockNode], 1]); + + await service.findAll(mockTenantId, filters); + + expect(nodeRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: NodeStatus.ACTIVE }), + }), + ); + }); + }); + + describe('findOne', () => { + it('should return a node by id', async () => { + nodeRepository.findOne.mockResolvedValue(mockNode); + + const result = await service.findOne(mockTenantId, mockNodeId); + + expect(nodeRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockNodeId, tenantId: mockTenantId }, + relations: ['rank', 'highestRank'], + }); + expect(result).toEqual(mockNode); + }); + + it('should throw NotFoundException if node not found', async () => { + nodeRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'non-existent')).rejects.toThrow(NotFoundException); + }); + }); + + describe('findByUserId', () => { + it('should return node for user', async () => { + nodeRepository.findOne.mockResolvedValue(mockNode); + + const result = await service.findByUserId(mockTenantId, mockUserId); + + expect(result).toEqual(mockNode); + }); + + it('should filter by structureId if provided', async () => { + nodeRepository.findOne.mockResolvedValue(mockNode); + + await service.findByUserId(mockTenantId, mockUserId, mockStructureId); + + expect(nodeRepository.findOne).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId, userId: mockUserId, structureId: mockStructureId }, + }); + }); + }); + + describe('create', () => { + it('should create a new node', async () => { + const createDto: CreateNodeDto = { + structureId: mockStructureId, + userId: 'new-user-id', + }; + + structureRepository.findOne.mockResolvedValue(mockStructure); + nodeRepository.findOne.mockResolvedValue(null); // No existing node + nodeRepository.create.mockReturnValue({ ...mockNode, userId: 'new-user-id' }); + nodeRepository.save.mockResolvedValue({ ...mockNode, userId: 'new-user-id' }); + + const result = await service.create(mockTenantId, createDto); + + expect(structureRepository.findOne).toHaveBeenCalled(); + expect(nodeRepository.create).toHaveBeenCalled(); + expect(result.userId).toBe('new-user-id'); + }); + + it('should throw NotFoundException if structure not found', async () => { + structureRepository.findOne.mockResolvedValue(null); + + await expect( + service.create(mockTenantId, { structureId: 'invalid', userId: 'user' }), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException if user already exists in structure', async () => { + structureRepository.findOne.mockResolvedValue(mockStructure); + nodeRepository.findOne.mockResolvedValue(mockNode); // User already exists + + await expect( + service.create(mockTenantId, { structureId: mockStructureId, userId: mockUserId }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('update', () => { + it('should update node position', async () => { + nodeRepository.findOne.mockResolvedValue(mockNode); + nodeRepository.save.mockResolvedValue({ ...mockNode, position: 2 }); + + const result = await service.update(mockTenantId, mockNodeId, { position: 2 }); + + expect(result.position).toBe(2); + }); + }); + + describe('updateStatus', () => { + it('should update node status', async () => { + nodeRepository.findOne.mockResolvedValue(mockNode); + nodeRepository.save.mockResolvedValue({ ...mockNode, status: NodeStatus.INACTIVE }); + + const result = await service.updateStatus(mockTenantId, mockNodeId, { + status: NodeStatus.INACTIVE, + }); + + expect(result.status).toBe(NodeStatus.INACTIVE); + }); + }); + + describe('getDownline', () => { + it('should return empty array if node has no path', async () => { + nodeRepository.findOne.mockResolvedValue({ ...mockNode, path: null }); + + const result = await service.getDownline(mockTenantId, mockNodeId); + + expect(result).toEqual([]); + }); + }); + + describe('generateInviteLink', () => { + it('should return existing invite code', async () => { + nodeRepository.findOne.mockResolvedValue(mockNode); + + const result = await service.generateInviteLink(mockTenantId, mockNodeId); + + expect(result.inviteCode).toBe(mockNode.inviteCode); + expect(result.inviteUrl).toContain(mockNode.inviteCode); + }); + + it('should generate new invite code if none exists', async () => { + const nodeWithoutCode = { ...mockNode, inviteCode: null }; + nodeRepository.findOne.mockResolvedValue(nodeWithoutCode); + nodeRepository.save.mockResolvedValue({ ...nodeWithoutCode, inviteCode: 'NEWCODE123' }); + + const result = await service.generateInviteLink(mockTenantId, mockNodeId); + + expect(nodeRepository.save).toHaveBeenCalled(); + expect(result.inviteCode).toBeTruthy(); + }); + }); + + describe('getMyNetworkSummary', () => { + it('should throw NotFoundException if user has no node', async () => { + nodeRepository.findOne.mockResolvedValue(null); + + await expect(service.getMyNetworkSummary(mockTenantId, 'unknown-user')).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/src/modules/mlm/__tests__/ranks.controller.spec.ts b/src/modules/mlm/__tests__/ranks.controller.spec.ts new file mode 100644 index 0000000..56a4051 --- /dev/null +++ b/src/modules/mlm/__tests__/ranks.controller.spec.ts @@ -0,0 +1,135 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RanksController } from '../controllers/ranks.controller'; +import { RanksService } from '../services/ranks.service'; +import { CreateRankDto, UpdateRankDto, RankFiltersDto } from '../dto/rank.dto'; + +describe('RanksController', () => { + let controller: RanksController; + let service: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockRankId = '550e8400-e29b-41d4-a716-446655440003'; + const mockStructureId = '550e8400-e29b-41d4-a716-446655440004'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockRank = { + id: mockRankId, + tenantId: mockTenantId, + structureId: mockStructureId, + name: 'Gold', + level: 3, + badgeUrl: null, + color: '#FFD700', + requirements: { personalVolume: 1000 }, + bonusRate: 0.05, + benefits: {}, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + structure: null as any, + currentNodes: [], + highestRankNodes: [], + }; + + beforeEach(async () => { + const mockService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + evaluateRank: jest.fn(), + evaluateAllNodes: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [RanksController], + providers: [{ provide: RanksService, useValue: mockService }], + }).compile(); + + controller = module.get(RanksController); + service = module.get(RanksService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return all ranks', async () => { + const filters: RankFiltersDto = {}; + service.findAll.mockResolvedValue([mockRank]); + + const result = await controller.findAll(mockRequestUser, filters); + + expect(service.findAll).toHaveBeenCalledWith(mockTenantId, filters); + expect(result).toEqual([mockRank]); + }); + + it('should filter by structureId', async () => { + const filters: RankFiltersDto = { structureId: mockStructureId }; + service.findAll.mockResolvedValue([mockRank]); + + await controller.findAll(mockRequestUser, filters); + + expect(service.findAll).toHaveBeenCalledWith(mockTenantId, filters); + }); + }); + + describe('findOne', () => { + it('should return a rank by id', async () => { + service.findOne.mockResolvedValue(mockRank); + + const result = await controller.findOne(mockRequestUser, mockRankId); + + expect(service.findOne).toHaveBeenCalledWith(mockTenantId, mockRankId); + expect(result).toEqual(mockRank); + }); + }); + + describe('create', () => { + it('should create a new rank', async () => { + const createDto: CreateRankDto = { + structureId: mockStructureId, + name: 'Platinum', + level: 4, + requirements: { personalVolume: 2000 }, + }; + service.create.mockResolvedValue({ ...mockRank, ...createDto }); + + const result = await controller.create(mockRequestUser, createDto); + + expect(service.create).toHaveBeenCalledWith(mockTenantId, createDto); + expect(result.name).toBe('Platinum'); + }); + }); + + describe('update', () => { + it('should update a rank', async () => { + const updateDto: UpdateRankDto = { name: 'Updated Rank' }; + service.update.mockResolvedValue({ ...mockRank, name: 'Updated Rank' }); + + const result = await controller.update(mockRequestUser, mockRankId, updateDto); + + expect(service.update).toHaveBeenCalledWith(mockTenantId, mockRankId, updateDto); + expect(result.name).toBe('Updated Rank'); + }); + }); + + describe('remove', () => { + it('should remove a rank', async () => { + service.remove.mockResolvedValue(undefined); + + await controller.remove(mockRequestUser, mockRankId); + + expect(service.remove).toHaveBeenCalledWith(mockTenantId, mockRankId); + }); + }); +}); diff --git a/src/modules/mlm/__tests__/ranks.service.spec.ts b/src/modules/mlm/__tests__/ranks.service.spec.ts new file mode 100644 index 0000000..472646c --- /dev/null +++ b/src/modules/mlm/__tests__/ranks.service.spec.ts @@ -0,0 +1,257 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { RanksService } from '../services/ranks.service'; +import { RankEntity } from '../entities/rank.entity'; +import { NodeEntity, NodeStatus } from '../entities/node.entity'; +import { CreateRankDto, UpdateRankDto, RankFiltersDto } from '../dto/rank.dto'; + +describe('RanksService', () => { + let service: RanksService; + let rankRepository: jest.Mocked>; + let nodeRepository: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockStructureId = '550e8400-e29b-41d4-a716-446655440002'; + const mockRankId = '550e8400-e29b-41d4-a716-446655440003'; + const mockNodeId = '550e8400-e29b-41d4-a716-446655440004'; + + const mockRank: RankEntity = { + id: mockRankId, + tenantId: mockTenantId, + structureId: mockStructureId, + name: 'Gold', + level: 3, + badgeUrl: 'https://example.com/gold.png', + color: '#FFD700', + requirements: { + personalVolume: 1000, + groupVolume: 10000, + directReferrals: 5, + }, + bonusRate: 0.05, + benefits: { discount: 0.15 }, + isActive: true, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + structure: null as any, + currentNodes: [], + highestRankNodes: [], + }; + + const mockNode = { + id: mockNodeId, + tenantId: mockTenantId, + structureId: mockStructureId, + userId: 'user-001', + parentId: null, + sponsorId: null, + position: 1, + depth: 0, + path: '/root', + personalVolume: 1500, + groupVolume: 15000, + directReferrals: 8, + totalDownline: 20, + totalEarnings: 5000, + rankId: null, + highestRankId: null, + status: NodeStatus.ACTIVE, + inviteCode: 'ABC123', + joinedAt: new Date('2026-01-01'), + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + structure: null as any, + parent: null, + sponsor: null, + children: [], + referrals: [], + rank: null, + highestRank: null, + commissionsReceived: [], + commissionsGenerated: [], + bonuses: [], + rankHistory: [], + } as NodeEntity; + + beforeEach(async () => { + const mockRankRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + }; + + const mockNodeRepo = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RanksService, + { provide: getRepositoryToken(RankEntity), useValue: mockRankRepo }, + { provide: getRepositoryToken(NodeEntity), useValue: mockNodeRepo }, + ], + }).compile(); + + service = module.get(RanksService); + rankRepository = module.get(getRepositoryToken(RankEntity)); + nodeRepository = module.get(getRepositoryToken(NodeEntity)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return all ranks for tenant', async () => { + rankRepository.find.mockResolvedValue([mockRank]); + + const result = await service.findAll(mockTenantId); + + expect(rankRepository.find).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId }, + order: { level: 'ASC' }, + }); + expect(result).toEqual([mockRank]); + }); + + it('should filter by structureId', async () => { + const filters: RankFiltersDto = { structureId: mockStructureId }; + rankRepository.find.mockResolvedValue([mockRank]); + + await service.findAll(mockTenantId, filters); + + expect(rankRepository.find).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId, structureId: mockStructureId }, + order: { level: 'ASC' }, + }); + }); + + it('should filter by isActive', async () => { + const filters: RankFiltersDto = { isActive: true }; + rankRepository.find.mockResolvedValue([mockRank]); + + await service.findAll(mockTenantId, filters); + + expect(rankRepository.find).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId, isActive: true }, + order: { level: 'ASC' }, + }); + }); + }); + + describe('findOne', () => { + it('should return a rank by id', async () => { + rankRepository.findOne.mockResolvedValue(mockRank); + + const result = await service.findOne(mockTenantId, mockRankId); + + expect(rankRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockRankId, tenantId: mockTenantId }, + }); + expect(result).toEqual(mockRank); + }); + + it('should throw NotFoundException if rank not found', async () => { + rankRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'non-existent')).rejects.toThrow(NotFoundException); + }); + }); + + describe('create', () => { + it('should create a new rank', async () => { + const createDto: CreateRankDto = { + structureId: mockStructureId, + name: 'Platinum', + level: 4, + requirements: { personalVolume: 2000 }, + }; + + rankRepository.create.mockReturnValue({ ...createDto, id: 'new-id', tenantId: mockTenantId } as RankEntity); + rankRepository.save.mockResolvedValue({ ...createDto, id: 'new-id', tenantId: mockTenantId } as RankEntity); + + const result = await service.create(mockTenantId, createDto); + + expect(rankRepository.create).toHaveBeenCalledWith({ ...createDto, tenantId: mockTenantId }); + expect(result.name).toBe('Platinum'); + }); + }); + + describe('update', () => { + it('should update an existing rank', async () => { + const updateDto: UpdateRankDto = { name: 'Updated Gold', level: 4 }; + rankRepository.findOne.mockResolvedValue(mockRank); + rankRepository.save.mockResolvedValue({ ...mockRank, ...updateDto }); + + const result = await service.update(mockTenantId, mockRankId, updateDto); + + expect(result.name).toBe('Updated Gold'); + }); + + it('should throw NotFoundException if rank not found', async () => { + rankRepository.findOne.mockResolvedValue(null); + + await expect(service.update(mockTenantId, 'non-existent', { name: 'New' })).rejects.toThrow(NotFoundException); + }); + }); + + describe('remove', () => { + it('should remove a rank', async () => { + rankRepository.findOne.mockResolvedValue(mockRank); + rankRepository.remove.mockResolvedValue(mockRank); + + await service.remove(mockTenantId, mockRankId); + + expect(rankRepository.remove).toHaveBeenCalledWith(mockRank); + }); + }); + + describe('evaluateRank', () => { + it('should return qualified rank for node', async () => { + nodeRepository.findOne.mockResolvedValue(mockNode); + rankRepository.find.mockResolvedValue([mockRank]); + + const result = await service.evaluateRank(mockTenantId, mockNodeId); + + expect(result).toEqual(mockRank); + }); + + it('should throw NotFoundException if node not found', async () => { + nodeRepository.findOne.mockResolvedValue(null); + + await expect(service.evaluateRank(mockTenantId, 'non-existent')).rejects.toThrow(NotFoundException); + }); + + it('should return null if node does not qualify for any rank', async () => { + const lowVolumeNode = { ...mockNode, personalVolume: 100, groupVolume: 500, directReferrals: 1 }; + const highRequirementRank = { ...mockRank, requirements: { personalVolume: 5000 } }; + + nodeRepository.findOne.mockResolvedValue(lowVolumeNode); + rankRepository.find.mockResolvedValue([highRequirementRank]); + + const result = await service.evaluateRank(mockTenantId, mockNodeId); + + expect(result).toBeNull(); + }); + }); + + describe('evaluateAllNodes', () => { + it('should evaluate and update all nodes in structure', async () => { + nodeRepository.find.mockResolvedValue([mockNode]); + nodeRepository.findOne.mockResolvedValue(mockNode); + rankRepository.find.mockResolvedValue([mockRank]); + nodeRepository.save.mockResolvedValue({ ...mockNode, rankId: mockRankId }); + + const result = await service.evaluateAllNodes(mockTenantId, mockStructureId); + + expect(result).toBe(1); + expect(nodeRepository.save).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/mlm/__tests__/structures.controller.spec.ts b/src/modules/mlm/__tests__/structures.controller.spec.ts new file mode 100644 index 0000000..69144b4 --- /dev/null +++ b/src/modules/mlm/__tests__/structures.controller.spec.ts @@ -0,0 +1,134 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { StructuresController } from '../controllers/structures.controller'; +import { StructuresService } from '../services/structures.service'; +import { StructureType } from '../entities/structure.entity'; +import { CreateStructureDto, UpdateStructureDto, StructureFiltersDto } from '../dto/structure.dto'; + +describe('StructuresController', () => { + let controller: StructuresController; + let service: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockStructureId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockStructure = { + id: mockStructureId, + tenantId: mockTenantId, + name: 'Unilevel Network', + description: 'Main compensation plan', + type: StructureType.UNILEVEL, + config: { maxDepth: 10, maxWidth: null }, + levelRates: [{ level: 1, rate: 0.1 }], + matchingRates: [], + isActive: true, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + createdBy: mockUserId, + nodes: [], + ranks: [], + }; + + beforeEach(async () => { + const mockService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [StructuresController], + providers: [ + { + provide: StructuresService, + useValue: mockService, + }, + ], + }).compile(); + + controller = module.get(StructuresController); + service = module.get(StructuresService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return all structures', async () => { + const filters: StructureFiltersDto = {}; + service.findAll.mockResolvedValue([mockStructure]); + + const result = await controller.findAll(mockRequestUser, filters); + + expect(service.findAll).toHaveBeenCalledWith(mockTenantId, filters); + expect(result).toEqual([mockStructure]); + }); + + it('should filter by type', async () => { + const filters: StructureFiltersDto = { type: StructureType.BINARY }; + service.findAll.mockResolvedValue([]); + + await controller.findAll(mockRequestUser, filters); + + expect(service.findAll).toHaveBeenCalledWith(mockTenantId, filters); + }); + }); + + describe('findOne', () => { + it('should return a structure by id', async () => { + service.findOne.mockResolvedValue(mockStructure); + + const result = await controller.findOne(mockRequestUser, mockStructureId); + + expect(service.findOne).toHaveBeenCalledWith(mockTenantId, mockStructureId); + expect(result).toEqual(mockStructure); + }); + }); + + describe('create', () => { + it('should create a new structure', async () => { + const createDto: CreateStructureDto = { + name: 'Binary Network', + type: StructureType.BINARY, + }; + service.create.mockResolvedValue({ ...mockStructure, name: 'Binary Network' }); + + const result = await controller.create(mockRequestUser, createDto); + + expect(service.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + expect(result.name).toBe('Binary Network'); + }); + }); + + describe('update', () => { + it('should update a structure', async () => { + const updateDto: UpdateStructureDto = { name: 'Updated Name' }; + service.update.mockResolvedValue({ ...mockStructure, name: 'Updated Name' }); + + const result = await controller.update(mockRequestUser, mockStructureId, updateDto); + + expect(service.update).toHaveBeenCalledWith(mockTenantId, mockStructureId, updateDto); + expect(result.name).toBe('Updated Name'); + }); + }); + + describe('remove', () => { + it('should remove a structure', async () => { + service.remove.mockResolvedValue(undefined); + + await controller.remove(mockRequestUser, mockStructureId); + + expect(service.remove).toHaveBeenCalledWith(mockTenantId, mockStructureId); + }); + }); +}); diff --git a/src/modules/mlm/__tests__/structures.service.spec.ts b/src/modules/mlm/__tests__/structures.service.spec.ts new file mode 100644 index 0000000..f105bb3 --- /dev/null +++ b/src/modules/mlm/__tests__/structures.service.spec.ts @@ -0,0 +1,203 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, ILike } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { StructuresService } from '../services/structures.service'; +import { StructureEntity, StructureType } from '../entities/structure.entity'; +import { CreateStructureDto, UpdateStructureDto, StructureFiltersDto } from '../dto/structure.dto'; + +describe('StructuresService', () => { + let service: StructuresService; + let repository: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockStructureId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockStructure: StructureEntity = { + id: mockStructureId, + tenantId: mockTenantId, + name: 'Unilevel Network', + description: 'Main compensation plan', + type: StructureType.UNILEVEL, + config: { maxDepth: 10, maxWidth: null }, + levelRates: [ + { level: 1, rate: 0.1 }, + { level: 2, rate: 0.05 }, + ], + matchingRates: [], + isActive: true, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + createdBy: mockUserId, + nodes: [], + ranks: [], + }; + + beforeEach(async () => { + const mockRepository = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StructuresService, + { + provide: getRepositoryToken(StructureEntity), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(StructuresService); + repository = module.get(getRepositoryToken(StructureEntity)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return all structures for tenant', async () => { + repository.find.mockResolvedValue([mockStructure]); + + const result = await service.findAll(mockTenantId); + + expect(repository.find).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId }, + order: { createdAt: 'DESC' }, + }); + expect(result).toEqual([mockStructure]); + }); + + it('should filter by type', async () => { + const filters: StructureFiltersDto = { type: StructureType.BINARY }; + repository.find.mockResolvedValue([]); + + await service.findAll(mockTenantId, filters); + + expect(repository.find).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId, type: StructureType.BINARY }, + order: { createdAt: 'DESC' }, + }); + }); + + it('should filter by isActive', async () => { + const filters: StructureFiltersDto = { isActive: true }; + repository.find.mockResolvedValue([mockStructure]); + + await service.findAll(mockTenantId, filters); + + expect(repository.find).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId, isActive: true }, + order: { createdAt: 'DESC' }, + }); + }); + + it('should filter by search', async () => { + const filters: StructureFiltersDto = { search: 'unilevel' }; + repository.find.mockResolvedValue([mockStructure]); + + await service.findAll(mockTenantId, filters); + + expect(repository.find).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId, name: ILike('%unilevel%') }, + order: { createdAt: 'DESC' }, + }); + }); + }); + + describe('findOne', () => { + it('should return a structure by id', async () => { + repository.findOne.mockResolvedValue(mockStructure); + + const result = await service.findOne(mockTenantId, mockStructureId); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { id: mockStructureId, tenantId: mockTenantId }, + }); + expect(result).toEqual(mockStructure); + }); + + it('should throw NotFoundException if structure not found', async () => { + repository.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'non-existent-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('create', () => { + it('should create a new structure', async () => { + const createDto: CreateStructureDto = { + name: 'Binary Network', + type: StructureType.BINARY, + config: { spillover: 'balanced' }, + levelRates: [{ level: 1, rate: 0.15 }], + }; + + const newStructure = { ...mockStructure, ...createDto, id: 'new-id' } as unknown as StructureEntity; + repository.create.mockReturnValue(newStructure); + repository.save.mockResolvedValue(newStructure); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(repository.create).toHaveBeenCalledWith({ + ...createDto, + tenantId: mockTenantId, + createdBy: mockUserId, + }); + expect(repository.save).toHaveBeenCalled(); + expect(result.name).toBe('Binary Network'); + }); + }); + + describe('update', () => { + it('should update an existing structure', async () => { + const updateDto: UpdateStructureDto = { + name: 'Updated Name', + isActive: false, + }; + repository.findOne.mockResolvedValue(mockStructure); + repository.save.mockResolvedValue({ ...mockStructure, ...updateDto } as unknown as StructureEntity); + + const result = await service.update(mockTenantId, mockStructureId, updateDto); + + expect(repository.save).toHaveBeenCalled(); + expect(result.name).toBe('Updated Name'); + expect(result.isActive).toBe(false); + }); + + it('should throw NotFoundException if structure not found', async () => { + repository.findOne.mockResolvedValue(null); + + await expect( + service.update(mockTenantId, 'non-existent-id', { name: 'New Name' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('remove', () => { + it('should remove a structure', async () => { + repository.findOne.mockResolvedValue(mockStructure); + repository.remove.mockResolvedValue(mockStructure); + + await service.remove(mockTenantId, mockStructureId); + + expect(repository.remove).toHaveBeenCalledWith(mockStructure); + }); + + it('should throw NotFoundException if structure not found', async () => { + repository.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, 'non-existent-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/src/modules/mlm/entities/bonus.entity.ts b/src/modules/mlm/entities/bonus.entity.ts index d78fb9d..b7fda3e 100644 --- a/src/modules/mlm/entities/bonus.entity.ts +++ b/src/modules/mlm/entities/bonus.entity.ts @@ -8,14 +8,9 @@ import { } from 'typeorm'; import { NodeEntity } from './node.entity'; import { RankEntity } from './rank.entity'; -import { CommissionStatus } from './commission.entity'; +import { BonusType, CommissionStatus } from './enums'; -export enum BonusType { - RANK_ACHIEVEMENT = 'rank_achievement', - RANK_MAINTENANCE = 'rank_maintenance', - FAST_START = 'fast_start', - POOL_SHARE = 'pool_share', -} +export { BonusType }; @Entity({ schema: 'mlm', name: 'bonuses' }) export class BonusEntity { diff --git a/src/modules/mlm/entities/commission.entity.ts b/src/modules/mlm/entities/commission.entity.ts index 28e82ff..6b927ca 100644 --- a/src/modules/mlm/entities/commission.entity.ts +++ b/src/modules/mlm/entities/commission.entity.ts @@ -7,21 +7,9 @@ import { JoinColumn, } from 'typeorm'; import { NodeEntity } from './node.entity'; +import { CommissionType, CommissionStatus } from './enums'; -export enum CommissionType { - LEVEL = 'level', - MATCHING = 'matching', - INFINITY = 'infinity', - LEADERSHIP = 'leadership', - POOL = 'pool', -} - -export enum CommissionStatus { - PENDING = 'pending', - APPROVED = 'approved', - PAID = 'paid', - CANCELLED = 'cancelled', -} +export { CommissionType, CommissionStatus }; @Entity({ schema: 'mlm', name: 'commissions' }) export class CommissionEntity { diff --git a/src/modules/mlm/entities/enums.ts b/src/modules/mlm/entities/enums.ts new file mode 100644 index 0000000..883f791 --- /dev/null +++ b/src/modules/mlm/entities/enums.ts @@ -0,0 +1,37 @@ +// MLM Module Enums - Extracted to avoid circular dependencies + +export enum NodeStatus { + PENDING = 'pending', + ACTIVE = 'active', + INACTIVE = 'inactive', + SUSPENDED = 'suspended', +} + +export enum CommissionType { + LEVEL = 'level', + MATCHING = 'matching', + INFINITY = 'infinity', + LEADERSHIP = 'leadership', + POOL = 'pool', +} + +export enum CommissionStatus { + PENDING = 'pending', + APPROVED = 'approved', + PAID = 'paid', + CANCELLED = 'cancelled', +} + +export enum BonusType { + RANK_ACHIEVEMENT = 'rank_achievement', + RANK_MAINTENANCE = 'rank_maintenance', + FAST_START = 'fast_start', + POOL_SHARE = 'pool_share', +} + +export enum StructureType { + UNILEVEL = 'unilevel', + BINARY = 'binary', + MATRIX = 'matrix', + HYBRID = 'hybrid', +} diff --git a/src/modules/mlm/entities/node.entity.ts b/src/modules/mlm/entities/node.entity.ts index 570dd00..866942c 100644 --- a/src/modules/mlm/entities/node.entity.ts +++ b/src/modules/mlm/entities/node.entity.ts @@ -13,13 +13,9 @@ import { RankEntity } from './rank.entity'; import { CommissionEntity } from './commission.entity'; import { BonusEntity } from './bonus.entity'; import { RankHistoryEntity } from './rank-history.entity'; +import { NodeStatus } from './enums'; -export enum NodeStatus { - PENDING = 'pending', - ACTIVE = 'active', - INACTIVE = 'inactive', - SUSPENDED = 'suspended', -} +export { NodeStatus }; @Entity({ schema: 'mlm', name: 'nodes' }) export class NodeEntity { diff --git a/src/modules/mlm/entities/structure.entity.ts b/src/modules/mlm/entities/structure.entity.ts index 76e73cd..9af64fb 100644 --- a/src/modules/mlm/entities/structure.entity.ts +++ b/src/modules/mlm/entities/structure.entity.ts @@ -8,13 +8,9 @@ import { } from 'typeorm'; import { NodeEntity } from './node.entity'; import { RankEntity } from './rank.entity'; +import { StructureType } from './enums'; -export enum StructureType { - UNILEVEL = 'unilevel', - BINARY = 'binary', - MATRIX = 'matrix', - HYBRID = 'hybrid', -} +export { StructureType }; export interface UnilevelConfig { maxWidth?: number | null;