diff --git a/src/modules/commissions/__tests__/assignments.service.spec.ts b/src/modules/commissions/__tests__/assignments.service.spec.ts new file mode 100644 index 0000000..9f99837 --- /dev/null +++ b/src/modules/commissions/__tests__/assignments.service.spec.ts @@ -0,0 +1,328 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; + +import { AssignmentsService } from '../services/assignments.service'; +import { CommissionAssignmentEntity, CommissionSchemeEntity, SchemeType, AppliesTo } from '../entities'; + +describe('AssignmentsService', () => { + let service: AssignmentsService; + let assignmentRepo: jest.Mocked>; + let schemeRepo: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockSchemeId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockScheme: Partial = { + id: mockSchemeId, + tenantId: mockTenantId, + name: 'Standard Commission', + type: SchemeType.PERCENTAGE, + rate: 10, + isActive: true, + deletedAt: null, + }; + + const mockAssignment: Partial = { + id: 'assignment-001', + tenantId: mockTenantId, + userId: mockUserId, + schemeId: mockSchemeId, + startsAt: new Date('2026-01-01'), + endsAt: null, + customRate: null, + isActive: true, + createdAt: new Date('2026-01-01'), + createdBy: mockUserId, + scheme: mockScheme as CommissionSchemeEntity, + }; + + beforeEach(async () => { + const mockAssignmentRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + remove: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockSchemeRepo = { + findOne: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssignmentsService, + { provide: getRepositoryToken(CommissionAssignmentEntity), useValue: mockAssignmentRepo }, + { provide: getRepositoryToken(CommissionSchemeEntity), useValue: mockSchemeRepo }, + ], + }).compile(); + + service = module.get(AssignmentsService); + assignmentRepo = module.get(getRepositoryToken(CommissionAssignmentEntity)); + schemeRepo = module.get(getRepositoryToken(CommissionSchemeEntity)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create an assignment successfully', async () => { + schemeRepo.findOne.mockResolvedValue(mockScheme as CommissionSchemeEntity); + assignmentRepo.create.mockReturnValue(mockAssignment as CommissionAssignmentEntity); + assignmentRepo.save.mockResolvedValue(mockAssignment as CommissionAssignmentEntity); + + const dto = { + userId: mockUserId, + schemeId: mockSchemeId, + }; + + const result = await service.create(mockTenantId, mockUserId, dto); + + expect(result.userId).toBe(mockUserId); + expect(result.schemeId).toBe(mockSchemeId); + expect(result.isActive).toBe(true); + expect(schemeRepo.findOne).toHaveBeenCalled(); + expect(assignmentRepo.create).toHaveBeenCalled(); + expect(assignmentRepo.save).toHaveBeenCalled(); + }); + + it('should create an assignment with custom rate', async () => { + const customRateAssignment = { ...mockAssignment, customRate: 15 }; + schemeRepo.findOne.mockResolvedValue(mockScheme as CommissionSchemeEntity); + assignmentRepo.create.mockReturnValue(customRateAssignment as CommissionAssignmentEntity); + assignmentRepo.save.mockResolvedValue(customRateAssignment as CommissionAssignmentEntity); + + const dto = { + userId: mockUserId, + schemeId: mockSchemeId, + customRate: 15, + }; + + const result = await service.create(mockTenantId, mockUserId, dto); + + expect(result.customRate).toBe(15); + }); + + it('should create an assignment with date range', async () => { + const datedAssignment = { + ...mockAssignment, + startsAt: new Date('2026-01-01'), + endsAt: new Date('2026-12-31'), + }; + schemeRepo.findOne.mockResolvedValue(mockScheme as CommissionSchemeEntity); + assignmentRepo.create.mockReturnValue(datedAssignment as CommissionAssignmentEntity); + assignmentRepo.save.mockResolvedValue(datedAssignment as CommissionAssignmentEntity); + + const dto = { + userId: mockUserId, + schemeId: mockSchemeId, + startsAt: '2026-01-01', + endsAt: '2026-12-31', + }; + + const result = await service.create(mockTenantId, mockUserId, dto); + + expect(result.startsAt).toBeDefined(); + expect(result.endsAt).toBeDefined(); + }); + + it('should throw NotFoundException if scheme not found', async () => { + schemeRepo.findOne.mockResolvedValue(null); + + const dto = { + userId: mockUserId, + schemeId: 'invalid-scheme-id', + }; + + await expect(service.create(mockTenantId, mockUserId, dto)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('findAll', () => { + it('should return paginated assignments', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockAssignment], 1]), + }; + + assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, { page: 1, limit: 10 }); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + }); + + it('should filter by userId', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockAssignment], 1]), + }; + + assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { userId: mockUserId }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'a.user_id = :userId', + { userId: mockUserId }, + ); + }); + + it('should filter by schemeId', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockAssignment], 1]), + }; + + assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { schemeId: mockSchemeId }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'a.scheme_id = :schemeId', + { schemeId: mockSchemeId }, + ); + }); + }); + + describe('findOne', () => { + it('should return an assignment by id', async () => { + assignmentRepo.findOne.mockResolvedValue(mockAssignment as CommissionAssignmentEntity); + + const result = await service.findOne(mockTenantId, 'assignment-001'); + + expect(result.id).toBe('assignment-001'); + expect(result.userId).toBe(mockUserId); + }); + + it('should throw NotFoundException if assignment not found', async () => { + assignmentRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'invalid-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('update', () => { + it('should update an assignment successfully', async () => { + const updatedAssignment = { ...mockAssignment, customRate: 20 }; + assignmentRepo.findOne.mockResolvedValue(mockAssignment as CommissionAssignmentEntity); + assignmentRepo.save.mockResolvedValue(updatedAssignment as CommissionAssignmentEntity); + + const result = await service.update(mockTenantId, 'assignment-001', { customRate: 20 }); + + expect(result.customRate).toBe(20); + }); + + it('should update dates', async () => { + const updatedAssignment = { + ...mockAssignment, + endsAt: new Date('2026-06-30'), + }; + assignmentRepo.findOne.mockResolvedValue(mockAssignment as CommissionAssignmentEntity); + assignmentRepo.save.mockResolvedValue(updatedAssignment as CommissionAssignmentEntity); + + const result = await service.update(mockTenantId, 'assignment-001', { + endsAt: '2026-06-30', + }); + + expect(result.endsAt).toBeDefined(); + }); + + it('should throw NotFoundException if assignment not found', async () => { + assignmentRepo.findOne.mockResolvedValue(null); + + await expect( + service.update(mockTenantId, 'invalid-id', { customRate: 20 }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('remove', () => { + it('should delete an assignment', async () => { + assignmentRepo.findOne.mockResolvedValue(mockAssignment as CommissionAssignmentEntity); + + await service.remove(mockTenantId, 'assignment-001'); + + expect(assignmentRepo.remove).toHaveBeenCalledWith(mockAssignment); + }); + + it('should throw NotFoundException if assignment not found', async () => { + assignmentRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, 'invalid-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('findActiveForUser', () => { + it('should return active assignments for a user', async () => { + const now = new Date(); + const activeAssignment = { + ...mockAssignment, + startsAt: new Date(now.getTime() - 86400000), // yesterday + endsAt: new Date(now.getTime() + 86400000), // tomorrow + }; + assignmentRepo.find.mockResolvedValue([activeAssignment as CommissionAssignmentEntity]); + + const result = await service.findActiveForUser(mockTenantId, mockUserId); + + expect(result).toHaveLength(1); + expect(result[0].userId).toBe(mockUserId); + }); + + it('should filter out expired assignments', async () => { + const now = new Date(); + const expiredAssignment = { + ...mockAssignment, + startsAt: new Date(now.getTime() - 172800000), // 2 days ago + endsAt: new Date(now.getTime() - 86400000), // yesterday + }; + assignmentRepo.find.mockResolvedValue([expiredAssignment as CommissionAssignmentEntity]); + + const result = await service.findActiveForUser(mockTenantId, mockUserId); + + expect(result).toHaveLength(0); + }); + + it('should include assignments without end date', async () => { + const now = new Date(); + const openEndedAssignment = { + ...mockAssignment, + startsAt: new Date(now.getTime() - 86400000), // yesterday + endsAt: null, + }; + assignmentRepo.find.mockResolvedValue([openEndedAssignment as CommissionAssignmentEntity]); + + const result = await service.findActiveForUser(mockTenantId, mockUserId); + + expect(result).toHaveLength(1); + }); + }); +}); diff --git a/src/modules/commissions/__tests__/commissions-dashboard.service.spec.ts b/src/modules/commissions/__tests__/commissions-dashboard.service.spec.ts new file mode 100644 index 0000000..7d3a6ba --- /dev/null +++ b/src/modules/commissions/__tests__/commissions-dashboard.service.spec.ts @@ -0,0 +1,338 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; + +import { CommissionsDashboardService } from '../services/commissions-dashboard.service'; +import { + CommissionSchemeEntity, + CommissionAssignmentEntity, + CommissionEntryEntity, + CommissionPeriodEntity, + EntryStatus, + PeriodStatus, +} from '../entities'; + +describe('CommissionsDashboardService', () => { + let service: CommissionsDashboardService; + let schemeRepo: jest.Mocked>; + let assignmentRepo: jest.Mocked>; + let entryRepo: jest.Mocked>; + let periodRepo: jest.Mocked>; + let dataSource: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + beforeEach(async () => { + const mockSchemeRepo = { + count: jest.fn(), + }; + + const mockAssignmentRepo = { + count: jest.fn(), + }; + + const mockEntryRepo = { + createQueryBuilder: jest.fn(), + }; + + const mockPeriodRepo = { + findOne: jest.fn(), + }; + + const mockDataSource = { + query: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CommissionsDashboardService, + { provide: getRepositoryToken(CommissionSchemeEntity), useValue: mockSchemeRepo }, + { provide: getRepositoryToken(CommissionAssignmentEntity), useValue: mockAssignmentRepo }, + { provide: getRepositoryToken(CommissionEntryEntity), useValue: mockEntryRepo }, + { provide: getRepositoryToken(CommissionPeriodEntity), useValue: mockPeriodRepo }, + { provide: DataSource, useValue: mockDataSource }, + ], + }).compile(); + + service = module.get(CommissionsDashboardService); + schemeRepo = module.get(getRepositoryToken(CommissionSchemeEntity)); + assignmentRepo = module.get(getRepositoryToken(CommissionAssignmentEntity)); + entryRepo = module.get(getRepositoryToken(CommissionEntryEntity)); + periodRepo = module.get(getRepositoryToken(CommissionPeriodEntity)); + dataSource = module.get(DataSource); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getDashboardSummary', () => { + it('should return complete dashboard summary', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { status: EntryStatus.PENDING, count: 10, total: 1000 }, + { status: EntryStatus.APPROVED, count: 5, total: 500 }, + { status: EntryStatus.PAID, count: 15, total: 1500 }, + ]), + }; + + const mockPeriod = { + id: 'period-001', + name: 'January 2026', + status: PeriodStatus.OPEN, + }; + + schemeRepo.count.mockResolvedValue(3); + assignmentRepo.count.mockResolvedValue(10); + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + periodRepo.findOne.mockResolvedValue(mockPeriod as CommissionPeriodEntity); + + const result = await service.getDashboardSummary(mockTenantId); + + expect(result.totalSchemes).toBe(3); + expect(result.totalActiveAssignments).toBe(10); + expect(result.pendingCommissions).toBe(10); + expect(result.pendingAmount).toBe(1000); + expect(result.approvedAmount).toBe(500); + expect(result.paidAmount).toBe(1500); + expect(result.currentPeriod).toBe('January 2026'); + expect(result.currency).toBe('USD'); + }); + + it('should handle no entries', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + + schemeRepo.count.mockResolvedValue(0); + assignmentRepo.count.mockResolvedValue(0); + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + periodRepo.findOne.mockResolvedValue(null); + + const result = await service.getDashboardSummary(mockTenantId); + + expect(result.totalSchemes).toBe(0); + expect(result.pendingCommissions).toBe(0); + expect(result.pendingAmount).toBe(0); + expect(result.currentPeriod).toBeNull(); + }); + }); + + describe('getUserEarnings', () => { + it('should return user earnings summary', async () => { + dataSource.query.mockResolvedValue([{ + total_pending: 500, + total_approved: 300, + total_paid: 1200, + total_entries: 20, + currency: 'USD', + }]); + + const result = await service.getUserEarnings(mockTenantId, mockUserId); + + expect(result.totalPending).toBe(500); + expect(result.totalApproved).toBe(300); + expect(result.totalPaid).toBe(1200); + expect(result.totalEntries).toBe(20); + expect(result.currency).toBe('USD'); + }); + + it('should return zero values when no earnings', async () => { + dataSource.query.mockResolvedValue([]); + + const result = await service.getUserEarnings(mockTenantId, mockUserId); + + expect(result.totalPending).toBe(0); + expect(result.totalApproved).toBe(0); + expect(result.totalPaid).toBe(0); + expect(result.totalEntries).toBe(0); + }); + + it('should filter by date range', async () => { + dataSource.query.mockResolvedValue([{ + total_pending: 100, + total_approved: 200, + total_paid: 300, + total_entries: 5, + currency: 'USD', + }]); + + const startDate = new Date('2026-01-01'); + const endDate = new Date('2026-01-31'); + + await service.getUserEarnings(mockTenantId, mockUserId, startDate, endDate); + + expect(dataSource.query).toHaveBeenCalledWith( + `SELECT * FROM commissions.get_user_earnings($1, $2, $3, $4)`, + [mockUserId, mockTenantId, startDate, endDate], + ); + }); + }); + + describe('getEntriesByStatus', () => { + it('should return entries grouped by status', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { status: EntryStatus.PENDING, count: 10, totalAmount: 1000 }, + { status: EntryStatus.APPROVED, count: 5, totalAmount: 500 }, + { status: EntryStatus.PAID, count: 15, totalAmount: 1500 }, + ]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getEntriesByStatus(mockTenantId); + + expect(result).toHaveLength(3); + expect(result[0].status).toBe(EntryStatus.PENDING); + expect(result[0].count).toBe(10); + expect(result[0].totalAmount).toBe(1000); + expect(result[0].percentage).toBe(33); // 10/30 ≈ 33% + }); + + it('should handle empty results', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getEntriesByStatus(mockTenantId); + + expect(result).toHaveLength(0); + }); + }); + + describe('getEntriesByScheme', () => { + it('should return entries grouped by scheme', async () => { + const mockQueryBuilder = { + innerJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + addGroupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { schemeId: 'scheme-001', schemeName: 'Standard', count: 20, totalAmount: 2000 }, + { schemeId: 'scheme-002', schemeName: 'Premium', count: 10, totalAmount: 1500 }, + ]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getEntriesByScheme(mockTenantId); + + expect(result).toHaveLength(2); + expect(result[0].schemeId).toBe('scheme-001'); + expect(result[0].schemeName).toBe('Standard'); + expect(result[0].count).toBe(20); + expect(result[0].totalAmount).toBe(2000); + expect(result[0].percentage).toBe(67); // 20/30 ≈ 67% + }); + + it('should handle empty results', async () => { + const mockQueryBuilder = { + innerJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + addGroupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getEntriesByScheme(mockTenantId); + + expect(result).toHaveLength(0); + }); + }); + + describe('getEntriesByUser', () => { + it('should return entries grouped by user', async () => { + dataSource.query.mockResolvedValue([ + { userId: 'user-001', userName: 'John Doe', count: 15, totalAmount: 1500 }, + { userId: 'user-002', userName: 'Jane Smith', count: 10, totalAmount: 1000 }, + { userId: 'user-003', userName: 'Bob Wilson', count: 5, totalAmount: 500 }, + ]); + + const result = await service.getEntriesByUser(mockTenantId); + + expect(result).toHaveLength(3); + expect(result[0].userId).toBe('user-001'); + expect(result[0].userName).toBe('John Doe'); + expect(result[0].count).toBe(15); + expect(result[0].totalAmount).toBe(1500); + expect(result[0].percentage).toBe(50); // 15/30 = 50% + }); + + it('should handle empty results', async () => { + dataSource.query.mockResolvedValue([]); + + const result = await service.getEntriesByUser(mockTenantId); + + expect(result).toHaveLength(0); + }); + }); + + describe('getTopEarners', () => { + it('should return top earners with default limit', async () => { + const allUsers = Array.from({ length: 15 }, (_, i) => ({ + userId: `user-${i}`, + userName: `User ${i}`, + count: 15 - i, + totalAmount: (15 - i) * 100, + })); + + dataSource.query.mockResolvedValue(allUsers); + + const result = await service.getTopEarners(mockTenantId); + + expect(result).toHaveLength(10); // default limit + expect(result[0].userId).toBe('user-0'); + }); + + it('should return top earners with custom limit', async () => { + const allUsers = Array.from({ length: 15 }, (_, i) => ({ + userId: `user-${i}`, + userName: `User ${i}`, + count: 15 - i, + totalAmount: (15 - i) * 100, + })); + + dataSource.query.mockResolvedValue(allUsers); + + const result = await service.getTopEarners(mockTenantId, 5); + + expect(result).toHaveLength(5); + }); + + it('should handle fewer users than limit', async () => { + dataSource.query.mockResolvedValue([ + { userId: 'user-001', userName: 'John Doe', count: 5, totalAmount: 500 }, + ]); + + const result = await service.getTopEarners(mockTenantId, 10); + + expect(result).toHaveLength(1); + }); + }); +}); diff --git a/src/modules/commissions/__tests__/entries.service.spec.ts b/src/modules/commissions/__tests__/entries.service.spec.ts new file mode 100644 index 0000000..dc39acb --- /dev/null +++ b/src/modules/commissions/__tests__/entries.service.spec.ts @@ -0,0 +1,455 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; + +import { EntriesService } from '../services/entries.service'; +import { CommissionEntryEntity, EntryStatus } from '../entities'; + +describe('EntriesService', () => { + let service: EntriesService; + let entryRepo: jest.Mocked>; + let dataSource: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockSchemeId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockEntry: Partial = { + id: 'entry-001', + tenantId: mockTenantId, + userId: mockUserId, + schemeId: mockSchemeId, + assignmentId: null, + referenceType: 'sale', + referenceId: 'sale-001', + baseAmount: 1000, + rateApplied: 10, + commissionAmount: 100, + currency: 'USD', + status: EntryStatus.PENDING, + periodId: null, + paidAt: null, + paymentReference: null, + notes: null, + metadata: {}, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + approvedBy: null, + approvedAt: null, + }; + + beforeEach(async () => { + const mockEntryRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockDataSource = { + query: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EntriesService, + { provide: getRepositoryToken(CommissionEntryEntity), useValue: mockEntryRepo }, + { provide: DataSource, useValue: mockDataSource }, + ], + }).compile(); + + service = module.get(EntriesService); + entryRepo = module.get(getRepositoryToken(CommissionEntryEntity)); + dataSource = module.get(DataSource); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const createMockEntry = (overrides: Partial = {}): CommissionEntryEntity => ({ + id: 'entry-001', + tenantId: mockTenantId, + userId: mockUserId, + schemeId: mockSchemeId, + assignmentId: null as any, + referenceType: 'sale', + referenceId: 'sale-001', + baseAmount: 1000, + rateApplied: 10, + commissionAmount: 100, + currency: 'USD', + status: EntryStatus.PENDING, + periodId: null as any, + paidAt: null as any, + paymentReference: null as any, + notes: null as any, + metadata: {}, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + approvedBy: null as any, + approvedAt: null as any, + scheme: null as any, + assignment: null as any, + period: null as any, + ...overrides, + } as CommissionEntryEntity); + + describe('create', () => { + it('should create an entry with calculated commission', async () => { + dataSource.query.mockResolvedValue([{ rate_applied: 10, commission_amount: 100 }]); + entryRepo.create.mockReturnValue(mockEntry as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(mockEntry as CommissionEntryEntity); + + const dto = { + userId: mockUserId, + schemeId: mockSchemeId, + referenceType: 'sale', + referenceId: 'sale-001', + baseAmount: 1000, + }; + + const result = await service.create(mockTenantId, mockUserId, dto); + + expect(result.baseAmount).toBe(1000); + expect(result.rateApplied).toBe(10); + expect(result.commissionAmount).toBe(100); + expect(dataSource.query).toHaveBeenCalledWith( + `SELECT * FROM commissions.calculate_commission($1, $2, $3, $4)`, + [dto.schemeId, dto.userId, dto.baseAmount, mockTenantId], + ); + }); + + it('should throw BadRequestException when commission is zero', async () => { + dataSource.query.mockResolvedValue([{ rate_applied: 0, commission_amount: 0 }]); + + const dto = { + userId: mockUserId, + schemeId: mockSchemeId, + referenceType: 'sale', + referenceId: 'sale-001', + baseAmount: 1000, + }; + + await expect(service.create(mockTenantId, mockUserId, dto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should create an entry with metadata', async () => { + const entryWithMetadata = { + ...mockEntry, + metadata: { source: 'web', campaign: 'summer2026' }, + }; + dataSource.query.mockResolvedValue([{ rate_applied: 10, commission_amount: 100 }]); + entryRepo.create.mockReturnValue(entryWithMetadata as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(entryWithMetadata as CommissionEntryEntity); + + const dto = { + userId: mockUserId, + schemeId: mockSchemeId, + referenceType: 'sale', + referenceId: 'sale-001', + baseAmount: 1000, + metadata: { source: 'web', campaign: 'summer2026' }, + }; + + const result = await service.create(mockTenantId, mockUserId, dto); + + expect(result.metadata).toEqual({ source: 'web', campaign: 'summer2026' }); + }); + }); + + describe('findAll', () => { + it('should return paginated entries', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockEntry], 1]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, { page: 1, limit: 10 }); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('should filter by status', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockEntry], 1]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { status: EntryStatus.PENDING }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'e.status = :status', + { status: EntryStatus.PENDING }, + ); + }); + + it('should filter by userId', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockEntry], 1]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { userId: mockUserId }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'e.user_id = :userId', + { userId: mockUserId }, + ); + }); + + it('should filter by referenceType', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockEntry], 1]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { referenceType: 'sale' }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'e.reference_type = :referenceType', + { referenceType: 'sale' }, + ); + }); + }); + + describe('findOne', () => { + it('should return an entry by id', async () => { + entryRepo.findOne.mockResolvedValue(mockEntry as CommissionEntryEntity); + + const result = await service.findOne(mockTenantId, 'entry-001'); + + expect(result.id).toBe('entry-001'); + expect(result.commissionAmount).toBe(100); + }); + + it('should throw NotFoundException if entry not found', async () => { + entryRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'invalid-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('update', () => { + it('should update an entry', async () => { + const updatedEntry = { ...mockEntry, notes: 'Updated notes' }; + entryRepo.findOne.mockResolvedValue(mockEntry as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(updatedEntry as CommissionEntryEntity); + + const result = await service.update(mockTenantId, 'entry-001', { notes: 'Updated notes' }); + + expect(result.notes).toBe('Updated notes'); + }); + + it('should throw NotFoundException if entry not found', async () => { + entryRepo.findOne.mockResolvedValue(null); + + await expect( + service.update(mockTenantId, 'invalid-id', { notes: 'Test' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('approve', () => { + it('should approve a pending entry', async () => { + const approvedEntry = { + ...mockEntry, + status: EntryStatus.APPROVED, + approvedBy: mockUserId, + approvedAt: new Date(), + }; + entryRepo.findOne.mockResolvedValue(mockEntry as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(approvedEntry as CommissionEntryEntity); + + const result = await service.approve(mockTenantId, 'entry-001', mockUserId, {}); + + expect(result.status).toBe(EntryStatus.APPROVED); + expect(result.approvedBy).toBe(mockUserId); + expect(result.approvedAt).toBeDefined(); + }); + + it('should throw BadRequestException if entry is not pending', async () => { + const approvedEntry = { ...mockEntry, status: EntryStatus.APPROVED }; + entryRepo.findOne.mockResolvedValue(approvedEntry as CommissionEntryEntity); + + await expect( + service.approve(mockTenantId, 'entry-001', mockUserId, {}), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if entry not found', async () => { + entryRepo.findOne.mockResolvedValue(null); + + await expect( + service.approve(mockTenantId, 'invalid-id', mockUserId, {}), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('reject', () => { + it('should reject a pending entry', async () => { + const pendingEntry = createMockEntry({ status: EntryStatus.PENDING }); + const rejectedEntry = createMockEntry({ + status: EntryStatus.REJECTED, + approvedBy: mockUserId, + approvedAt: new Date(), + notes: 'Invalid sale', + }); + entryRepo.findOne.mockResolvedValue(pendingEntry); + entryRepo.save.mockResolvedValue(rejectedEntry); + + const result = await service.reject(mockTenantId, 'entry-001', mockUserId, { + notes: 'Invalid sale', + }); + + expect(result.status).toBe(EntryStatus.REJECTED); + expect(result.notes).toBe('Invalid sale'); + }); + + it('should throw BadRequestException if entry is not pending', async () => { + const approvedEntry = createMockEntry({ status: EntryStatus.APPROVED }); + entryRepo.findOne.mockResolvedValue(approvedEntry); + + await expect( + service.reject(mockTenantId, 'entry-001', mockUserId, { notes: 'Reason' }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('calculateCommission', () => { + it('should calculate commission without creating entry', async () => { + dataSource.query.mockResolvedValue([{ rate_applied: 10, commission_amount: 100 }]); + + const result = await service.calculateCommission(mockTenantId, { + schemeId: mockSchemeId, + userId: mockUserId, + amount: 1000, + }); + + expect(result.rateApplied).toBe(10); + expect(result.commissionAmount).toBe(100); + }); + + it('should return zero for invalid calculation', async () => { + dataSource.query.mockResolvedValue([]); + + const result = await service.calculateCommission(mockTenantId, { + schemeId: mockSchemeId, + userId: mockUserId, + amount: 1000, + }); + + expect(result.rateApplied).toBe(0); + expect(result.commissionAmount).toBe(0); + }); + }); + + describe('markAsPaid', () => { + it('should mark an approved entry as paid', async () => { + const approvedEntry = { ...mockEntry, status: EntryStatus.APPROVED }; + const paidEntry = { + ...approvedEntry, + status: EntryStatus.PAID, + paidAt: new Date(), + paymentReference: 'PAY-001', + }; + entryRepo.findOne.mockResolvedValue(approvedEntry as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(paidEntry as CommissionEntryEntity); + + const result = await service.markAsPaid(mockTenantId, 'entry-001', 'PAY-001'); + + expect(result.status).toBe(EntryStatus.PAID); + expect(result.paymentReference).toBe('PAY-001'); + expect(result.paidAt).toBeDefined(); + }); + + it('should throw BadRequestException if entry is not approved', async () => { + entryRepo.findOne.mockResolvedValue(mockEntry as CommissionEntryEntity); + + await expect( + service.markAsPaid(mockTenantId, 'entry-001', 'PAY-001'), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if entry not found', async () => { + entryRepo.findOne.mockResolvedValue(null); + + await expect( + service.markAsPaid(mockTenantId, 'invalid-id', 'PAY-001'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('bulkApprove', () => { + it('should bulk approve multiple entries', async () => { + const mockQueryBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 3 }), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.bulkApprove( + mockTenantId, + ['entry-001', 'entry-002', 'entry-003'], + mockUserId, + ); + + expect(result).toBe(3); + }); + + it('should return zero when no entries match', async () => { + const mockQueryBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 0 }), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.bulkApprove(mockTenantId, ['invalid-id'], mockUserId); + + expect(result).toBe(0); + }); + }); +}); diff --git a/src/modules/commissions/__tests__/periods.service.spec.ts b/src/modules/commissions/__tests__/periods.service.spec.ts new file mode 100644 index 0000000..0580492 --- /dev/null +++ b/src/modules/commissions/__tests__/periods.service.spec.ts @@ -0,0 +1,382 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; + +import { PeriodsService } from '../services/periods.service'; +import { CommissionPeriodEntity, PeriodStatus, CommissionEntryEntity, EntryStatus } from '../entities'; + +describe('PeriodsService', () => { + let service: PeriodsService; + let periodRepo: jest.Mocked>; + let entryRepo: jest.Mocked>; + let dataSource: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockPeriod: Partial = { + id: 'period-001', + tenantId: mockTenantId, + name: 'January 2026', + startsAt: new Date('2026-01-01'), + endsAt: new Date('2026-01-31'), + totalEntries: 0, + totalAmount: 0, + currency: 'USD', + status: PeriodStatus.OPEN, + closedAt: null, + closedBy: null, + paidAt: null, + paidBy: null, + paymentReference: null, + paymentNotes: null, + createdAt: new Date('2026-01-01'), + createdBy: mockUserId, + }; + + beforeEach(async () => { + const mockPeriodRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + remove: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockEntryRepo = { + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockDataSource = { + query: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PeriodsService, + { provide: getRepositoryToken(CommissionPeriodEntity), useValue: mockPeriodRepo }, + { provide: getRepositoryToken(CommissionEntryEntity), useValue: mockEntryRepo }, + { provide: DataSource, useValue: mockDataSource }, + ], + }).compile(); + + service = module.get(PeriodsService); + periodRepo = module.get(getRepositoryToken(CommissionPeriodEntity)); + entryRepo = module.get(getRepositoryToken(CommissionEntryEntity)); + dataSource = module.get(DataSource); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create a period successfully', async () => { + periodRepo.create.mockReturnValue(mockPeriod as CommissionPeriodEntity); + periodRepo.save.mockResolvedValue(mockPeriod as CommissionPeriodEntity); + + const dto = { + name: 'January 2026', + startsAt: '2026-01-01', + endsAt: '2026-01-31', + }; + + const result = await service.create(mockTenantId, mockUserId, dto); + + expect(result.name).toBe('January 2026'); + expect(result.status).toBe(PeriodStatus.OPEN); + expect(periodRepo.create).toHaveBeenCalled(); + expect(periodRepo.save).toHaveBeenCalled(); + }); + + it('should create a period with custom currency', async () => { + const eurPeriod = { ...mockPeriod, currency: 'EUR' }; + periodRepo.create.mockReturnValue(eurPeriod as CommissionPeriodEntity); + periodRepo.save.mockResolvedValue(eurPeriod as CommissionPeriodEntity); + + const dto = { + name: 'January 2026', + startsAt: '2026-01-01', + endsAt: '2026-01-31', + currency: 'EUR', + }; + + const result = await service.create(mockTenantId, mockUserId, dto); + + expect(result.currency).toBe('EUR'); + }); + }); + + describe('findAll', () => { + it('should return paginated periods', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockPeriod], 1]), + }; + + periodRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, { page: 1, limit: 10 }); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('should filter by status', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockPeriod], 1]), + }; + + periodRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { status: PeriodStatus.OPEN }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'p.status = :status', + { status: PeriodStatus.OPEN }, + ); + }); + }); + + describe('findOne', () => { + it('should return a period by id', async () => { + periodRepo.findOne.mockResolvedValue(mockPeriod as CommissionPeriodEntity); + + const result = await service.findOne(mockTenantId, 'period-001'); + + expect(result.id).toBe('period-001'); + expect(result.name).toBe('January 2026'); + }); + + it('should throw NotFoundException if period not found', async () => { + periodRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'invalid-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('update', () => { + it('should update an open period', async () => { + const updatedPeriod = { ...mockPeriod, name: 'Updated Period' }; + periodRepo.findOne.mockResolvedValue(mockPeriod as CommissionPeriodEntity); + periodRepo.save.mockResolvedValue(updatedPeriod as CommissionPeriodEntity); + + const result = await service.update(mockTenantId, 'period-001', { name: 'Updated Period' }); + + expect(result.name).toBe('Updated Period'); + }); + + it('should throw BadRequestException for non-open period', async () => { + const closedPeriod = { ...mockPeriod, status: PeriodStatus.CLOSED }; + periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity); + + await expect( + service.update(mockTenantId, 'period-001', { name: 'Test' }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if period not found', async () => { + periodRepo.findOne.mockResolvedValue(null); + + await expect( + service.update(mockTenantId, 'invalid-id', { name: 'Test' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('close', () => { + it('should close an open period', async () => { + const closedPeriod = { + ...mockPeriod, + status: PeriodStatus.CLOSED, + closedAt: new Date(), + closedBy: mockUserId, + }; + periodRepo.findOne + .mockResolvedValueOnce(mockPeriod as CommissionPeriodEntity) + .mockResolvedValueOnce(closedPeriod as CommissionPeriodEntity); + dataSource.query.mockResolvedValue([]); + + const result = await service.close(mockTenantId, 'period-001', mockUserId, {}); + + expect(result.status).toBe(PeriodStatus.CLOSED); + expect(dataSource.query).toHaveBeenCalledWith( + `SELECT commissions.close_period($1, $2)`, + ['period-001', mockUserId], + ); + }); + + it('should throw BadRequestException for non-open period', async () => { + const closedPeriod = { ...mockPeriod, status: PeriodStatus.CLOSED }; + periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity); + + await expect( + service.close(mockTenantId, 'period-001', mockUserId, {}), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if period not found', async () => { + periodRepo.findOne.mockResolvedValue(null); + + await expect( + service.close(mockTenantId, 'invalid-id', mockUserId, {}), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('markAsPaid', () => { + it('should mark a closed period as paid', async () => { + const closedPeriod = { ...mockPeriod, status: PeriodStatus.CLOSED }; + const paidPeriod = { + ...closedPeriod, + status: PeriodStatus.PAID, + paidAt: new Date(), + paidBy: mockUserId, + paymentReference: 'PAY-001', + }; + + const mockQueryBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 5 }), + }; + + periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity); + periodRepo.save.mockResolvedValue(paidPeriod as CommissionPeriodEntity); + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.markAsPaid(mockTenantId, 'period-001', mockUserId, { + paymentReference: 'PAY-001', + }); + + expect(result.status).toBe(PeriodStatus.PAID); + expect(result.paymentReference).toBe('PAY-001'); + }); + + it('should throw BadRequestException for open period', async () => { + periodRepo.findOne.mockResolvedValue(mockPeriod as CommissionPeriodEntity); + + await expect( + service.markAsPaid(mockTenantId, 'period-001', mockUserId, {}), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if period not found', async () => { + periodRepo.findOne.mockResolvedValue(null); + + await expect( + service.markAsPaid(mockTenantId, 'invalid-id', mockUserId, {}), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('remove', () => { + it('should delete an open period without entries', async () => { + periodRepo.findOne.mockResolvedValue(mockPeriod as CommissionPeriodEntity); + entryRepo.count.mockResolvedValue(0); + + await service.remove(mockTenantId, 'period-001'); + + expect(periodRepo.remove).toHaveBeenCalledWith(mockPeriod); + }); + + it('should throw BadRequestException for non-open period', async () => { + const closedPeriod = { ...mockPeriod, status: PeriodStatus.CLOSED }; + periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity); + + await expect(service.remove(mockTenantId, 'period-001')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException if period has entries', async () => { + periodRepo.findOne.mockResolvedValue(mockPeriod as CommissionPeriodEntity); + entryRepo.count.mockResolvedValue(5); + + await expect(service.remove(mockTenantId, 'period-001')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw NotFoundException if period not found', async () => { + periodRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, 'invalid-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('getCurrentPeriod', () => { + it('should return the current open period', async () => { + periodRepo.findOne.mockResolvedValue(mockPeriod as CommissionPeriodEntity); + + const result = await service.getCurrentPeriod(mockTenantId); + + expect(result).not.toBeNull(); + expect(result!.status).toBe(PeriodStatus.OPEN); + }); + + it('should return null if no open period', async () => { + periodRepo.findOne.mockResolvedValue(null); + + const result = await service.getCurrentPeriod(mockTenantId); + + expect(result).toBeNull(); + }); + }); + + describe('assignEntriesToPeriod', () => { + it('should assign entries to a period', async () => { + const mockQueryBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 3 }), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.assignEntriesToPeriod( + mockTenantId, + 'period-001', + ['entry-001', 'entry-002', 'entry-003'], + ); + + expect(result).toBe(3); + }); + + it('should return zero when no entries match', async () => { + const mockQueryBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 0 }), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.assignEntriesToPeriod(mockTenantId, 'period-001', []); + + expect(result).toBe(0); + }); + }); +}); diff --git a/src/modules/commissions/__tests__/schemes.service.spec.ts b/src/modules/commissions/__tests__/schemes.service.spec.ts new file mode 100644 index 0000000..887d0ce --- /dev/null +++ b/src/modules/commissions/__tests__/schemes.service.spec.ts @@ -0,0 +1,342 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; + +import { SchemesService } from '../services/schemes.service'; +import { CommissionSchemeEntity, SchemeType, AppliesTo } from '../entities'; + +describe('SchemesService', () => { + let service: SchemesService; + let schemeRepo: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockScheme: Partial = { + id: 'scheme-001', + tenantId: mockTenantId, + name: 'Standard Commission', + description: 'Standard percentage commission', + type: SchemeType.PERCENTAGE, + rate: 10, + fixedAmount: 0, + tiers: [], + appliesTo: AppliesTo.ALL, + productIds: [], + categoryIds: [], + minAmount: 0, + maxAmount: null, + isActive: true, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + createdBy: mockUserId, + deletedAt: null, + }; + + beforeEach(async () => { + const mockSchemeRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SchemesService, + { provide: getRepositoryToken(CommissionSchemeEntity), useValue: mockSchemeRepo }, + ], + }).compile(); + + service = module.get(SchemesService); + schemeRepo = module.get(getRepositoryToken(CommissionSchemeEntity)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create a percentage scheme successfully', async () => { + schemeRepo.create.mockReturnValue(mockScheme as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue(mockScheme as CommissionSchemeEntity); + + const dto = { + name: 'Standard Commission', + description: 'Standard percentage commission', + type: SchemeType.PERCENTAGE, + rate: 10, + appliesTo: AppliesTo.ALL, + }; + + const result = await service.create(mockTenantId, mockUserId, dto); + + expect(result.name).toBe('Standard Commission'); + expect(result.type).toBe(SchemeType.PERCENTAGE); + expect(result.rate).toBe(10); + expect(schemeRepo.create).toHaveBeenCalled(); + expect(schemeRepo.save).toHaveBeenCalled(); + }); + + it('should create a fixed amount scheme', async () => { + const fixedScheme = { ...mockScheme, type: SchemeType.FIXED, fixedAmount: 50 }; + schemeRepo.create.mockReturnValue(fixedScheme as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue(fixedScheme as CommissionSchemeEntity); + + const dto = { + name: 'Fixed Commission', + type: SchemeType.FIXED, + fixedAmount: 50, + appliesTo: AppliesTo.ALL, + }; + + const result = await service.create(mockTenantId, mockUserId, dto); + + expect(result.type).toBe(SchemeType.FIXED); + expect(result.fixedAmount).toBe(50); + }); + + it('should create a tiered scheme', async () => { + const tiers = [ + { from: 0, to: 1000, rate: 5 }, + { from: 1000, to: 5000, rate: 10 }, + { from: 5000, to: null, rate: 15 }, + ]; + const tieredScheme = { ...mockScheme, type: SchemeType.TIERED, tiers }; + schemeRepo.create.mockReturnValue(tieredScheme as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue(tieredScheme as CommissionSchemeEntity); + + const dto = { + name: 'Tiered Commission', + type: SchemeType.TIERED, + tiers, + appliesTo: AppliesTo.ALL, + }; + + const result = await service.create(mockTenantId, mockUserId, dto); + + expect(result.type).toBe(SchemeType.TIERED); + expect(result.tiers).toHaveLength(3); + }); + + it('should create a scheme with product restrictions', async () => { + const productScheme = { + ...mockScheme, + appliesTo: AppliesTo.PRODUCTS, + productIds: ['product-001', 'product-002'], + }; + schemeRepo.create.mockReturnValue(productScheme as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue(productScheme as CommissionSchemeEntity); + + const dto = { + name: 'Product Commission', + type: SchemeType.PERCENTAGE, + rate: 15, + appliesTo: AppliesTo.PRODUCTS, + productIds: ['product-001', 'product-002'], + }; + + const result = await service.create(mockTenantId, mockUserId, dto); + + expect(result.appliesTo).toBe(AppliesTo.PRODUCTS); + expect(result.productIds).toHaveLength(2); + }); + }); + + describe('findAll', () => { + it('should return paginated schemes', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockScheme], 1]), + }; + + schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, { page: 1, limit: 10 }); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + expect(result.totalPages).toBe(1); + }); + + it('should filter by type', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockScheme], 1]), + }; + + schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { type: SchemeType.PERCENTAGE }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 's.type = :type', + { type: SchemeType.PERCENTAGE }, + ); + }); + + it('should filter by isActive', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + + schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { isActive: true }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 's.is_active = :isActive', + { isActive: true }, + ); + }); + + it('should search by name or description', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockScheme], 1]), + }; + + schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { search: 'Standard' }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + '(s.name ILIKE :search OR s.description ILIKE :search)', + { search: '%Standard%' }, + ); + }); + }); + + describe('findOne', () => { + it('should return a scheme by id', async () => { + schemeRepo.findOne.mockResolvedValue(mockScheme as CommissionSchemeEntity); + + const result = await service.findOne(mockTenantId, 'scheme-001'); + + expect(result.id).toBe('scheme-001'); + expect(result.name).toBe('Standard Commission'); + }); + + it('should throw NotFoundException if scheme not found', async () => { + schemeRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'invalid-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('update', () => { + it('should update a scheme successfully', async () => { + const updatedScheme = { ...mockScheme, name: 'Updated Commission', rate: 15 }; + schemeRepo.findOne.mockResolvedValue(mockScheme as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue(updatedScheme as CommissionSchemeEntity); + + const result = await service.update(mockTenantId, 'scheme-001', { + name: 'Updated Commission', + rate: 15, + }); + + expect(result.name).toBe('Updated Commission'); + expect(result.rate).toBe(15); + }); + + it('should throw NotFoundException if scheme not found', async () => { + schemeRepo.findOne.mockResolvedValue(null); + + await expect( + service.update(mockTenantId, 'invalid-id', { name: 'Test' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('remove', () => { + it('should soft delete a scheme', async () => { + schemeRepo.findOne.mockResolvedValue(mockScheme as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue({ + ...mockScheme, + deletedAt: new Date(), + } as CommissionSchemeEntity); + + await service.remove(mockTenantId, 'scheme-001'); + + expect(schemeRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ deletedAt: expect.any(Date) }), + ); + }); + + it('should throw NotFoundException if scheme not found', async () => { + schemeRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, 'invalid-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('activate', () => { + it('should activate a scheme', async () => { + const inactiveScheme = { ...mockScheme, isActive: false }; + schemeRepo.findOne.mockResolvedValue(inactiveScheme as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue({ + ...inactiveScheme, + isActive: true, + } as CommissionSchemeEntity); + + const result = await service.activate(mockTenantId, 'scheme-001'); + + expect(result.isActive).toBe(true); + }); + + it('should throw NotFoundException if scheme not found', async () => { + schemeRepo.findOne.mockResolvedValue(null); + + await expect(service.activate(mockTenantId, 'invalid-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('deactivate', () => { + it('should deactivate a scheme', async () => { + schemeRepo.findOne.mockResolvedValue(mockScheme as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue({ + ...mockScheme, + isActive: false, + } as CommissionSchemeEntity); + + const result = await service.deactivate(mockTenantId, 'scheme-001'); + + expect(result.isActive).toBe(false); + }); + + it('should throw NotFoundException if scheme not found', async () => { + schemeRepo.findOne.mockResolvedValue(null); + + await expect(service.deactivate(mockTenantId, 'invalid-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/src/modules/sales/__tests__/pipeline.service.spec.ts b/src/modules/sales/__tests__/pipeline.service.spec.ts new file mode 100644 index 0000000..42a6903 --- /dev/null +++ b/src/modules/sales/__tests__/pipeline.service.spec.ts @@ -0,0 +1,342 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; + +import { PipelineService } from '../services/pipeline.service'; +import { PipelineStageEntity, OpportunityEntity } from '../entities'; + +describe('PipelineService', () => { + let service: PipelineService; + let stageRepo: jest.Mocked>; + let opportunityRepo: jest.Mocked>; + let dataSource: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + + const mockStage: Partial = { + id: 'stage-001', + tenantId: mockTenantId, + name: 'Qualification', + position: 1, + color: '#3B82F6', + isWon: false, + isLost: false, + isActive: true, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }; + + const mockStages: Partial[] = [ + mockStage, + { + id: 'stage-002', + tenantId: mockTenantId, + name: 'Proposal', + position: 2, + color: '#F59E0B', + isWon: false, + isLost: false, + isActive: true, + }, + { + id: 'stage-003', + tenantId: mockTenantId, + name: 'Closed Won', + position: 3, + color: '#10B981', + isWon: true, + isLost: false, + isActive: true, + }, + ]; + + beforeEach(async () => { + const mockStageRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + remove: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockOpportunityRepo = { + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockDataSource = { + query: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PipelineService, + { provide: getRepositoryToken(PipelineStageEntity), useValue: mockStageRepo }, + { provide: getRepositoryToken(OpportunityEntity), useValue: mockOpportunityRepo }, + { provide: DataSource, useValue: mockDataSource }, + ], + }).compile(); + + service = module.get(PipelineService); + stageRepo = module.get(getRepositoryToken(PipelineStageEntity)); + opportunityRepo = module.get(getRepositoryToken(OpportunityEntity)); + dataSource = module.get(DataSource); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('initializeDefaults', () => { + it('should call database function to initialize default stages', async () => { + dataSource.query.mockResolvedValue([]); + + await service.initializeDefaults(mockTenantId); + + expect(dataSource.query).toHaveBeenCalledWith( + `SELECT sales.initialize_default_stages($1)`, + [mockTenantId], + ); + }); + }); + + describe('findAll', () => { + it('should return all stages with opportunity stats', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { stageId: 'stage-001', count: 5, total: 50000 }, + { stageId: 'stage-002', count: 3, total: 30000 }, + ]), + }; + + stageRepo.find.mockResolvedValue(mockStages as PipelineStageEntity[]); + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId); + + expect(result).toHaveLength(3); + expect(result[0].opportunityCount).toBe(5); + expect(result[0].totalAmount).toBe(50000); + expect(result[2].opportunityCount).toBe(0); + expect(stageRepo.find).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId }, + order: { position: 'ASC' }, + }); + }); + + it('should return stages with zero stats when no opportunities', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + + stageRepo.find.mockResolvedValue([mockStage] as PipelineStageEntity[]); + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId); + + expect(result).toHaveLength(1); + expect(result[0].opportunityCount).toBe(0); + expect(result[0].totalAmount).toBe(0); + }); + }); + + describe('findOne', () => { + it('should return a stage by id', async () => { + stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity); + + const result = await service.findOne(mockTenantId, 'stage-001'); + + expect(result.id).toBe('stage-001'); + expect(result.name).toBe('Qualification'); + expect(stageRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'stage-001', tenantId: mockTenantId }, + }); + }); + + it('should throw NotFoundException if stage not found', async () => { + stageRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'invalid-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('create', () => { + it('should create a new stage at the end', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ max: 2 }), + }; + + stageRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + stageRepo.create.mockReturnValue(mockStage as PipelineStageEntity); + stageRepo.save.mockResolvedValue(mockStage as PipelineStageEntity); + + const dto = { + name: 'Qualification', + color: '#3B82F6', + }; + + const result = await service.create(mockTenantId, dto); + + expect(result.name).toBe('Qualification'); + expect(stageRepo.create).toHaveBeenCalled(); + expect(stageRepo.save).toHaveBeenCalled(); + }); + + it('should create a stage with custom position', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ max: 5 }), + }; + + stageRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + stageRepo.create.mockReturnValue({ ...mockStage, position: 3 } as PipelineStageEntity); + stageRepo.save.mockResolvedValue({ ...mockStage, position: 3 } as PipelineStageEntity); + + const dto = { + name: 'Custom Stage', + position: 3, + }; + + const result = await service.create(mockTenantId, dto); + + expect(stageRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ position: 3 }), + ); + }); + + it('should create a won stage', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ max: 2 }), + }; + + const wonStage = { ...mockStage, isWon: true }; + stageRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + stageRepo.create.mockReturnValue(wonStage as PipelineStageEntity); + stageRepo.save.mockResolvedValue(wonStage as PipelineStageEntity); + + const dto = { + name: 'Closed Won', + isWon: true, + }; + + const result = await service.create(mockTenantId, dto); + + expect(result.isWon).toBe(true); + }); + }); + + describe('update', () => { + it('should update a stage successfully', async () => { + const updatedStage = { ...mockStage, name: 'Updated Stage' }; + stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity); + stageRepo.save.mockResolvedValue(updatedStage as PipelineStageEntity); + + const result = await service.update(mockTenantId, 'stage-001', { name: 'Updated Stage' }); + + expect(result.name).toBe('Updated Stage'); + expect(stageRepo.save).toHaveBeenCalled(); + }); + + it('should throw NotFoundException if stage not found', async () => { + stageRepo.findOne.mockResolvedValue(null); + + await expect( + service.update(mockTenantId, 'invalid-id', { name: 'Test' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('remove', () => { + it('should delete a stage without opportunities', async () => { + stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity); + opportunityRepo.count.mockResolvedValue(0); + + await service.remove(mockTenantId, 'stage-001'); + + expect(stageRepo.remove).toHaveBeenCalledWith(mockStage); + }); + + it('should throw error when stage has opportunities', async () => { + stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity); + opportunityRepo.count.mockResolvedValue(5); + + await expect(service.remove(mockTenantId, 'stage-001')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw NotFoundException if stage not found', async () => { + stageRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, 'invalid-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('reorder', () => { + it('should reorder stages successfully', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + + stageRepo.find.mockResolvedValue(mockStages as PipelineStageEntity[]); + stageRepo.save.mockResolvedValue(mockStages as PipelineStageEntity[]); + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const dto = { + stageIds: ['stage-003', 'stage-001', 'stage-002'], + }; + + const result = await service.reorder(mockTenantId, dto); + + expect(stageRepo.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should handle empty stage list', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + + stageRepo.find.mockResolvedValue([]); + stageRepo.save.mockResolvedValue([]); + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const dto = { stageIds: [] }; + + const result = await service.reorder(mockTenantId, dto); + + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/src/modules/sales/__tests__/sales-dashboard.service.spec.ts b/src/modules/sales/__tests__/sales-dashboard.service.spec.ts new file mode 100644 index 0000000..0f3f06d --- /dev/null +++ b/src/modules/sales/__tests__/sales-dashboard.service.spec.ts @@ -0,0 +1,308 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; + +import { SalesDashboardService } from '../services/sales-dashboard.service'; +import { LeadEntity, OpportunityEntity, ActivityEntity, OpportunityStage, LeadStatus, ActivityStatus } from '../entities'; + +describe('SalesDashboardService', () => { + let service: SalesDashboardService; + let leadRepo: jest.Mocked>; + let opportunityRepo: jest.Mocked>; + let activityRepo: jest.Mocked>; + let dataSource: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + + beforeEach(async () => { + const mockLeadRepo = { + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockOpportunityRepo = { + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockActivityRepo = { + count: jest.fn(), + }; + + const mockDataSource = { + query: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SalesDashboardService, + { provide: getRepositoryToken(LeadEntity), useValue: mockLeadRepo }, + { provide: getRepositoryToken(OpportunityEntity), useValue: mockOpportunityRepo }, + { provide: getRepositoryToken(ActivityEntity), useValue: mockActivityRepo }, + { provide: DataSource, useValue: mockDataSource }, + ], + }).compile(); + + service = module.get(SalesDashboardService); + leadRepo = module.get(getRepositoryToken(LeadEntity)); + opportunityRepo = module.get(getRepositoryToken(OpportunityEntity)); + activityRepo = module.get(getRepositoryToken(ActivityEntity)); + dataSource = module.get(DataSource); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getDashboardSummary', () => { + it('should return dashboard summary with all stats', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + setParameter: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ + total: 10, + totalValue: 100000, + wonCount: 3, + wonValue: 45000, + }), + }; + + leadRepo.count + .mockResolvedValueOnce(50) // total leads + .mockResolvedValueOnce(10); // converted leads + opportunityRepo.count.mockResolvedValue(20); + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + activityRepo.count.mockResolvedValue(25); + + const result = await service.getDashboardSummary(mockTenantId); + + expect(result.totalLeads).toBe(50); + expect(result.totalOpportunities).toBe(20); + expect(result.totalPipelineValue).toBe(100000); + expect(result.wonDeals).toBe(3); + expect(result.conversionRate).toBe(20); + expect(result.averageDealSize).toBe(15000); + expect(result.activitiesThisWeek).toBe(25); + expect(result.currency).toBe('USD'); + }); + + it('should handle zero leads gracefully', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + setParameter: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ + total: 0, + totalValue: 0, + wonCount: 0, + wonValue: 0, + }), + }; + + leadRepo.count.mockResolvedValue(0); + opportunityRepo.count.mockResolvedValue(0); + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + activityRepo.count.mockResolvedValue(0); + + const result = await service.getDashboardSummary(mockTenantId); + + expect(result.totalLeads).toBe(0); + expect(result.conversionRate).toBe(0); + expect(result.averageDealSize).toBe(0); + }); + }); + + describe('getLeadsByStatus', () => { + it('should return leads grouped by status', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { status: LeadStatus.NEW, count: 20 }, + { status: LeadStatus.CONTACTED, count: 15 }, + { status: LeadStatus.QUALIFIED, count: 10 }, + { status: LeadStatus.CONVERTED, count: 5 }, + ]), + }; + + leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getLeadsByStatus(mockTenantId); + + expect(result).toHaveLength(4); + expect(result[0].status).toBe(LeadStatus.NEW); + expect(result[0].count).toBe(20); + expect(result[0].percentage).toBe(40); // 20/50 = 40% + }); + + it('should handle empty results', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + + leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getLeadsByStatus(mockTenantId); + + expect(result).toHaveLength(0); + }); + }); + + describe('getLeadsBySource', () => { + it('should return leads grouped by source', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { source: 'website', count: 30 }, + { source: 'referral', count: 15 }, + { source: 'social', count: 5 }, + ]), + }; + + leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getLeadsBySource(mockTenantId); + + expect(result).toHaveLength(3); + expect(result[0].source).toBe('website'); + expect(result[0].count).toBe(30); + expect(result[0].percentage).toBe(60); // 30/50 = 60% + }); + }); + + describe('getOpportunitiesByStage', () => { + it('should return opportunities grouped by stage', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { stage: OpportunityStage.QUALIFICATION, count: 10, totalAmount: 100000 }, + { stage: OpportunityStage.PROPOSAL, count: 5, totalAmount: 75000 }, + { stage: OpportunityStage.CLOSED_WON, count: 3, totalAmount: 45000 }, + ]), + }; + + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getOpportunitiesByStage(mockTenantId); + + expect(result).toHaveLength(3); + expect(result[0].stage).toBe(OpportunityStage.QUALIFICATION); + expect(result[0].count).toBe(10); + expect(result[0].totalAmount).toBe(100000); + expect(result[0].percentage).toBe(56); // 10/18 ≈ 56% + }); + }); + + describe('getConversionFunnel', () => { + it('should return conversion funnel stages', async () => { + leadRepo.count + .mockResolvedValueOnce(100) // total leads + .mockResolvedValueOnce(40) // new leads + .mockResolvedValueOnce(30) // contacted + .mockResolvedValueOnce(20) // qualified + .mockResolvedValueOnce(10); // converted + + opportunityRepo.count.mockResolvedValue(5); // won + + const result = await service.getConversionFunnel(mockTenantId); + + expect(result).toHaveLength(5); + expect(result[0].stage).toBe('New Leads'); + expect(result[0].count).toBe(40); + expect(result[4].stage).toBe('Won Deals'); + expect(result[4].count).toBe(5); + }); + + it('should handle zero leads in funnel', async () => { + leadRepo.count.mockResolvedValue(0); + opportunityRepo.count.mockResolvedValue(0); + + const result = await service.getConversionFunnel(mockTenantId); + + expect(result).toHaveLength(5); + result.forEach((stage) => { + expect(stage.percentage).toBe(0); + }); + }); + }); + + describe('getSalesPerformance', () => { + it('should return sales performance by user', async () => { + dataSource.query.mockResolvedValue([ + { + user_id: 'user-001', + user_name: 'John Doe', + leads_assigned: 20, + opportunities_won: 5, + total_revenue: 50000, + activities_completed: 30, + }, + { + user_id: 'user-002', + user_name: 'Jane Smith', + leads_assigned: 15, + opportunities_won: 3, + total_revenue: 35000, + activities_completed: 25, + }, + ]); + + const result = await service.getSalesPerformance(mockTenantId); + + expect(result).toHaveLength(2); + expect(result[0].userId).toBe('user-001'); + expect(result[0].userName).toBe('John Doe'); + expect(result[0].leadsAssigned).toBe(20); + expect(result[0].opportunitiesWon).toBe(5); + expect(result[0].totalRevenue).toBe(50000); + expect(result[0].conversionRate).toBe(25); // 5/20 = 25% + expect(result[0].activitiesCompleted).toBe(30); + }); + + it('should handle empty results', async () => { + dataSource.query.mockResolvedValue([]); + + const result = await service.getSalesPerformance(mockTenantId); + + expect(result).toHaveLength(0); + }); + + it('should handle user with zero leads', async () => { + dataSource.query.mockResolvedValue([ + { + user_id: 'user-001', + user_name: 'New User', + leads_assigned: 0, + opportunities_won: 0, + total_revenue: 0, + activities_completed: 0, + }, + ]); + + const result = await service.getSalesPerformance(mockTenantId); + + expect(result[0].conversionRate).toBe(0); + }); + }); +});