From 61d7d292125035b2b80662ab960c45af9fb925ec Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 13:25:42 -0600 Subject: [PATCH] test(P2): Add controller tests for sales, commissions, and portfolio - Add 12 controller test files (117 tests total): - sales: leads, opportunities, activities, pipeline, dashboard - commissions: schemes, assignments, entries, periods, dashboard - portfolio: categories, products - Fix flaky test in assignments.service.spec.ts (timestamp issue) - All 441 tests passing for these modules Co-Authored-By: Claude Opus 4.5 --- .../__tests__/assignments.controller.spec.ts | 185 +++++++++ .../__tests__/assignments.service.spec.ts | 7 +- .../__tests__/dashboard.controller.spec.ts | 177 +++++++++ .../__tests__/entries.controller.spec.ts | 243 ++++++++++++ .../__tests__/periods.controller.spec.ts | 235 +++++++++++ .../__tests__/schemes.controller.spec.ts | 196 ++++++++++ .../__tests__/categories.controller.spec.ts | 235 +++++++++++ .../__tests__/products.controller.spec.ts | 367 ++++++++++++++++++ .../__tests__/activities.controller.spec.ts | 179 +++++++++ .../__tests__/dashboard.controller.spec.ts | 171 ++++++++ .../sales/__tests__/leads.controller.spec.ts | 219 +++++++++++ .../opportunities.controller.spec.ts | 200 ++++++++++ .../__tests__/pipeline.controller.spec.ts | 166 ++++++++ 13 files changed, 2577 insertions(+), 3 deletions(-) create mode 100644 src/modules/commissions/__tests__/assignments.controller.spec.ts create mode 100644 src/modules/commissions/__tests__/dashboard.controller.spec.ts create mode 100644 src/modules/commissions/__tests__/entries.controller.spec.ts create mode 100644 src/modules/commissions/__tests__/periods.controller.spec.ts create mode 100644 src/modules/commissions/__tests__/schemes.controller.spec.ts create mode 100644 src/modules/portfolio/__tests__/categories.controller.spec.ts create mode 100644 src/modules/portfolio/__tests__/products.controller.spec.ts create mode 100644 src/modules/sales/__tests__/activities.controller.spec.ts create mode 100644 src/modules/sales/__tests__/dashboard.controller.spec.ts create mode 100644 src/modules/sales/__tests__/leads.controller.spec.ts create mode 100644 src/modules/sales/__tests__/opportunities.controller.spec.ts create mode 100644 src/modules/sales/__tests__/pipeline.controller.spec.ts diff --git a/src/modules/commissions/__tests__/assignments.controller.spec.ts b/src/modules/commissions/__tests__/assignments.controller.spec.ts new file mode 100644 index 0000000..3351813 --- /dev/null +++ b/src/modules/commissions/__tests__/assignments.controller.spec.ts @@ -0,0 +1,185 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AssignmentsController } from '../controllers/assignments.controller'; +import { AssignmentsService } from '../services/assignments.service'; +import { CreateAssignmentDto, UpdateAssignmentDto, AssignmentListQueryDto } from '../dto'; + +describe('AssignmentsController', () => { + let controller: AssignmentsController; + let assignmentsService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockAssignmentId = '550e8400-e29b-41d4-a716-446655440003'; + const mockSchemeId = '550e8400-e29b-41d4-a716-446655440004'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockAssignment = { + id: mockAssignmentId, + tenantId: mockTenantId, + userId: mockUserId, + schemeId: mockSchemeId, + isActive: true, + customRate: null, + effectiveFrom: new Date('2026-01-01'), + effectiveTo: null, + notes: 'Standard assignment', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + createdBy: mockUserId, + }; + + const mockPaginatedAssignments = { + data: [mockAssignment], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + beforeEach(async () => { + const mockAssignmentsService = { + findAll: jest.fn(), + findOne: jest.fn(), + findActiveForUser: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AssignmentsController], + providers: [ + { + provide: AssignmentsService, + useValue: mockAssignmentsService, + }, + ], + }).compile(); + + controller = module.get(AssignmentsController); + assignmentsService = module.get(AssignmentsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated assignments', async () => { + const query: AssignmentListQueryDto = { page: 1, limit: 20 }; + assignmentsService.findAll.mockResolvedValue(mockPaginatedAssignments); + + const result = await controller.findAll(mockRequestUser, query); + + expect(assignmentsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + expect(result).toEqual(mockPaginatedAssignments); + }); + + it('should filter by userId', async () => { + const query: AssignmentListQueryDto = { page: 1, limit: 20, userId: mockUserId }; + assignmentsService.findAll.mockResolvedValue(mockPaginatedAssignments); + + await controller.findAll(mockRequestUser, query); + + expect(assignmentsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + }); + + describe('findMyAssignments', () => { + it('should return current user active assignments', async () => { + assignmentsService.findActiveForUser.mockResolvedValue([mockAssignment]); + + const result = await controller.findMyAssignments(mockRequestUser); + + expect(assignmentsService.findActiveForUser).toHaveBeenCalledWith(mockTenantId, mockUserId); + expect(result).toEqual([mockAssignment]); + }); + }); + + describe('findOne', () => { + it('should return an assignment by id', async () => { + assignmentsService.findOne.mockResolvedValue(mockAssignment); + + const result = await controller.findOne(mockRequestUser, mockAssignmentId); + + expect(assignmentsService.findOne).toHaveBeenCalledWith(mockTenantId, mockAssignmentId); + expect(result).toEqual(mockAssignment); + }); + }); + + describe('create', () => { + it('should create a new assignment', async () => { + const createDto: CreateAssignmentDto = { + userId: 'another-user-id', + schemeId: mockSchemeId, + effectiveFrom: '2026-02-01', + }; + const createdAssignment = { ...mockAssignment, ...createDto, id: 'new-assignment-id' }; + assignmentsService.create.mockResolvedValue(createdAssignment); + + const result = await controller.create(mockRequestUser, createDto); + + expect(assignmentsService.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + expect(result).toEqual(createdAssignment); + }); + + it('should create assignment with custom rate', async () => { + const createDto: CreateAssignmentDto = { + userId: mockUserId, + schemeId: mockSchemeId, + customRate: 12.5, + }; + assignmentsService.create.mockResolvedValue({ ...mockAssignment, customRate: 12.5 }); + + const result = await controller.create(mockRequestUser, createDto); + + expect(result.customRate).toBe(12.5); + }); + }); + + describe('update', () => { + it('should update an assignment', async () => { + const updateDto: UpdateAssignmentDto = { + customRate: 15, + notes: 'Updated notes', + }; + const updatedAssignment = { ...mockAssignment, ...updateDto }; + assignmentsService.update.mockResolvedValue(updatedAssignment); + + const result = await controller.update(mockRequestUser, mockAssignmentId, updateDto); + + expect(assignmentsService.update).toHaveBeenCalledWith(mockTenantId, mockAssignmentId, updateDto); + expect(result.customRate).toBe(15); + }); + + it('should deactivate an assignment', async () => { + const updateDto: UpdateAssignmentDto = { + isActive: false, + effectiveTo: '2026-03-01', + }; + const deactivatedAssignment = { ...mockAssignment, isActive: false }; + assignmentsService.update.mockResolvedValue(deactivatedAssignment); + + const result = await controller.update(mockRequestUser, mockAssignmentId, updateDto); + + expect(result.isActive).toBe(false); + }); + }); + + describe('remove', () => { + it('should delete an assignment', async () => { + assignmentsService.remove.mockResolvedValue(undefined); + + const result = await controller.remove(mockRequestUser, mockAssignmentId); + + expect(assignmentsService.remove).toHaveBeenCalledWith(mockTenantId, mockAssignmentId); + expect(result).toEqual({ message: 'Commission assignment deleted successfully' }); + }); + }); +}); diff --git a/src/modules/commissions/__tests__/assignments.service.spec.ts b/src/modules/commissions/__tests__/assignments.service.spec.ts index dde8d8e..3885cba 100644 --- a/src/modules/commissions/__tests__/assignments.service.spec.ts +++ b/src/modules/commissions/__tests__/assignments.service.spec.ts @@ -380,12 +380,13 @@ describe('AssignmentsService', () => { describe('remove', () => { it('should delete an assignment', async () => { - assignmentRepo.findOne.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity); - assignmentRepo.remove.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity); + const mockAssignment = createMockAssignment() as CommissionAssignmentEntity; + assignmentRepo.findOne.mockResolvedValue(mockAssignment); + assignmentRepo.remove.mockResolvedValue(mockAssignment); await service.remove(mockTenantId, mockAssignmentId); - expect(assignmentRepo.remove).toHaveBeenCalledWith(createMockAssignment()); + expect(assignmentRepo.remove).toHaveBeenCalledWith(mockAssignment); }); it('should throw NotFoundException when assignment not found', async () => { diff --git a/src/modules/commissions/__tests__/dashboard.controller.spec.ts b/src/modules/commissions/__tests__/dashboard.controller.spec.ts new file mode 100644 index 0000000..66795cf --- /dev/null +++ b/src/modules/commissions/__tests__/dashboard.controller.spec.ts @@ -0,0 +1,177 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CommissionsDashboardController } from '../controllers/dashboard.controller'; +import { CommissionsDashboardService } from '../services/commissions-dashboard.service'; +import { EntryStatus } from '../entities/commission-entry.entity'; + +describe('CommissionsDashboardController', () => { + let controller: CommissionsDashboardController; + let dashboardService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockDashboardSummary = { + totalSchemes: 5, + activeSchemes: 4, + totalAssignments: 25, + activeAssignments: 20, + totalEntries: 150, + pendingEntries: 30, + approvedEntries: 100, + paidEntries: 20, + totalPendingAmount: 15000, + totalApprovedAmount: 50000, + totalPaidAmount: 10000, + currentPeriodId: 'period-123', + currentPeriodName: 'January 2026', + }; + + const mockUserEarnings = { + userId: mockUserId, + userName: 'John Doe', + totalEarnings: 25000, + pendingAmount: 5000, + approvedAmount: 15000, + paidAmount: 5000, + totalEntries: 50, + averageCommission: 500, + lastEntryDate: new Date('2026-01-15'), + }; + + const mockEntriesByStatus = [ + { status: EntryStatus.PENDING, count: 30, amount: 15000 }, + { status: EntryStatus.APPROVED, count: 100, amount: 50000 }, + { status: EntryStatus.PAID, count: 20, amount: 10000 }, + ]; + + const mockEntriesByScheme = [ + { schemeId: 'scheme-1', schemeName: 'Sales Commission', count: 80, amount: 40000 }, + { schemeId: 'scheme-2', schemeName: 'Referral Bonus', count: 50, amount: 25000 }, + { schemeId: 'scheme-3', schemeName: 'Performance Bonus', count: 20, amount: 10000 }, + ]; + + const mockEntriesByUser = [ + { userId: mockUserId, userName: 'John Doe', count: 50, amount: 25000 }, + { userId: 'user-2', userName: 'Jane Smith', count: 40, amount: 20000 }, + { userId: 'user-3', userName: 'Bob Johnson', count: 30, amount: 15000 }, + ]; + + const mockTopEarners = [ + { userId: mockUserId, userName: 'John Doe', count: 50, amount: 25000 }, + { userId: 'user-2', userName: 'Jane Smith', count: 40, amount: 20000 }, + ]; + + beforeEach(async () => { + const mockDashboardService = { + getDashboardSummary: jest.fn(), + getUserEarnings: jest.fn(), + getEntriesByStatus: jest.fn(), + getEntriesByScheme: jest.fn(), + getEntriesByUser: jest.fn(), + getTopEarners: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [CommissionsDashboardController], + providers: [ + { + provide: CommissionsDashboardService, + useValue: mockDashboardService, + }, + ], + }).compile(); + + controller = module.get(CommissionsDashboardController); + dashboardService = module.get(CommissionsDashboardService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getSummary', () => { + it('should return dashboard summary', async () => { + dashboardService.getDashboardSummary.mockResolvedValue(mockDashboardSummary); + + const result = await controller.getSummary(mockRequestUser); + + expect(dashboardService.getDashboardSummary).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockDashboardSummary); + expect(result.totalSchemes).toBe(5); + }); + }); + + describe('getMyEarnings', () => { + it('should return current user earnings', async () => { + dashboardService.getUserEarnings.mockResolvedValue(mockUserEarnings); + + const result = await controller.getMyEarnings(mockRequestUser); + + expect(dashboardService.getUserEarnings).toHaveBeenCalledWith(mockTenantId, mockUserId); + expect(result).toEqual(mockUserEarnings); + expect(result.totalEarnings).toBe(25000); + }); + }); + + describe('getEntriesByStatus', () => { + it('should return entries grouped by status', async () => { + dashboardService.getEntriesByStatus.mockResolvedValue(mockEntriesByStatus); + + const result = await controller.getEntriesByStatus(mockRequestUser); + + expect(dashboardService.getEntriesByStatus).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockEntriesByStatus); + expect(result.length).toBe(3); + }); + }); + + describe('getEntriesByScheme', () => { + it('should return entries grouped by scheme', async () => { + dashboardService.getEntriesByScheme.mockResolvedValue(mockEntriesByScheme); + + const result = await controller.getEntriesByScheme(mockRequestUser); + + expect(dashboardService.getEntriesByScheme).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockEntriesByScheme); + expect(result.length).toBe(3); + }); + }); + + describe('getEntriesByUser', () => { + it('should return entries grouped by user', async () => { + dashboardService.getEntriesByUser.mockResolvedValue(mockEntriesByUser); + + const result = await controller.getEntriesByUser(mockRequestUser); + + expect(dashboardService.getEntriesByUser).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockEntriesByUser); + expect(result.length).toBe(3); + }); + }); + + describe('getTopEarners', () => { + it('should return top earners with default limit', async () => { + dashboardService.getTopEarners.mockResolvedValue(mockTopEarners); + + const result = await controller.getTopEarners(mockRequestUser, undefined); + + expect(dashboardService.getTopEarners).toHaveBeenCalledWith(mockTenantId, 10); + expect(result).toEqual(mockTopEarners); + }); + + it('should return top earners with custom limit', async () => { + dashboardService.getTopEarners.mockResolvedValue(mockTopEarners.slice(0, 1)); + + const result = await controller.getTopEarners(mockRequestUser, 5); + + expect(dashboardService.getTopEarners).toHaveBeenCalledWith(mockTenantId, 5); + }); + }); +}); diff --git a/src/modules/commissions/__tests__/entries.controller.spec.ts b/src/modules/commissions/__tests__/entries.controller.spec.ts new file mode 100644 index 0000000..441dc07 --- /dev/null +++ b/src/modules/commissions/__tests__/entries.controller.spec.ts @@ -0,0 +1,243 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EntriesController } from '../controllers/entries.controller'; +import { EntriesService } from '../services/entries.service'; +import { + CreateEntryDto, + UpdateEntryDto, + ApproveEntryDto, + RejectEntryDto, + EntryListQueryDto, + CalculateCommissionDto, +} from '../dto'; +import { EntryStatus } from '../entities/commission-entry.entity'; + +describe('EntriesController', () => { + let controller: EntriesController; + let entriesService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockEntryId = '550e8400-e29b-41d4-a716-446655440003'; + const mockSchemeId = '550e8400-e29b-41d4-a716-446655440004'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockEntry = { + id: mockEntryId, + tenantId: mockTenantId, + userId: mockUserId, + schemeId: mockSchemeId, + referenceType: 'opportunity', + referenceId: 'opp-123', + baseAmount: 10000, + rateApplied: 10, + commissionAmount: 1000, + currency: 'USD', + status: EntryStatus.PENDING, + periodId: null, + notes: 'Commission for closed deal', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }; + + const mockPaginatedEntries = { + data: [mockEntry], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + beforeEach(async () => { + const mockEntriesService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + approve: jest.fn(), + reject: jest.fn(), + calculateCommission: jest.fn(), + bulkApprove: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [EntriesController], + providers: [ + { + provide: EntriesService, + useValue: mockEntriesService, + }, + ], + }).compile(); + + controller = module.get(EntriesController); + entriesService = module.get(EntriesService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated entries', async () => { + const query: EntryListQueryDto = { page: 1, limit: 20 }; + entriesService.findAll.mockResolvedValue(mockPaginatedEntries); + + const result = await controller.findAll(mockRequestUser, query); + + expect(entriesService.findAll).toHaveBeenCalledWith(mockTenantId, query); + expect(result).toEqual(mockPaginatedEntries); + }); + + it('should filter by status', async () => { + const query: EntryListQueryDto = { page: 1, limit: 20, status: EntryStatus.APPROVED }; + entriesService.findAll.mockResolvedValue({ ...mockPaginatedEntries, data: [] }); + + await controller.findAll(mockRequestUser, query); + + expect(entriesService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + }); + + describe('findMyEntries', () => { + it('should return current user entries', async () => { + const query: EntryListQueryDto = { page: 1, limit: 20 }; + entriesService.findAll.mockResolvedValue(mockPaginatedEntries); + + const result = await controller.findMyEntries(mockRequestUser, query); + + expect(entriesService.findAll).toHaveBeenCalledWith(mockTenantId, { ...query, userId: mockUserId }); + expect(result).toEqual(mockPaginatedEntries); + }); + }); + + describe('findOne', () => { + it('should return an entry by id', async () => { + entriesService.findOne.mockResolvedValue(mockEntry); + + const result = await controller.findOne(mockRequestUser, mockEntryId); + + expect(entriesService.findOne).toHaveBeenCalledWith(mockTenantId, mockEntryId); + expect(result).toEqual(mockEntry); + }); + }); + + describe('create', () => { + it('should create a new entry', async () => { + const createDto: CreateEntryDto = { + userId: mockUserId, + schemeId: mockSchemeId, + referenceType: 'opportunity', + referenceId: 'new-opp-id', + baseAmount: 5000, + }; + const createdEntry = { ...mockEntry, ...createDto, id: 'new-entry-id', commissionAmount: 500 }; + entriesService.create.mockResolvedValue(createdEntry); + + const result = await controller.create(mockRequestUser, createDto); + + expect(entriesService.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + expect(result).toEqual(createdEntry); + }); + }); + + describe('update', () => { + it('should update an entry', async () => { + const updateDto: UpdateEntryDto = { + notes: 'Updated notes', + }; + const updatedEntry = { ...mockEntry, ...updateDto }; + entriesService.update.mockResolvedValue(updatedEntry); + + const result = await controller.update(mockRequestUser, mockEntryId, updateDto); + + expect(entriesService.update).toHaveBeenCalledWith(mockTenantId, mockEntryId, updateDto); + expect(result.notes).toBe('Updated notes'); + }); + }); + + describe('approve', () => { + it('should approve an entry', async () => { + const approveDto: ApproveEntryDto = { + notes: 'Approved by manager', + }; + const approvedEntry = { + ...mockEntry, + status: EntryStatus.APPROVED, + approvedBy: mockUserId, + approvedAt: new Date(), + }; + entriesService.approve.mockResolvedValue(approvedEntry); + + const result = await controller.approve(mockRequestUser, mockEntryId, approveDto); + + expect(entriesService.approve).toHaveBeenCalledWith(mockTenantId, mockEntryId, mockUserId, approveDto); + expect(result.status).toBe(EntryStatus.APPROVED); + }); + }); + + describe('reject', () => { + it('should reject an entry', async () => { + const rejectDto: RejectEntryDto = { + reason: 'Invalid reference', + }; + const rejectedEntry = { + ...mockEntry, + status: EntryStatus.REJECTED, + }; + entriesService.reject.mockResolvedValue(rejectedEntry); + + const result = await controller.reject(mockRequestUser, mockEntryId, rejectDto); + + expect(entriesService.reject).toHaveBeenCalledWith(mockTenantId, mockEntryId, mockUserId, rejectDto); + expect(result.status).toBe(EntryStatus.REJECTED); + }); + }); + + describe('calculateCommission', () => { + it('should calculate commission', async () => { + const calculateDto: CalculateCommissionDto = { + schemeId: mockSchemeId, + baseAmount: 10000, + }; + const calculationResult = { + baseAmount: 10000, + rate: 10, + commissionAmount: 1000, + currency: 'USD', + }; + entriesService.calculateCommission.mockResolvedValue(calculationResult); + + const result = await controller.calculateCommission(mockRequestUser, calculateDto); + + expect(entriesService.calculateCommission).toHaveBeenCalledWith(mockTenantId, calculateDto); + expect(result.commissionAmount).toBe(1000); + }); + }); + + describe('bulkApprove', () => { + it('should bulk approve entries', async () => { + const entryIds = [mockEntryId, 'entry-2', 'entry-3']; + entriesService.bulkApprove.mockResolvedValue(3); + + const result = await controller.bulkApprove(mockRequestUser, { entryIds }); + + expect(entriesService.bulkApprove).toHaveBeenCalledWith(mockTenantId, entryIds, mockUserId); + expect(result).toEqual({ approved: 3 }); + }); + + it('should return count of approved entries', async () => { + const entryIds = [mockEntryId, 'entry-2']; + entriesService.bulkApprove.mockResolvedValue(1); // Only 1 was valid + + const result = await controller.bulkApprove(mockRequestUser, { entryIds }); + + expect(result.approved).toBe(1); + }); + }); +}); diff --git a/src/modules/commissions/__tests__/periods.controller.spec.ts b/src/modules/commissions/__tests__/periods.controller.spec.ts new file mode 100644 index 0000000..295b227 --- /dev/null +++ b/src/modules/commissions/__tests__/periods.controller.spec.ts @@ -0,0 +1,235 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PeriodsController } from '../controllers/periods.controller'; +import { PeriodsService } from '../services/periods.service'; +import { CreatePeriodDto, UpdatePeriodDto, ClosePeriodDto, MarkPaidDto, PeriodListQueryDto } from '../dto'; +import { PeriodStatus } from '../entities/commission-period.entity'; + +describe('PeriodsController', () => { + let controller: PeriodsController; + let periodsService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockPeriodId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockPeriod = { + id: mockPeriodId, + tenantId: mockTenantId, + name: 'January 2026', + startsAt: new Date('2026-01-01'), + endsAt: new Date('2026-01-31'), + totalEntries: 10, + totalAmount: 5000, + currency: 'USD', + status: PeriodStatus.OPEN, + closedAt: null, + closedBy: null, + paidAt: null, + paidBy: null, + createdAt: new Date('2026-01-01'), + createdBy: mockUserId, + }; + + const mockPaginatedPeriods = { + data: [mockPeriod], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + beforeEach(async () => { + const mockPeriodsService = { + findAll: jest.fn(), + findOne: jest.fn(), + getCurrentPeriod: jest.fn(), + create: jest.fn(), + update: jest.fn(), + close: jest.fn(), + markAsPaid: jest.fn(), + remove: jest.fn(), + assignEntriesToPeriod: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [PeriodsController], + providers: [ + { + provide: PeriodsService, + useValue: mockPeriodsService, + }, + ], + }).compile(); + + controller = module.get(PeriodsController); + periodsService = module.get(PeriodsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated periods', async () => { + const query: PeriodListQueryDto = { page: 1, limit: 20 }; + periodsService.findAll.mockResolvedValue(mockPaginatedPeriods); + + const result = await controller.findAll(mockRequestUser, query); + + expect(periodsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + expect(result).toEqual(mockPaginatedPeriods); + }); + + it('should filter by status', async () => { + const query: PeriodListQueryDto = { page: 1, limit: 20, status: PeriodStatus.CLOSED }; + periodsService.findAll.mockResolvedValue({ ...mockPaginatedPeriods, data: [] }); + + await controller.findAll(mockRequestUser, query); + + expect(periodsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + }); + + describe('getCurrentPeriod', () => { + it('should return the current open period', async () => { + periodsService.getCurrentPeriod.mockResolvedValue(mockPeriod); + + const result = await controller.getCurrentPeriod(mockRequestUser); + + expect(periodsService.getCurrentPeriod).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockPeriod); + }); + + it('should return null when no current period exists', async () => { + periodsService.getCurrentPeriod.mockResolvedValue(null); + + const result = await controller.getCurrentPeriod(mockRequestUser); + + expect(result).toBeNull(); + }); + }); + + describe('findOne', () => { + it('should return a period by id', async () => { + periodsService.findOne.mockResolvedValue(mockPeriod); + + const result = await controller.findOne(mockRequestUser, mockPeriodId); + + expect(periodsService.findOne).toHaveBeenCalledWith(mockTenantId, mockPeriodId); + expect(result).toEqual(mockPeriod); + }); + }); + + describe('create', () => { + it('should create a new period', async () => { + const createDto: CreatePeriodDto = { + name: 'February 2026', + startsAt: '2026-02-01', + endsAt: '2026-02-28', + }; + const createdPeriod = { ...mockPeriod, ...createDto, id: 'new-period-id' }; + periodsService.create.mockResolvedValue(createdPeriod); + + const result = await controller.create(mockRequestUser, createDto); + + expect(periodsService.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + expect(result).toEqual(createdPeriod); + }); + }); + + describe('update', () => { + it('should update a period', async () => { + const updateDto: UpdatePeriodDto = { + name: 'Updated Period Name', + }; + const updatedPeriod = { ...mockPeriod, ...updateDto }; + periodsService.update.mockResolvedValue(updatedPeriod); + + const result = await controller.update(mockRequestUser, mockPeriodId, updateDto); + + expect(periodsService.update).toHaveBeenCalledWith(mockTenantId, mockPeriodId, updateDto); + expect(result.name).toBe('Updated Period Name'); + }); + }); + + describe('close', () => { + it('should close a period', async () => { + const closeDto: ClosePeriodDto = { + notes: 'Period closed for payroll', + }; + const closedPeriod = { + ...mockPeriod, + status: PeriodStatus.CLOSED, + closedAt: new Date(), + closedBy: mockUserId, + }; + periodsService.close.mockResolvedValue(closedPeriod); + + const result = await controller.close(mockRequestUser, mockPeriodId, closeDto); + + expect(periodsService.close).toHaveBeenCalledWith(mockTenantId, mockPeriodId, mockUserId, closeDto); + expect(result.status).toBe(PeriodStatus.CLOSED); + }); + }); + + describe('markPaid', () => { + it('should mark a period as paid', async () => { + const markPaidDto: MarkPaidDto = { + paymentReference: 'PAY-2026-001', + paymentNotes: 'Bank transfer completed', + }; + const paidPeriod = { + ...mockPeriod, + status: PeriodStatus.PAID, + paidAt: new Date(), + paidBy: mockUserId, + paymentReference: markPaidDto.paymentReference, + }; + periodsService.markAsPaid.mockResolvedValue(paidPeriod); + + const result = await controller.markPaid(mockRequestUser, mockPeriodId, markPaidDto); + + expect(periodsService.markAsPaid).toHaveBeenCalledWith(mockTenantId, mockPeriodId, mockUserId, markPaidDto); + expect(result.status).toBe(PeriodStatus.PAID); + }); + }); + + describe('remove', () => { + it('should delete a period', async () => { + periodsService.remove.mockResolvedValue(undefined); + + const result = await controller.remove(mockRequestUser, mockPeriodId); + + expect(periodsService.remove).toHaveBeenCalledWith(mockTenantId, mockPeriodId); + expect(result).toEqual({ message: 'Commission period deleted successfully' }); + }); + }); + + describe('assignEntries', () => { + it('should assign entries to a period', async () => { + const entryIds = ['entry-1', 'entry-2', 'entry-3']; + periodsService.assignEntriesToPeriod.mockResolvedValue(3); + + const result = await controller.assignEntries(mockRequestUser, mockPeriodId, { entryIds }); + + expect(periodsService.assignEntriesToPeriod).toHaveBeenCalledWith(mockTenantId, mockPeriodId, entryIds); + expect(result).toEqual({ assigned: 3 }); + }); + + it('should return count of assigned entries', async () => { + const entryIds = ['entry-1', 'entry-2']; + periodsService.assignEntriesToPeriod.mockResolvedValue(1); + + const result = await controller.assignEntries(mockRequestUser, mockPeriodId, { entryIds }); + + expect(result.assigned).toBe(1); + }); + }); +}); diff --git a/src/modules/commissions/__tests__/schemes.controller.spec.ts b/src/modules/commissions/__tests__/schemes.controller.spec.ts new file mode 100644 index 0000000..805a88b --- /dev/null +++ b/src/modules/commissions/__tests__/schemes.controller.spec.ts @@ -0,0 +1,196 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SchemesController } from '../controllers/schemes.controller'; +import { SchemesService } from '../services/schemes.service'; +import { CreateSchemeDto, UpdateSchemeDto, SchemeListQueryDto } from '../dto'; +import { SchemeType, AppliesTo } from '../entities/commission-scheme.entity'; + +describe('SchemesController', () => { + let controller: SchemesController; + let schemesService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockSchemeId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockScheme = { + id: mockSchemeId, + tenantId: mockTenantId, + name: 'Sales Commission', + description: '10% on all sales', + 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, + }; + + const mockPaginatedSchemes = { + data: [mockScheme], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + beforeEach(async () => { + const mockSchemesService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + activate: jest.fn(), + deactivate: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [SchemesController], + providers: [ + { + provide: SchemesService, + useValue: mockSchemesService, + }, + ], + }).compile(); + + controller = module.get(SchemesController); + schemesService = module.get(SchemesService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated schemes', async () => { + const query: SchemeListQueryDto = { page: 1, limit: 20 }; + schemesService.findAll.mockResolvedValue(mockPaginatedSchemes); + + const result = await controller.findAll(mockRequestUser, query); + + expect(schemesService.findAll).toHaveBeenCalledWith(mockTenantId, query); + expect(result).toEqual(mockPaginatedSchemes); + }); + + it('should filter by type', async () => { + const query: SchemeListQueryDto = { page: 1, limit: 20, type: SchemeType.TIERED }; + schemesService.findAll.mockResolvedValue({ ...mockPaginatedSchemes, data: [] }); + + await controller.findAll(mockRequestUser, query); + + expect(schemesService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + }); + + describe('findOne', () => { + it('should return a scheme by id', async () => { + schemesService.findOne.mockResolvedValue(mockScheme); + + const result = await controller.findOne(mockRequestUser, mockSchemeId); + + expect(schemesService.findOne).toHaveBeenCalledWith(mockTenantId, mockSchemeId); + expect(result).toEqual(mockScheme); + }); + }); + + describe('create', () => { + it('should create a new scheme', async () => { + const createDto: CreateSchemeDto = { + name: 'Premium Commission', + type: SchemeType.PERCENTAGE, + rate: 15, + appliesTo: AppliesTo.PRODUCTS, + }; + const createdScheme = { ...mockScheme, ...createDto, id: 'new-scheme-id' }; + schemesService.create.mockResolvedValue(createdScheme); + + const result = await controller.create(mockRequestUser, createDto); + + expect(schemesService.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + expect(result).toEqual(createdScheme); + }); + + it('should create a tiered scheme', async () => { + const createDto: CreateSchemeDto = { + name: 'Tiered Commission', + type: SchemeType.TIERED, + tiers: [ + { minAmount: 0, maxAmount: 1000, rate: 5 }, + { minAmount: 1001, maxAmount: 5000, rate: 10 }, + { minAmount: 5001, rate: 15 }, + ], + }; + schemesService.create.mockResolvedValue({ ...mockScheme, ...createDto }); + + await controller.create(mockRequestUser, createDto); + + expect(schemesService.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + }); + }); + + describe('update', () => { + it('should update a scheme', async () => { + const updateDto: UpdateSchemeDto = { + rate: 12, + description: 'Updated commission description', + }; + const updatedScheme = { ...mockScheme, ...updateDto }; + schemesService.update.mockResolvedValue(updatedScheme); + + const result = await controller.update(mockRequestUser, mockSchemeId, updateDto); + + expect(schemesService.update).toHaveBeenCalledWith(mockTenantId, mockSchemeId, updateDto); + expect(result.rate).toBe(12); + }); + }); + + describe('remove', () => { + it('should delete a scheme', async () => { + schemesService.remove.mockResolvedValue(undefined); + + const result = await controller.remove(mockRequestUser, mockSchemeId); + + expect(schemesService.remove).toHaveBeenCalledWith(mockTenantId, mockSchemeId); + expect(result).toEqual({ message: 'Commission scheme deleted successfully' }); + }); + }); + + describe('activate', () => { + it('should activate a scheme', async () => { + const activatedScheme = { ...mockScheme, isActive: true }; + schemesService.activate.mockResolvedValue(activatedScheme); + + const result = await controller.activate(mockRequestUser, mockSchemeId); + + expect(schemesService.activate).toHaveBeenCalledWith(mockTenantId, mockSchemeId); + expect(result.isActive).toBe(true); + }); + }); + + describe('deactivate', () => { + it('should deactivate a scheme', async () => { + const deactivatedScheme = { ...mockScheme, isActive: false }; + schemesService.deactivate.mockResolvedValue(deactivatedScheme); + + const result = await controller.deactivate(mockRequestUser, mockSchemeId); + + expect(schemesService.deactivate).toHaveBeenCalledWith(mockTenantId, mockSchemeId); + expect(result.isActive).toBe(false); + }); + }); +}); diff --git a/src/modules/portfolio/__tests__/categories.controller.spec.ts b/src/modules/portfolio/__tests__/categories.controller.spec.ts new file mode 100644 index 0000000..907b67e --- /dev/null +++ b/src/modules/portfolio/__tests__/categories.controller.spec.ts @@ -0,0 +1,235 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CategoriesController } from '../controllers/categories.controller'; +import { CategoriesService } from '../services/categories.service'; +import { CreateCategoryDto, UpdateCategoryDto, CategoryListQueryDto } from '../dto'; + +describe('CategoriesController', () => { + let controller: CategoriesController; + let categoriesService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockCategoryId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockCategory = { + id: mockCategoryId, + tenantId: mockTenantId, + parentId: null, + name: 'Electronics', + slug: 'electronics', + description: 'Electronic products', + position: 1, + imageUrl: null, + color: '#3B82F6', + icon: 'laptop', + isActive: true, + metaTitle: null, + metaDescription: null, + customFields: {}, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + createdBy: mockUserId, + }; + + const mockPaginatedCategories = { + data: [mockCategory], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + const mockCategoryTree = [ + { + ...mockCategory, + children: [ + { + id: 'child-1', + name: 'Phones', + slug: 'phones', + parentId: mockCategoryId, + children: [], + }, + { + id: 'child-2', + name: 'Laptops', + slug: 'laptops', + parentId: mockCategoryId, + children: [], + }, + ], + }, + ]; + + beforeEach(async () => { + const mockCategoriesService = { + findAll: jest.fn(), + findOne: jest.fn(), + getTree: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [CategoriesController], + providers: [ + { + provide: CategoriesService, + useValue: mockCategoriesService, + }, + ], + }).compile(); + + controller = module.get(CategoriesController); + categoriesService = module.get(CategoriesService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated categories', async () => { + const query: CategoryListQueryDto = { page: 1, limit: 20 }; + categoriesService.findAll.mockResolvedValue(mockPaginatedCategories); + + const result = await controller.findAll(mockRequestUser, query); + + expect(categoriesService.findAll).toHaveBeenCalledWith(mockTenantId, query); + expect(result).toEqual(mockPaginatedCategories); + }); + + it('should filter by active status', async () => { + const query: CategoryListQueryDto = { page: 1, limit: 20, isActive: true }; + categoriesService.findAll.mockResolvedValue(mockPaginatedCategories); + + await controller.findAll(mockRequestUser, query); + + expect(categoriesService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + + it('should search by name', async () => { + const query: CategoryListQueryDto = { page: 1, limit: 20, search: 'electronics' }; + categoriesService.findAll.mockResolvedValue(mockPaginatedCategories); + + await controller.findAll(mockRequestUser, query); + + expect(categoriesService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + }); + + describe('getTree', () => { + it('should return category tree structure', async () => { + categoriesService.getTree.mockResolvedValue(mockCategoryTree); + + const result = await controller.getTree(mockRequestUser); + + expect(categoriesService.getTree).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockCategoryTree); + expect(result[0].children.length).toBe(2); + }); + }); + + describe('findOne', () => { + it('should return a category by id', async () => { + categoriesService.findOne.mockResolvedValue(mockCategory); + + const result = await controller.findOne(mockRequestUser, mockCategoryId); + + expect(categoriesService.findOne).toHaveBeenCalledWith(mockTenantId, mockCategoryId); + expect(result).toEqual(mockCategory); + }); + }); + + describe('create', () => { + it('should create a new category', async () => { + const createDto: CreateCategoryDto = { + name: 'Clothing', + slug: 'clothing', + description: 'Fashion and clothing', + }; + const createdCategory = { ...mockCategory, ...createDto, id: 'new-category-id' }; + categoriesService.create.mockResolvedValue(createdCategory); + + const result = await controller.create(mockRequestUser, createDto); + + expect(categoriesService.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + expect(result).toEqual(createdCategory); + }); + + it('should create a subcategory', async () => { + const createDto: CreateCategoryDto = { + name: 'Smartphones', + slug: 'smartphones', + parentId: mockCategoryId, + }; + const createdSubcategory = { ...mockCategory, ...createDto, id: 'sub-category-id' }; + categoriesService.create.mockResolvedValue(createdSubcategory); + + const result = await controller.create(mockRequestUser, createDto); + + expect(result.parentId).toBe(mockCategoryId); + }); + }); + + describe('update', () => { + it('should update a category', async () => { + const updateDto: UpdateCategoryDto = { + name: 'Updated Electronics', + color: '#EF4444', + }; + const updatedCategory = { ...mockCategory, ...updateDto }; + categoriesService.update.mockResolvedValue(updatedCategory); + + const result = await controller.update(mockRequestUser, mockCategoryId, updateDto); + + expect(categoriesService.update).toHaveBeenCalledWith(mockTenantId, mockCategoryId, updateDto); + expect(result.name).toBe('Updated Electronics'); + expect(result.color).toBe('#EF4444'); + }); + + it('should deactivate a category', async () => { + const updateDto: UpdateCategoryDto = { + isActive: false, + }; + const deactivatedCategory = { ...mockCategory, isActive: false }; + categoriesService.update.mockResolvedValue(deactivatedCategory); + + const result = await controller.update(mockRequestUser, mockCategoryId, updateDto); + + expect(result.isActive).toBe(false); + }); + + it('should move category to different parent', async () => { + const newParentId = 'new-parent-id'; + const updateDto: UpdateCategoryDto = { + parentId: newParentId, + }; + const movedCategory = { ...mockCategory, parentId: newParentId }; + categoriesService.update.mockResolvedValue(movedCategory); + + const result = await controller.update(mockRequestUser, mockCategoryId, updateDto); + + expect(result.parentId).toBe(newParentId); + }); + }); + + describe('remove', () => { + it('should delete a category', async () => { + categoriesService.remove.mockResolvedValue(undefined); + + const result = await controller.remove(mockRequestUser, mockCategoryId); + + expect(categoriesService.remove).toHaveBeenCalledWith(mockTenantId, mockCategoryId); + expect(result).toEqual({ message: 'Category deleted successfully' }); + }); + }); +}); diff --git a/src/modules/portfolio/__tests__/products.controller.spec.ts b/src/modules/portfolio/__tests__/products.controller.spec.ts new file mode 100644 index 0000000..9b0b05d --- /dev/null +++ b/src/modules/portfolio/__tests__/products.controller.spec.ts @@ -0,0 +1,367 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ProductsController } from '../controllers/products.controller'; +import { ProductsService } from '../services/products.service'; +import { + CreateProductDto, + UpdateProductDto, + UpdateProductStatusDto, + ProductListQueryDto, + CreateVariantDto, + UpdateVariantDto, + CreatePriceDto, + UpdatePriceDto, +} from '../dto'; +import { ProductType, ProductStatus } from '../entities/product.entity'; + +describe('ProductsController', () => { + let controller: ProductsController; + let productsService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockProductId = '550e8400-e29b-41d4-a716-446655440003'; + const mockVariantId = '550e8400-e29b-41d4-a716-446655440004'; + const mockPriceId = '550e8400-e29b-41d4-a716-446655440005'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockProduct = { + id: mockProductId, + tenantId: mockTenantId, + categoryId: 'cat-123', + name: 'Wireless Headphones', + slug: 'wireless-headphones', + sku: 'WH-001', + barcode: '1234567890123', + description: 'Premium wireless headphones', + shortDescription: 'High quality wireless headphones', + productType: ProductType.PHYSICAL, + status: ProductStatus.ACTIVE, + basePrice: 149.99, + costPrice: 80, + compareAtPrice: 199.99, + currency: 'USD', + trackInventory: true, + stockQuantity: 100, + lowStockThreshold: 10, + allowBackorder: false, + weight: 0.5, + images: [], + tags: ['audio', 'wireless'], + isVisible: true, + isFeatured: false, + hasVariants: true, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + createdBy: mockUserId, + }; + + const mockVariant = { + id: mockVariantId, + productId: mockProductId, + name: 'Black', + sku: 'WH-001-BLK', + price: 149.99, + stockQuantity: 50, + attributes: { color: 'Black' }, + isDefault: true, + createdAt: new Date('2026-01-01'), + }; + + const mockPrice = { + id: mockPriceId, + productId: mockProductId, + name: 'Retail', + amount: 149.99, + currency: 'USD', + isDefault: true, + minQuantity: 1, + createdAt: new Date('2026-01-01'), + }; + + const mockPaginatedProducts = { + data: [mockProduct], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + beforeEach(async () => { + const mockProductsService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateStatus: jest.fn(), + duplicate: jest.fn(), + remove: jest.fn(), + getVariants: jest.fn(), + createVariant: jest.fn(), + updateVariant: jest.fn(), + removeVariant: jest.fn(), + getPrices: jest.fn(), + createPrice: jest.fn(), + updatePrice: jest.fn(), + removePrice: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ProductsController], + providers: [ + { + provide: ProductsService, + useValue: mockProductsService, + }, + ], + }).compile(); + + controller = module.get(ProductsController); + productsService = module.get(ProductsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ============================================ + // Products Tests + // ============================================ + + describe('findAll', () => { + it('should return paginated products', async () => { + const query: ProductListQueryDto = { page: 1, limit: 20 }; + productsService.findAll.mockResolvedValue(mockPaginatedProducts); + + const result = await controller.findAll(mockRequestUser, query); + + expect(productsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + expect(result).toEqual(mockPaginatedProducts); + }); + + it('should filter by status', async () => { + const query: ProductListQueryDto = { page: 1, limit: 20, status: ProductStatus.ACTIVE }; + productsService.findAll.mockResolvedValue(mockPaginatedProducts); + + await controller.findAll(mockRequestUser, query); + + expect(productsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + + it('should filter by category', async () => { + const query: ProductListQueryDto = { page: 1, limit: 20, categoryId: 'cat-123' }; + productsService.findAll.mockResolvedValue(mockPaginatedProducts); + + await controller.findAll(mockRequestUser, query); + + expect(productsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + }); + + describe('findOne', () => { + it('should return a product by id', async () => { + productsService.findOne.mockResolvedValue(mockProduct); + + const result = await controller.findOne(mockRequestUser, mockProductId); + + expect(productsService.findOne).toHaveBeenCalledWith(mockTenantId, mockProductId); + expect(result).toEqual(mockProduct); + }); + }); + + describe('create', () => { + it('should create a new product', async () => { + const createDto: CreateProductDto = { + name: 'New Headphones', + slug: 'new-headphones', + basePrice: 99.99, + productType: ProductType.PHYSICAL, + }; + const createdProduct = { ...mockProduct, ...createDto, id: 'new-product-id' }; + productsService.create.mockResolvedValue(createdProduct); + + const result = await controller.create(mockRequestUser, createDto); + + expect(productsService.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + expect(result).toEqual(createdProduct); + }); + }); + + describe('update', () => { + it('should update a product', async () => { + const updateDto: UpdateProductDto = { + name: 'Updated Headphones', + basePrice: 129.99, + }; + const updatedProduct = { ...mockProduct, ...updateDto }; + productsService.update.mockResolvedValue(updatedProduct); + + const result = await controller.update(mockRequestUser, mockProductId, updateDto); + + expect(productsService.update).toHaveBeenCalledWith(mockTenantId, mockProductId, updateDto); + expect(result.name).toBe('Updated Headphones'); + }); + }); + + describe('updateStatus', () => { + it('should update product status', async () => { + const statusDto: UpdateProductStatusDto = { + status: ProductStatus.INACTIVE, + }; + const updatedProduct = { ...mockProduct, status: ProductStatus.INACTIVE }; + productsService.updateStatus.mockResolvedValue(updatedProduct); + + const result = await controller.updateStatus(mockRequestUser, mockProductId, statusDto); + + expect(productsService.updateStatus).toHaveBeenCalledWith(mockTenantId, mockProductId, statusDto); + expect(result.status).toBe(ProductStatus.INACTIVE); + }); + }); + + describe('duplicate', () => { + it('should duplicate a product', async () => { + const duplicatedProduct = { ...mockProduct, id: 'duplicated-id', name: 'Wireless Headphones (Copy)' }; + productsService.duplicate.mockResolvedValue(duplicatedProduct); + + const result = await controller.duplicate(mockRequestUser, mockProductId); + + expect(productsService.duplicate).toHaveBeenCalledWith(mockTenantId, mockUserId, mockProductId); + expect(result.id).toBe('duplicated-id'); + }); + }); + + describe('remove', () => { + it('should delete a product', async () => { + productsService.remove.mockResolvedValue(undefined); + + const result = await controller.remove(mockRequestUser, mockProductId); + + expect(productsService.remove).toHaveBeenCalledWith(mockTenantId, mockProductId); + expect(result).toEqual({ message: 'Product deleted successfully' }); + }); + }); + + // ============================================ + // Variants Tests + // ============================================ + + describe('getVariants', () => { + it('should return product variants', async () => { + productsService.getVariants.mockResolvedValue([mockVariant]); + + const result = await controller.getVariants(mockRequestUser, mockProductId); + + expect(productsService.getVariants).toHaveBeenCalledWith(mockTenantId, mockProductId); + expect(result).toEqual([mockVariant]); + }); + }); + + describe('createVariant', () => { + it('should create a product variant', async () => { + const createDto: CreateVariantDto = { + name: 'White', + sku: 'WH-001-WHT', + price: 149.99, + attributes: { color: 'White' }, + }; + const createdVariant = { ...mockVariant, ...createDto, id: 'new-variant-id' }; + productsService.createVariant.mockResolvedValue(createdVariant); + + const result = await controller.createVariant(mockRequestUser, mockProductId, createDto); + + expect(productsService.createVariant).toHaveBeenCalledWith(mockTenantId, mockProductId, createDto); + expect(result).toEqual(createdVariant); + }); + }); + + describe('updateVariant', () => { + it('should update a product variant', async () => { + const updateDto: UpdateVariantDto = { + price: 139.99, + stockQuantity: 75, + }; + const updatedVariant = { ...mockVariant, ...updateDto }; + productsService.updateVariant.mockResolvedValue(updatedVariant); + + const result = await controller.updateVariant(mockRequestUser, mockProductId, mockVariantId, updateDto); + + expect(productsService.updateVariant).toHaveBeenCalledWith(mockTenantId, mockProductId, mockVariantId, updateDto); + expect(result.price).toBe(139.99); + }); + }); + + describe('removeVariant', () => { + it('should delete a product variant', async () => { + productsService.removeVariant.mockResolvedValue(undefined); + + const result = await controller.removeVariant(mockRequestUser, mockProductId, mockVariantId); + + expect(productsService.removeVariant).toHaveBeenCalledWith(mockTenantId, mockProductId, mockVariantId); + expect(result).toEqual({ message: 'Variant deleted successfully' }); + }); + }); + + // ============================================ + // Prices Tests + // ============================================ + + describe('getPrices', () => { + it('should return product prices', async () => { + productsService.getPrices.mockResolvedValue([mockPrice]); + + const result = await controller.getPrices(mockRequestUser, mockProductId); + + expect(productsService.getPrices).toHaveBeenCalledWith(mockTenantId, mockProductId); + expect(result).toEqual([mockPrice]); + }); + }); + + describe('createPrice', () => { + it('should create a product price', async () => { + const createDto: CreatePriceDto = { + name: 'Wholesale', + amount: 99.99, + currency: 'USD', + minQuantity: 10, + }; + const createdPrice = { ...mockPrice, ...createDto, id: 'new-price-id' }; + productsService.createPrice.mockResolvedValue(createdPrice); + + const result = await controller.createPrice(mockRequestUser, mockProductId, createDto); + + expect(productsService.createPrice).toHaveBeenCalledWith(mockTenantId, mockProductId, createDto); + expect(result).toEqual(createdPrice); + }); + }); + + describe('updatePrice', () => { + it('should update a product price', async () => { + const updateDto: UpdatePriceDto = { + amount: 159.99, + }; + const updatedPrice = { ...mockPrice, ...updateDto }; + productsService.updatePrice.mockResolvedValue(updatedPrice); + + const result = await controller.updatePrice(mockRequestUser, mockProductId, mockPriceId, updateDto); + + expect(productsService.updatePrice).toHaveBeenCalledWith(mockTenantId, mockProductId, mockPriceId, updateDto); + expect(result.amount).toBe(159.99); + }); + }); + + describe('removePrice', () => { + it('should delete a product price', async () => { + productsService.removePrice.mockResolvedValue(undefined); + + const result = await controller.removePrice(mockRequestUser, mockProductId, mockPriceId); + + expect(productsService.removePrice).toHaveBeenCalledWith(mockTenantId, mockProductId, mockPriceId); + expect(result).toEqual({ message: 'Price deleted successfully' }); + }); + }); +}); diff --git a/src/modules/sales/__tests__/activities.controller.spec.ts b/src/modules/sales/__tests__/activities.controller.spec.ts new file mode 100644 index 0000000..fd2d54a --- /dev/null +++ b/src/modules/sales/__tests__/activities.controller.spec.ts @@ -0,0 +1,179 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ActivitiesController } from '../controllers/activities.controller'; +import { ActivitiesService } from '../services/activities.service'; +import { CreateActivityDto, UpdateActivityDto, CompleteActivityDto, ActivityListQueryDto } from '../dto'; +import { ActivityType, ActivityStatus } from '../entities/activity.entity'; + +describe('ActivitiesController', () => { + let controller: ActivitiesController; + let activitiesService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockActivityId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockActivity = { + id: mockActivityId, + tenantId: mockTenantId, + type: ActivityType.CALL, + status: ActivityStatus.PENDING, + subject: 'Follow up call', + description: 'Discuss proposal', + dueDate: new Date('2026-02-10'), + assignedTo: mockUserId, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + createdBy: mockUserId, + }; + + const mockPaginatedActivities = { + data: [mockActivity], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + beforeEach(async () => { + const mockActivitiesService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + complete: jest.fn(), + remove: jest.fn(), + getUpcoming: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ActivitiesController], + providers: [ + { + provide: ActivitiesService, + useValue: mockActivitiesService, + }, + ], + }).compile(); + + controller = module.get(ActivitiesController); + activitiesService = module.get(ActivitiesService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated activities', async () => { + const query: ActivityListQueryDto = { page: 1, limit: 20 }; + activitiesService.findAll.mockResolvedValue(mockPaginatedActivities); + + const result = await controller.findAll(mockRequestUser, query); + + expect(activitiesService.findAll).toHaveBeenCalledWith(mockTenantId, query); + expect(result).toEqual(mockPaginatedActivities); + }); + + it('should filter by type', async () => { + const query: ActivityListQueryDto = { page: 1, limit: 20, type: ActivityType.MEETING }; + activitiesService.findAll.mockResolvedValue({ ...mockPaginatedActivities, data: [] }); + + await controller.findAll(mockRequestUser, query); + + expect(activitiesService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + }); + + describe('getUpcoming', () => { + it('should return upcoming activities', async () => { + activitiesService.getUpcoming.mockResolvedValue([mockActivity]); + + const result = await controller.getUpcoming(mockRequestUser); + + expect(activitiesService.getUpcoming).toHaveBeenCalledWith(mockTenantId, mockUserId); + expect(result).toEqual([mockActivity]); + }); + }); + + describe('findOne', () => { + it('should return an activity by id', async () => { + activitiesService.findOne.mockResolvedValue(mockActivity); + + const result = await controller.findOne(mockRequestUser, mockActivityId); + + expect(activitiesService.findOne).toHaveBeenCalledWith(mockTenantId, mockActivityId); + expect(result).toEqual(mockActivity); + }); + }); + + describe('create', () => { + it('should create a new activity', async () => { + const createDto: CreateActivityDto = { + type: ActivityType.CALL, + subject: 'Initial call', + dueDate: '2026-02-15', + }; + const createdActivity = { ...mockActivity, ...createDto, id: 'new-activity-id' }; + activitiesService.create.mockResolvedValue(createdActivity); + + const result = await controller.create(mockRequestUser, createDto); + + expect(activitiesService.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + expect(result).toEqual(createdActivity); + }); + }); + + describe('update', () => { + it('should update an activity', async () => { + const updateDto: UpdateActivityDto = { + subject: 'Updated call subject', + status: ActivityStatus.COMPLETED, + }; + const updatedActivity = { ...mockActivity, ...updateDto }; + activitiesService.update.mockResolvedValue(updatedActivity); + + const result = await controller.update(mockRequestUser, mockActivityId, updateDto); + + expect(activitiesService.update).toHaveBeenCalledWith(mockTenantId, mockActivityId, updateDto); + expect(result.subject).toBe('Updated call subject'); + }); + }); + + describe('complete', () => { + it('should complete an activity', async () => { + const completeDto: CompleteActivityDto = { + outcome: 'Discussed proposal details', + }; + const completedActivity = { + ...mockActivity, + status: ActivityStatus.COMPLETED, + completedAt: new Date(), + outcome: completeDto.outcome, + }; + activitiesService.complete.mockResolvedValue(completedActivity); + + const result = await controller.complete(mockRequestUser, mockActivityId, completeDto); + + expect(activitiesService.complete).toHaveBeenCalledWith(mockTenantId, mockActivityId, completeDto); + expect(result.status).toBe(ActivityStatus.COMPLETED); + }); + }); + + describe('remove', () => { + it('should delete an activity', async () => { + activitiesService.remove.mockResolvedValue(undefined); + + const result = await controller.remove(mockRequestUser, mockActivityId); + + expect(activitiesService.remove).toHaveBeenCalledWith(mockTenantId, mockActivityId); + expect(result).toEqual({ message: 'Activity deleted successfully' }); + }); + }); +}); diff --git a/src/modules/sales/__tests__/dashboard.controller.spec.ts b/src/modules/sales/__tests__/dashboard.controller.spec.ts new file mode 100644 index 0000000..c7497ce --- /dev/null +++ b/src/modules/sales/__tests__/dashboard.controller.spec.ts @@ -0,0 +1,171 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SalesDashboardController } from '../controllers/dashboard.controller'; +import { SalesDashboardService } from '../services/sales-dashboard.service'; +import { LeadStatus, LeadSource } from '../entities/lead.entity'; +import { OpportunityStage } from '../entities/opportunity.entity'; + +describe('SalesDashboardController', () => { + let controller: SalesDashboardController; + let dashboardService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockDashboardSummary = { + totalLeads: 150, + newLeadsThisMonth: 25, + qualifiedLeads: 45, + convertedLeads: 30, + totalOpportunities: 80, + openOpportunities: 50, + wonOpportunities: 25, + lostOpportunities: 5, + totalPipelineValue: 500000, + wonValue: 150000, + averageDealSize: 6000, + winRate: 83.33, + }; + + const mockLeadsByStatus = [ + { status: LeadStatus.NEW, count: 50 }, + { status: LeadStatus.CONTACTED, count: 35 }, + { status: LeadStatus.QUALIFIED, count: 45 }, + { status: LeadStatus.CONVERTED, count: 20 }, + ]; + + const mockLeadsBySource = [ + { source: LeadSource.WEBSITE, count: 60 }, + { source: LeadSource.REFERRAL, count: 40 }, + { source: LeadSource.SOCIAL_MEDIA, count: 25 }, + { source: LeadSource.EVENT, count: 25 }, + ]; + + const mockOpportunitiesByStage = [ + { stage: OpportunityStage.PROSPECTING, count: 15, value: 75000 }, + { stage: OpportunityStage.QUALIFICATION, count: 12, value: 60000 }, + { stage: OpportunityStage.PROPOSAL, count: 10, value: 100000 }, + { stage: OpportunityStage.NEGOTIATION, count: 8, value: 120000 }, + { stage: OpportunityStage.CLOSED_WON, count: 25, value: 150000 }, + ]; + + const mockConversionFunnel = [ + { stage: 'Lead Created', count: 150, percentage: 100 }, + { stage: 'Qualified', count: 45, percentage: 30 }, + { stage: 'Opportunity Created', count: 35, percentage: 23 }, + { stage: 'Proposal Sent', count: 25, percentage: 17 }, + { stage: 'Won', count: 20, percentage: 13 }, + ]; + + const mockSalesPerformance = [ + { userId: mockUserId, name: 'John Doe', leadsConverted: 10, opportunitiesWon: 5, totalValue: 50000 }, + { userId: 'user-2', name: 'Jane Smith', leadsConverted: 8, opportunitiesWon: 4, totalValue: 40000 }, + ]; + + beforeEach(async () => { + const mockDashboardService = { + getDashboardSummary: jest.fn(), + getLeadsByStatus: jest.fn(), + getLeadsBySource: jest.fn(), + getOpportunitiesByStage: jest.fn(), + getConversionFunnel: jest.fn(), + getSalesPerformance: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [SalesDashboardController], + providers: [ + { + provide: SalesDashboardService, + useValue: mockDashboardService, + }, + ], + }).compile(); + + controller = module.get(SalesDashboardController); + dashboardService = module.get(SalesDashboardService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getSummary', () => { + it('should return dashboard summary', async () => { + dashboardService.getDashboardSummary.mockResolvedValue(mockDashboardSummary); + + const result = await controller.getSummary(mockRequestUser); + + expect(dashboardService.getDashboardSummary).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockDashboardSummary); + expect(result.totalLeads).toBe(150); + expect(result.winRate).toBe(83.33); + }); + }); + + describe('getLeadsByStatus', () => { + it('should return leads grouped by status', async () => { + dashboardService.getLeadsByStatus.mockResolvedValue(mockLeadsByStatus); + + const result = await controller.getLeadsByStatus(mockRequestUser); + + expect(dashboardService.getLeadsByStatus).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockLeadsByStatus); + expect(result.length).toBe(4); + }); + }); + + describe('getLeadsBySource', () => { + it('should return leads grouped by source', async () => { + dashboardService.getLeadsBySource.mockResolvedValue(mockLeadsBySource); + + const result = await controller.getLeadsBySource(mockRequestUser); + + expect(dashboardService.getLeadsBySource).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockLeadsBySource); + expect(result.length).toBe(4); + }); + }); + + describe('getOpportunitiesByStage', () => { + it('should return opportunities grouped by stage', async () => { + dashboardService.getOpportunitiesByStage.mockResolvedValue(mockOpportunitiesByStage); + + const result = await controller.getOpportunitiesByStage(mockRequestUser); + + expect(dashboardService.getOpportunitiesByStage).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockOpportunitiesByStage); + expect(result.length).toBe(5); + }); + }); + + describe('getConversionFunnel', () => { + it('should return conversion funnel data', async () => { + dashboardService.getConversionFunnel.mockResolvedValue(mockConversionFunnel); + + const result = await controller.getConversionFunnel(mockRequestUser); + + expect(dashboardService.getConversionFunnel).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockConversionFunnel); + expect(result[0].percentage).toBe(100); + }); + }); + + describe('getSalesPerformance', () => { + it('should return sales performance by user', async () => { + dashboardService.getSalesPerformance.mockResolvedValue(mockSalesPerformance); + + const result = await controller.getSalesPerformance(mockRequestUser); + + expect(dashboardService.getSalesPerformance).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockSalesPerformance); + expect(result.length).toBe(2); + }); + }); +}); diff --git a/src/modules/sales/__tests__/leads.controller.spec.ts b/src/modules/sales/__tests__/leads.controller.spec.ts new file mode 100644 index 0000000..2a52fa0 --- /dev/null +++ b/src/modules/sales/__tests__/leads.controller.spec.ts @@ -0,0 +1,219 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LeadsController } from '../controllers/leads.controller'; +import { LeadsService } from '../services/leads.service'; +import { CreateLeadDto, UpdateLeadDto, ConvertLeadDto, LeadListQueryDto } from '../dto'; +import { LeadStatus, LeadSource } from '../entities/lead.entity'; + +describe('LeadsController', () => { + let controller: LeadsController; + let leadsService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockLeadId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockLead = { + id: mockLeadId, + tenantId: mockTenantId, + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + phone: '+1234567890', + company: 'Acme Corp', + jobTitle: 'CEO', + source: LeadSource.WEBSITE, + status: LeadStatus.NEW, + score: 50, + assignedTo: mockUserId, + notes: 'Interested in enterprise plan', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + createdBy: mockUserId, + }; + + const mockPaginatedLeads = { + data: [mockLead], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + beforeEach(async () => { + const mockLeadsService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + convert: jest.fn(), + calculateScore: jest.fn(), + assign: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [LeadsController], + providers: [ + { + provide: LeadsService, + useValue: mockLeadsService, + }, + ], + }).compile(); + + controller = module.get(LeadsController); + leadsService = module.get(LeadsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated leads', async () => { + const query: LeadListQueryDto = { page: 1, limit: 20 }; + leadsService.findAll.mockResolvedValue(mockPaginatedLeads); + + const result = await controller.findAll(mockRequestUser, query); + + expect(leadsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + expect(result).toEqual(mockPaginatedLeads); + }); + + it('should filter by status', async () => { + const query: LeadListQueryDto = { page: 1, limit: 20, status: LeadStatus.QUALIFIED }; + leadsService.findAll.mockResolvedValue({ ...mockPaginatedLeads, data: [] }); + + await controller.findAll(mockRequestUser, query); + + expect(leadsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + + it('should filter by search term', async () => { + const query: LeadListQueryDto = { page: 1, limit: 20, search: 'john' }; + leadsService.findAll.mockResolvedValue(mockPaginatedLeads); + + await controller.findAll(mockRequestUser, query); + + expect(leadsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + }); + + describe('findOne', () => { + it('should return a lead by id', async () => { + leadsService.findOne.mockResolvedValue(mockLead); + + const result = await controller.findOne(mockRequestUser, mockLeadId); + + expect(leadsService.findOne).toHaveBeenCalledWith(mockTenantId, mockLeadId); + expect(result).toEqual(mockLead); + }); + }); + + describe('create', () => { + it('should create a new lead', async () => { + const createDto: CreateLeadDto = { + firstName: 'Jane', + lastName: 'Smith', + email: 'jane.smith@example.com', + source: LeadSource.REFERRAL, + }; + const createdLead = { ...mockLead, ...createDto, id: 'new-lead-id' }; + leadsService.create.mockResolvedValue(createdLead); + + const result = await controller.create(mockRequestUser, createDto); + + expect(leadsService.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + expect(result).toEqual(createdLead); + }); + + it('should create lead with all fields', async () => { + const createDto: CreateLeadDto = { + firstName: 'Jane', + lastName: 'Smith', + email: 'jane.smith@example.com', + phone: '+1987654321', + company: 'Tech Corp', + jobTitle: 'CTO', + source: LeadSource.EVENT, + notes: 'Met at conference', + }; + leadsService.create.mockResolvedValue({ ...mockLead, ...createDto }); + + await controller.create(mockRequestUser, createDto); + + expect(leadsService.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + }); + }); + + describe('update', () => { + it('should update a lead', async () => { + const updateDto: UpdateLeadDto = { + status: LeadStatus.CONTACTED, + notes: 'Followed up via phone', + }; + const updatedLead = { ...mockLead, ...updateDto }; + leadsService.update.mockResolvedValue(updatedLead); + + const result = await controller.update(mockRequestUser, mockLeadId, updateDto); + + expect(leadsService.update).toHaveBeenCalledWith(mockTenantId, mockLeadId, updateDto); + expect(result.status).toBe(LeadStatus.CONTACTED); + }); + + it('should update lead score', async () => { + const updateDto: UpdateLeadDto = { score: 85 }; + leadsService.update.mockResolvedValue({ ...mockLead, score: 85 }); + + const result = await controller.update(mockRequestUser, mockLeadId, updateDto); + + expect(result.score).toBe(85); + }); + }); + + describe('remove', () => { + it('should delete a lead', async () => { + leadsService.remove.mockResolvedValue(undefined); + + const result = await controller.remove(mockRequestUser, mockLeadId); + + expect(leadsService.remove).toHaveBeenCalledWith(mockTenantId, mockLeadId); + expect(result).toEqual({ message: 'Lead deleted successfully' }); + }); + }); + + describe('convert', () => { + it('should convert lead to opportunity', async () => { + const convertDto: ConvertLeadDto = { + opportunityName: 'Enterprise Deal', + amount: 50000, + expectedCloseDate: '2026-03-01', + }; + const opportunityId = 'new-opportunity-id'; + leadsService.convert.mockResolvedValue({ opportunityId }); + + const result = await controller.convert(mockRequestUser, mockLeadId, convertDto); + + expect(leadsService.convert).toHaveBeenCalledWith(mockTenantId, mockLeadId, convertDto); + expect(result).toEqual({ opportunityId }); + }); + }); + + describe('calculateScore', () => { + it('should calculate lead score', async () => { + leadsService.calculateScore.mockResolvedValue({ score: 75 }); + + const result = await controller.calculateScore(mockRequestUser, mockLeadId); + + expect(leadsService.calculateScore).toHaveBeenCalledWith(mockTenantId, mockLeadId); + expect(result).toEqual({ score: 75 }); + }); + }); +}); diff --git a/src/modules/sales/__tests__/opportunities.controller.spec.ts b/src/modules/sales/__tests__/opportunities.controller.spec.ts new file mode 100644 index 0000000..b1392a3 --- /dev/null +++ b/src/modules/sales/__tests__/opportunities.controller.spec.ts @@ -0,0 +1,200 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OpportunitiesController } from '../controllers/opportunities.controller'; +import { OpportunitiesService } from '../services/opportunities.service'; +import { CreateOpportunityDto, UpdateOpportunityDto, UpdateOpportunityStageDto, OpportunityListQueryDto } from '../dto'; +import { OpportunityStage } from '../entities/opportunity.entity'; + +describe('OpportunitiesController', () => { + let controller: OpportunitiesController; + let opportunitiesService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockOpportunityId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockOpportunity = { + id: mockOpportunityId, + tenantId: mockTenantId, + name: 'Enterprise Deal', + description: 'Large enterprise contract', + stage: OpportunityStage.PROSPECTING, + amount: 50000, + currency: 'USD', + probability: 25, + expectedCloseDate: new Date('2026-03-01'), + assignedTo: mockUserId, + contactName: 'John Doe', + contactEmail: 'john@example.com', + companyName: 'Acme Corp', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + createdBy: mockUserId, + }; + + const mockPaginatedOpportunities = { + data: [mockOpportunity], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + const mockPipelineSummary = [ + { stage: OpportunityStage.PROSPECTING, count: 10, value: 100000 }, + { stage: OpportunityStage.QUALIFICATION, count: 8, value: 80000 }, + { stage: OpportunityStage.PROPOSAL, count: 5, value: 75000 }, + { stage: OpportunityStage.NEGOTIATION, count: 3, value: 90000 }, + { stage: OpportunityStage.CLOSED_WON, count: 2, value: 60000 }, + ]; + + beforeEach(async () => { + const mockOpportunitiesService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateStage: jest.fn(), + remove: jest.fn(), + getPipelineSummary: jest.fn(), + assignTo: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [OpportunitiesController], + providers: [ + { + provide: OpportunitiesService, + useValue: mockOpportunitiesService, + }, + ], + }).compile(); + + controller = module.get(OpportunitiesController); + opportunitiesService = module.get(OpportunitiesService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated opportunities', async () => { + const query: OpportunityListQueryDto = { page: 1, limit: 20 }; + opportunitiesService.findAll.mockResolvedValue(mockPaginatedOpportunities); + + const result = await controller.findAll(mockRequestUser, query); + + expect(opportunitiesService.findAll).toHaveBeenCalledWith(mockTenantId, query); + expect(result).toEqual(mockPaginatedOpportunities); + }); + + it('should filter by stage', async () => { + const query: OpportunityListQueryDto = { page: 1, limit: 20, stage: OpportunityStage.PROPOSAL }; + opportunitiesService.findAll.mockResolvedValue({ ...mockPaginatedOpportunities, data: [] }); + + await controller.findAll(mockRequestUser, query); + + expect(opportunitiesService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + }); + + describe('getPipelineSummary', () => { + it('should return pipeline summary', async () => { + opportunitiesService.getPipelineSummary.mockResolvedValue(mockPipelineSummary); + + const result = await controller.getPipelineSummary(mockRequestUser); + + expect(opportunitiesService.getPipelineSummary).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockPipelineSummary); + expect(result.length).toBe(5); + }); + }); + + describe('findOne', () => { + it('should return an opportunity by id', async () => { + opportunitiesService.findOne.mockResolvedValue(mockOpportunity); + + const result = await controller.findOne(mockRequestUser, mockOpportunityId); + + expect(opportunitiesService.findOne).toHaveBeenCalledWith(mockTenantId, mockOpportunityId); + expect(result).toEqual(mockOpportunity); + }); + }); + + describe('create', () => { + it('should create a new opportunity', async () => { + const createDto: CreateOpportunityDto = { + name: 'New Deal', + amount: 25000, + expectedCloseDate: '2026-04-01', + }; + const createdOpportunity = { ...mockOpportunity, ...createDto, id: 'new-opp-id' }; + opportunitiesService.create.mockResolvedValue(createdOpportunity); + + const result = await controller.create(mockRequestUser, createDto); + + expect(opportunitiesService.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + expect(result).toEqual(createdOpportunity); + }); + }); + + describe('update', () => { + it('should update an opportunity', async () => { + const updateDto: UpdateOpportunityDto = { + amount: 75000, + probability: 50, + }; + const updatedOpportunity = { ...mockOpportunity, ...updateDto }; + opportunitiesService.update.mockResolvedValue(updatedOpportunity); + + const result = await controller.update(mockRequestUser, mockOpportunityId, updateDto); + + expect(opportunitiesService.update).toHaveBeenCalledWith(mockTenantId, mockOpportunityId, updateDto); + expect(result.amount).toBe(75000); + }); + }); + + describe('updateStage', () => { + it('should update opportunity stage', async () => { + const stageDto: UpdateOpportunityStageDto = { stage: OpportunityStage.NEGOTIATION }; + const updatedOpportunity = { ...mockOpportunity, stage: OpportunityStage.NEGOTIATION, probability: 60 }; + opportunitiesService.updateStage.mockResolvedValue(updatedOpportunity); + + const result = await controller.updateStage(mockRequestUser, mockOpportunityId, stageDto); + + expect(opportunitiesService.updateStage).toHaveBeenCalledWith(mockTenantId, mockOpportunityId, stageDto); + expect(result.stage).toBe(OpportunityStage.NEGOTIATION); + }); + }); + + describe('remove', () => { + it('should delete an opportunity', async () => { + opportunitiesService.remove.mockResolvedValue(undefined); + + const result = await controller.remove(mockRequestUser, mockOpportunityId); + + expect(opportunitiesService.remove).toHaveBeenCalledWith(mockTenantId, mockOpportunityId); + expect(result).toEqual({ message: 'Opportunity deleted successfully' }); + }); + }); + + describe('assign', () => { + it('should assign opportunity to a user', async () => { + const newUserId = 'new-user-id'; + const assignedOpportunity = { ...mockOpportunity, assignedTo: newUserId }; + opportunitiesService.assignTo.mockResolvedValue(assignedOpportunity); + + const result = await controller.assign(mockRequestUser, mockOpportunityId, newUserId); + + expect(opportunitiesService.assignTo).toHaveBeenCalledWith(mockTenantId, mockOpportunityId, newUserId); + expect(result.assignedTo).toBe(newUserId); + }); + }); +}); diff --git a/src/modules/sales/__tests__/pipeline.controller.spec.ts b/src/modules/sales/__tests__/pipeline.controller.spec.ts new file mode 100644 index 0000000..cef8903 --- /dev/null +++ b/src/modules/sales/__tests__/pipeline.controller.spec.ts @@ -0,0 +1,166 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PipelineController } from '../controllers/pipeline.controller'; +import { PipelineService } from '../services/pipeline.service'; +import { CreatePipelineStageDto, UpdatePipelineStageDto, ReorderStagesDto } from '../dto'; + +describe('PipelineController', () => { + let controller: PipelineController; + let pipelineService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockStageId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockPipelineStage = { + id: mockStageId, + tenantId: mockTenantId, + name: 'Qualification', + code: 'qualification', + description: 'Qualification stage', + displayOrder: 1, + color: '#3498db', + isActive: true, + isDefault: false, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }; + + const mockPipelineStages = [ + mockPipelineStage, + { ...mockPipelineStage, id: 'stage-2', name: 'Proposal', code: 'proposal', displayOrder: 2 }, + ]; + + beforeEach(async () => { + const mockPipelineService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + reorder: jest.fn(), + initializeDefaults: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [PipelineController], + providers: [ + { + provide: PipelineService, + useValue: mockPipelineService, + }, + ], + }).compile(); + + controller = module.get(PipelineController); + pipelineService = module.get(PipelineService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return all pipeline stages', async () => { + pipelineService.findAll.mockResolvedValue(mockPipelineStages); + + const result = await controller.findAll(mockRequestUser); + + expect(pipelineService.findAll).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockPipelineStages); + expect(result.length).toBe(2); + }); + }); + + describe('findOne', () => { + it('should return a pipeline stage by id', async () => { + pipelineService.findOne.mockResolvedValue(mockPipelineStage); + + const result = await controller.findOne(mockRequestUser, mockStageId); + + expect(pipelineService.findOne).toHaveBeenCalledWith(mockTenantId, mockStageId); + expect(result).toEqual(mockPipelineStage); + }); + }); + + describe('create', () => { + it('should create a new pipeline stage', async () => { + const createDto: CreatePipelineStageDto = { + name: 'New Stage', + code: 'new_stage', + displayOrder: 3, + }; + const createdStage = { ...mockPipelineStage, ...createDto, id: 'new-stage-id' }; + pipelineService.create.mockResolvedValue(createdStage); + + const result = await controller.create(mockRequestUser, createDto); + + expect(pipelineService.create).toHaveBeenCalledWith(mockTenantId, createDto); + expect(result).toEqual(createdStage); + }); + }); + + describe('update', () => { + it('should update a pipeline stage', async () => { + const updateDto: UpdatePipelineStageDto = { + name: 'Updated Stage Name', + color: '#e74c3c', + }; + const updatedStage = { ...mockPipelineStage, ...updateDto }; + pipelineService.update.mockResolvedValue(updatedStage); + + const result = await controller.update(mockRequestUser, mockStageId, updateDto); + + expect(pipelineService.update).toHaveBeenCalledWith(mockTenantId, mockStageId, updateDto); + expect(result.name).toBe('Updated Stage Name'); + expect(result.color).toBe('#e74c3c'); + }); + }); + + describe('remove', () => { + it('should delete a pipeline stage', async () => { + pipelineService.remove.mockResolvedValue(undefined); + + const result = await controller.remove(mockRequestUser, mockStageId); + + expect(pipelineService.remove).toHaveBeenCalledWith(mockTenantId, mockStageId); + expect(result).toEqual({ message: 'Pipeline stage deleted successfully' }); + }); + }); + + describe('reorder', () => { + it('should reorder pipeline stages', async () => { + const reorderDto: ReorderStagesDto = { + stageIds: ['stage-2', mockStageId], + }; + const reorderedStages = [ + { ...mockPipelineStages[1], displayOrder: 1 }, + { ...mockPipelineStages[0], displayOrder: 2 }, + ]; + pipelineService.reorder.mockResolvedValue(reorderedStages); + + const result = await controller.reorder(mockRequestUser, reorderDto); + + expect(pipelineService.reorder).toHaveBeenCalledWith(mockTenantId, reorderDto); + expect(result[0].displayOrder).toBe(1); + expect(result[1].displayOrder).toBe(2); + }); + }); + + describe('initializeDefaults', () => { + it('should initialize default pipeline stages', async () => { + pipelineService.initializeDefaults.mockResolvedValue(undefined); + + const result = await controller.initializeDefaults(mockRequestUser); + + expect(pipelineService.initializeDefaults).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual({ message: 'Default pipeline stages initialized' }); + }); + }); +});