From 5b0e61c029d019d38ec9fe1087b5bdeed4283b76 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 27 Jan 2026 12:53:39 -0600 Subject: [PATCH] [TASK-007] feat: P1 complete - billing entities + unit tests ## T-01.5: Billing entities - invoice.entity.ts: Added hosted_invoice_url, customer_name, customer_email, billing_address, notes ## T-05.1: Sales tests (109 tests, 100% coverage) - leads.service.spec.ts - opportunities.service.spec.ts - activities.service.spec.ts - pipeline.service.spec.ts ## T-05.2: Commissions tests (136 tests, 100% coverage) - schemes.service.spec.ts - assignments.service.spec.ts - entries.service.spec.ts - periods.service.spec.ts ## T-05.3: Portfolio tests (79 tests, 100% coverage) - categories.service.spec.ts - products.service.spec.ts Total: 324 new tests, all passing Co-Authored-By: Claude Opus 4.5 --- .../__tests__/billing-edge-cases.spec.ts | 5 + .../billing/entities/invoice.entity.ts | 22 + .../__tests__/assignments.service.spec.ts | 520 +++++++++ .../__tests__/entries.service.spec.ts | 686 ++++++++++++ .../__tests__/periods.service.spec.ts | 617 +++++++++++ .../__tests__/schemes.service.spec.ts | 505 +++++++++ .../__tests__/categories.service.spec.ts | 510 +++++++++ .../__tests__/products.service.spec.ts | 997 ++++++++++++++++++ .../__tests__/activities.service.spec.ts | 573 ++++++++++ .../sales/__tests__/leads.service.spec.ts | 402 +++++++ .../__tests__/opportunities.service.spec.ts | 490 +++++++++ .../sales/__tests__/pipeline.service.spec.ts | 483 +++++++++ 12 files changed, 5810 insertions(+) create mode 100644 src/modules/commissions/__tests__/assignments.service.spec.ts create mode 100644 src/modules/commissions/__tests__/entries.service.spec.ts create mode 100644 src/modules/commissions/__tests__/periods.service.spec.ts create mode 100644 src/modules/commissions/__tests__/schemes.service.spec.ts create mode 100644 src/modules/portfolio/__tests__/categories.service.spec.ts create mode 100644 src/modules/portfolio/__tests__/products.service.spec.ts create mode 100644 src/modules/sales/__tests__/activities.service.spec.ts create mode 100644 src/modules/sales/__tests__/leads.service.spec.ts create mode 100644 src/modules/sales/__tests__/opportunities.service.spec.ts create mode 100644 src/modules/sales/__tests__/pipeline.service.spec.ts diff --git a/src/modules/billing/__tests__/billing-edge-cases.spec.ts b/src/modules/billing/__tests__/billing-edge-cases.spec.ts index 84c655f..9c6faf1 100644 --- a/src/modules/billing/__tests__/billing-edge-cases.spec.ts +++ b/src/modules/billing/__tests__/billing-edge-cases.spec.ts @@ -61,6 +61,11 @@ describe('BillingService - Edge Cases', () => { paid_at: null as unknown as Date, external_invoice_id: '', pdf_url: null, + hosted_invoice_url: null, + customer_name: null, + customer_email: null, + billing_address: null, + notes: null, line_items: [{ description: 'Pro Plan', quantity: 1, unit_price: 100, amount: 100 }], billing_details: null as unknown as { name?: string; email?: string; address?: string; tax_id?: string }, items: [], diff --git a/src/modules/billing/entities/invoice.entity.ts b/src/modules/billing/entities/invoice.entity.ts index 81454d1..c0dc41a 100644 --- a/src/modules/billing/entities/invoice.entity.ts +++ b/src/modules/billing/entities/invoice.entity.ts @@ -61,6 +61,28 @@ export class Invoice { @Column({ type: 'varchar', length: 500, nullable: true }) pdf_url: string | null; + @Column({ type: 'varchar', length: 500, nullable: true }) + hosted_invoice_url: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + customer_name: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + customer_email: string | null; + + @Column({ type: 'jsonb', nullable: true }) + billing_address: { + line1?: string; + line2?: string; + city?: string; + state?: string; + postal_code?: string; + country?: string; + } | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + @Column({ type: 'jsonb', nullable: true }) line_items: Array<{ description: string; 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..c00cc8d --- /dev/null +++ b/src/modules/commissions/__tests__/assignments.service.spec.ts @@ -0,0 +1,520 @@ +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'; +import { CreateAssignmentDto, UpdateAssignmentDto, AssignmentListQueryDto } from '../dto'; + +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 mockAssignmentId = '550e8400-e29b-41d4-a716-446655440003'; + const mockSchemeId = '550e8400-e29b-41d4-a716-446655440004'; + const mockTargetUserId = '550e8400-e29b-41d4-a716-446655440005'; + + const createMockScheme = (overrides: Partial = {}): Partial => ({ + id: mockSchemeId, + tenantId: mockTenantId, + name: 'Standard Commission', + type: SchemeType.PERCENTAGE, + rate: 10, + isActive: true, + deletedAt: null, + ...overrides, + }); + + const createMockAssignment = (overrides: Partial = {}): Partial => ({ + id: mockAssignmentId, + tenantId: mockTenantId, + userId: mockTargetUserId, + schemeId: mockSchemeId, + startsAt: new Date('2026-01-01'), + endsAt: null, + customRate: null, + isActive: true, + createdAt: new Date(), + createdBy: mockUserId, + scheme: createMockScheme() as CommissionSchemeEntity, + ...overrides, + }); + + 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 a new assignment', async () => { + const createDto: CreateAssignmentDto = { + userId: mockTargetUserId, + schemeId: mockSchemeId, + }; + + schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity); + assignmentRepo.create.mockReturnValue(createMockAssignment() as CommissionAssignmentEntity); + assignmentRepo.save.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(schemeRepo.findOne).toHaveBeenCalledWith({ + where: { id: mockSchemeId, tenantId: mockTenantId, deletedAt: null }, + }); + expect(assignmentRepo.create).toHaveBeenCalledWith({ + tenantId: mockTenantId, + userId: mockTargetUserId, + schemeId: mockSchemeId, + startsAt: expect.any(Date), + endsAt: undefined, + customRate: undefined, + isActive: true, + createdBy: mockUserId, + }); + expect(result.id).toBe(mockAssignmentId); + }); + + it('should create assignment with custom rate', async () => { + const createDto: CreateAssignmentDto = { + userId: mockTargetUserId, + schemeId: mockSchemeId, + customRate: 15, + }; + + const assignmentWithCustomRate = { ...createMockAssignment(), customRate: 15 }; + schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity); + assignmentRepo.create.mockReturnValue(assignmentWithCustomRate as CommissionAssignmentEntity); + assignmentRepo.save.mockResolvedValue(assignmentWithCustomRate as CommissionAssignmentEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(result.customRate).toBe(15); + }); + + it('should create assignment with start and end dates', async () => { + const createDto: CreateAssignmentDto = { + userId: mockTargetUserId, + schemeId: mockSchemeId, + startsAt: '2026-02-01', + endsAt: '2026-12-31', + }; + + schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity); + assignmentRepo.create.mockReturnValue(createMockAssignment() as CommissionAssignmentEntity); + assignmentRepo.save.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity); + + await service.create(mockTenantId, mockUserId, createDto); + + expect(assignmentRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + startsAt: expect.any(Date), + endsAt: expect.any(Date), + }), + ); + }); + + it('should throw NotFoundException when scheme not found', async () => { + const createDto: CreateAssignmentDto = { + userId: mockTargetUserId, + schemeId: mockSchemeId, + }; + + schemeRepo.findOne.mockResolvedValue(null); + + await expect(service.create(mockTenantId, mockUserId, createDto)).rejects.toThrow(NotFoundException); + await expect(service.create(mockTenantId, mockUserId, createDto)).rejects.toThrow('Commission scheme not found'); + }); + + it('should create inactive assignment when specified', async () => { + const createDto: CreateAssignmentDto = { + userId: mockTargetUserId, + schemeId: mockSchemeId, + isActive: false, + }; + + schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity); + const inactiveAssignment = { ...createMockAssignment(), isActive: false }; + assignmentRepo.create.mockReturnValue(inactiveAssignment as CommissionAssignmentEntity); + assignmentRepo.save.mockResolvedValue(inactiveAssignment as CommissionAssignmentEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(result.isActive).toBe(false); + }); + }); + + describe('findAll', () => { + it('should return paginated list of assignments', async () => { + const query: AssignmentListQueryDto = { page: 1, limit: 10 }; + 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([[createMockAssignment()], 1]), + }; + + assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, query); + + 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 userId', async () => { + const query: AssignmentListQueryDto = { page: 1, limit: 10, userId: mockTargetUserId }; + 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([[createMockAssignment()], 1]), + }; + + assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('a.user_id = :userId', { userId: mockTargetUserId }); + }); + + it('should filter by schemeId', async () => { + const query: AssignmentListQueryDto = { page: 1, limit: 10, schemeId: mockSchemeId }; + 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([[createMockAssignment()], 1]), + }; + + assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('a.scheme_id = :schemeId', { schemeId: mockSchemeId }); + }); + + it('should filter by isActive', async () => { + const query: AssignmentListQueryDto = { page: 1, limit: 10, isActive: true }; + 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([[createMockAssignment()], 1]), + }; + + assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('a.is_active = :isActive', { isActive: true }); + }); + + it('should limit max results to 100', async () => { + const query: AssignmentListQueryDto = { page: 1, limit: 500 }; + 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([[], 0]), + }; + + assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.take).toHaveBeenCalledWith(100); + expect(result.limit).toBe(100); + }); + + it('should use default pagination values', async () => { + const query: AssignmentListQueryDto = {}; + 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([[], 0]), + }; + + assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0); + expect(mockQueryBuilder.take).toHaveBeenCalledWith(20); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + }); + + describe('findOne', () => { + it('should return a single assignment', async () => { + assignmentRepo.findOne.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity); + + const result = await service.findOne(mockTenantId, mockAssignmentId); + + expect(assignmentRepo.findOne).toHaveBeenCalledWith({ + where: { id: mockAssignmentId, tenantId: mockTenantId }, + relations: ['scheme'], + }); + expect(result.id).toBe(mockAssignmentId); + }); + + it('should throw NotFoundException when assignment not found', async () => { + assignmentRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, mockAssignmentId)).rejects.toThrow(NotFoundException); + await expect(service.findOne(mockTenantId, mockAssignmentId)).rejects.toThrow('Commission assignment not found'); + }); + }); + + describe('update', () => { + it('should update an existing assignment', async () => { + const updateDto: UpdateAssignmentDto = { + customRate: 12.5, + }; + + const updatedAssignment = { ...createMockAssignment(), customRate: 12.5 }; + assignmentRepo.findOne.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity); + assignmentRepo.save.mockResolvedValue(updatedAssignment as CommissionAssignmentEntity); + + const result = await service.update(mockTenantId, mockAssignmentId, updateDto); + + expect(result.customRate).toBe(12.5); + }); + + it('should throw NotFoundException when assignment not found', async () => { + assignmentRepo.findOne.mockResolvedValue(null); + + await expect(service.update(mockTenantId, mockAssignmentId, { customRate: 10 })).rejects.toThrow(NotFoundException); + }); + + it('should update dates', async () => { + const updateDto: UpdateAssignmentDto = { + startsAt: '2026-03-01', + endsAt: '2026-06-30', + }; + + assignmentRepo.findOne.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity); + assignmentRepo.save.mockImplementation(async (entity) => entity as CommissionAssignmentEntity); + + await service.update(mockTenantId, mockAssignmentId, updateDto); + + expect(assignmentRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + startsAt: expect.any(Date), + endsAt: expect.any(Date), + }), + ); + }); + + it('should update isActive status', async () => { + const updateDto: UpdateAssignmentDto = { + isActive: false, + }; + + const updatedAssignment = { ...createMockAssignment(), isActive: false }; + assignmentRepo.findOne.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity); + assignmentRepo.save.mockResolvedValue(updatedAssignment as CommissionAssignmentEntity); + + const result = await service.update(mockTenantId, mockAssignmentId, updateDto); + + expect(result.isActive).toBe(false); + }); + }); + + describe('remove', () => { + it('should delete an assignment', async () => { + assignmentRepo.findOne.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity); + assignmentRepo.remove.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity); + + await service.remove(mockTenantId, mockAssignmentId); + + expect(assignmentRepo.remove).toHaveBeenCalledWith(createMockAssignment()); + }); + + it('should throw NotFoundException when assignment not found', async () => { + assignmentRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, mockAssignmentId)).rejects.toThrow(NotFoundException); + }); + }); + + describe('findActiveForUser', () => { + it('should return active assignments for a user', async () => { + const now = new Date(); + const activeAssignment = { + ...createMockAssignment(), + startsAt: new Date(now.getTime() - 86400000), // yesterday + endsAt: new Date(now.getTime() + 86400000), // tomorrow + isActive: true, + }; + + assignmentRepo.find.mockResolvedValue([activeAssignment as CommissionAssignmentEntity]); + + const result = await service.findActiveForUser(mockTenantId, mockTargetUserId); + + expect(assignmentRepo.find).toHaveBeenCalledWith({ + where: { + tenantId: mockTenantId, + userId: mockTargetUserId, + isActive: true, + }, + relations: ['scheme'], + }); + expect(result).toHaveLength(1); + }); + + it('should filter out assignments not yet started', async () => { + const now = new Date(); + const futureAssignment = { + ...createMockAssignment(), + startsAt: new Date(now.getTime() + 86400000), // tomorrow + endsAt: null, + isActive: true, + }; + + assignmentRepo.find.mockResolvedValue([futureAssignment as CommissionAssignmentEntity]); + + const result = await service.findActiveForUser(mockTenantId, mockTargetUserId); + + expect(result).toHaveLength(0); + }); + + it('should filter out expired assignments', async () => { + const now = new Date(); + const expiredAssignment = { + ...createMockAssignment(), + startsAt: new Date(now.getTime() - 172800000), // 2 days ago + endsAt: new Date(now.getTime() - 86400000), // yesterday + isActive: true, + }; + + assignmentRepo.find.mockResolvedValue([expiredAssignment as CommissionAssignmentEntity]); + + const result = await service.findActiveForUser(mockTenantId, mockTargetUserId); + + expect(result).toHaveLength(0); + }); + + it('should include assignments with no end date', async () => { + const now = new Date(); + const openEndedAssignment = { + ...createMockAssignment(), + startsAt: new Date(now.getTime() - 86400000), // yesterday + endsAt: null, + isActive: true, + }; + + assignmentRepo.find.mockResolvedValue([openEndedAssignment as CommissionAssignmentEntity]); + + const result = await service.findActiveForUser(mockTenantId, mockTargetUserId); + + expect(result).toHaveLength(1); + }); + + it('should return empty array when no active assignments', async () => { + assignmentRepo.find.mockResolvedValue([]); + + const result = await service.findActiveForUser(mockTenantId, mockTargetUserId); + + expect(result).toHaveLength(0); + }); + }); + + describe('toResponse (private method via public methods)', () => { + it('should convert entity to response DTO', async () => { + assignmentRepo.findOne.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity); + + const result = await service.findOne(mockTenantId, mockAssignmentId); + + expect(result).toEqual({ + id: mockAssignmentId, + tenantId: mockTenantId, + userId: mockTargetUserId, + schemeId: mockSchemeId, + startsAt: expect.any(Date), + endsAt: null, + customRate: null, + isActive: true, + createdAt: expect.any(Date), + createdBy: mockUserId, + scheme: createMockScheme(), + }); + }); + + it('should convert customRate string to number', async () => { + const assignmentWithStringRate = { ...createMockAssignment(), customRate: '12.5' }; + assignmentRepo.findOne.mockResolvedValue(assignmentWithStringRate as any); + + const result = await service.findOne(mockTenantId, mockAssignmentId); + + expect(typeof result.customRate).toBe('number'); + expect(result.customRate).toBe(12.5); + }); + + it('should handle null customRate', async () => { + const assignmentWithNullRate = { ...createMockAssignment(), customRate: null }; + assignmentRepo.findOne.mockResolvedValue(assignmentWithNullRate as CommissionAssignmentEntity); + + const result = await service.findOne(mockTenantId, mockAssignmentId); + + expect(result.customRate).toBeNull(); + }); + }); +}); 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..4340472 --- /dev/null +++ b/src/modules/commissions/__tests__/entries.service.spec.ts @@ -0,0 +1,686 @@ +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'; +import { + CreateEntryDto, + UpdateEntryDto, + ApproveEntryDto, + RejectEntryDto, + EntryListQueryDto, + CalculateCommissionDto, +} from '../dto'; + +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 mockEntryId = '550e8400-e29b-41d4-a716-446655440003'; + const mockSchemeId = '550e8400-e29b-41d4-a716-446655440004'; + const mockAssignmentId = '550e8400-e29b-41d4-a716-446655440005'; + const mockReferenceId = '550e8400-e29b-41d4-a716-446655440006'; + const mockPeriodId = '550e8400-e29b-41d4-a716-446655440007'; + const mockApproverId = '550e8400-e29b-41d4-a716-446655440008'; + + const createMockEntry = (overrides: Partial = {}): Partial => ({ + id: mockEntryId, + tenantId: mockTenantId, + userId: mockUserId, + schemeId: mockSchemeId, + assignmentId: mockAssignmentId, + referenceType: 'sale', + referenceId: mockReferenceId, + baseAmount: 1000, + rateApplied: 10, + commissionAmount: 100, + currency: 'USD', + status: EntryStatus.PENDING, + periodId: null, + paidAt: null, + paymentReference: null, + notes: null, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + approvedBy: null, + approvedAt: null, + scheme: null, + period: null, + ...overrides, + }); + + beforeEach(async () => { + const mockEntryRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: 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.clearAllMocks(); + }); + + describe('create', () => { + it('should create a new commission entry', async () => { + const createDto: CreateEntryDto = { + userId: mockUserId, + schemeId: mockSchemeId, + assignmentId: mockAssignmentId, + referenceType: 'sale', + referenceId: mockReferenceId, + baseAmount: 1000, + }; + + dataSource.query.mockResolvedValue([{ rate_applied: 10, commission_amount: 100 }]); + entryRepo.create.mockReturnValue(createMockEntry() as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(createMockEntry() as CommissionEntryEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(dataSource.query).toHaveBeenCalledWith( + `SELECT * FROM commissions.calculate_commission($1, $2, $3, $4)`, + [mockSchemeId, mockUserId, 1000, mockTenantId], + ); + expect(result.id).toBe(mockEntryId); + expect(result.commissionAmount).toBe(100); + }); + + it('should create entry with custom currency', async () => { + const createDto: CreateEntryDto = { + userId: mockUserId, + schemeId: mockSchemeId, + referenceType: 'sale', + referenceId: mockReferenceId, + baseAmount: 1000, + currency: 'EUR', + }; + + dataSource.query.mockResolvedValue([{ rate_applied: 10, commission_amount: 100 }]); + const eurEntry = { ...createMockEntry(), currency: 'EUR' }; + entryRepo.create.mockReturnValue(eurEntry as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(eurEntry as CommissionEntryEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(result.currency).toBe('EUR'); + }); + + it('should create entry with notes and metadata', async () => { + const createDto: CreateEntryDto = { + userId: mockUserId, + schemeId: mockSchemeId, + referenceType: 'sale', + referenceId: mockReferenceId, + baseAmount: 1000, + notes: 'Special commission for VIP sale', + metadata: { vipClient: true, discount: 5 }, + }; + + dataSource.query.mockResolvedValue([{ rate_applied: 10, commission_amount: 100 }]); + const entryWithMeta = { ...createMockEntry(), notes: createDto.notes, metadata: createDto.metadata }; + entryRepo.create.mockReturnValue(entryWithMeta as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(entryWithMeta as CommissionEntryEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(result.notes).toBe('Special commission for VIP sale'); + expect(result.metadata).toEqual({ vipClient: true, discount: 5 }); + }); + + it('should throw BadRequestException when commission is zero', async () => { + const createDto: CreateEntryDto = { + userId: mockUserId, + schemeId: mockSchemeId, + referenceType: 'sale', + referenceId: mockReferenceId, + baseAmount: 1000, + }; + + dataSource.query.mockResolvedValue([{ rate_applied: 0, commission_amount: 0 }]); + + await expect(service.create(mockTenantId, mockUserId, createDto)).rejects.toThrow(BadRequestException); + await expect(service.create(mockTenantId, mockUserId, createDto)).rejects.toThrow( + 'Commission calculation resulted in zero amount', + ); + }); + + it('should handle empty calculation result', async () => { + const createDto: CreateEntryDto = { + userId: mockUserId, + schemeId: mockSchemeId, + referenceType: 'sale', + referenceId: mockReferenceId, + baseAmount: 1000, + }; + + dataSource.query.mockResolvedValue([]); + + await expect(service.create(mockTenantId, mockUserId, createDto)).rejects.toThrow(BadRequestException); + }); + }); + + describe('findAll', () => { + it('should return paginated list of entries', async () => { + const query: EntryListQueryDto = { page: 1, limit: 10 }; + 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([[createMockEntry()], 1]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, query); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + }); + + it('should filter by userId', async () => { + const query: EntryListQueryDto = { page: 1, limit: 10, userId: mockUserId }; + 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([[createMockEntry()], 1]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('e.user_id = :userId', { userId: mockUserId }); + }); + + it('should filter by schemeId', async () => { + const query: EntryListQueryDto = { page: 1, limit: 10, schemeId: mockSchemeId }; + 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([[createMockEntry()], 1]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('e.scheme_id = :schemeId', { schemeId: mockSchemeId }); + }); + + it('should filter by periodId', async () => { + const query: EntryListQueryDto = { page: 1, limit: 10, periodId: mockPeriodId }; + 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([[createMockEntry()], 1]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('e.period_id = :periodId', { periodId: mockPeriodId }); + }); + + it('should filter by status', async () => { + const query: EntryListQueryDto = { page: 1, limit: 10, status: EntryStatus.PENDING }; + 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([[createMockEntry()], 1]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('e.status = :status', { status: EntryStatus.PENDING }); + }); + + it('should filter by referenceType', async () => { + const query: EntryListQueryDto = { page: 1, limit: 10, referenceType: 'sale' }; + 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([[createMockEntry()], 1]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('e.reference_type = :referenceType', { referenceType: 'sale' }); + }); + + it('should limit max results to 100', async () => { + const query: EntryListQueryDto = { page: 1, limit: 500 }; + 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([[], 0]), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.take).toHaveBeenCalledWith(100); + expect(result.limit).toBe(100); + }); + }); + + describe('findOne', () => { + it('should return a single entry', async () => { + entryRepo.findOne.mockResolvedValue(createMockEntry() as CommissionEntryEntity); + + const result = await service.findOne(mockTenantId, mockEntryId); + + expect(entryRepo.findOne).toHaveBeenCalledWith({ + where: { id: mockEntryId, tenantId: mockTenantId }, + relations: ['scheme', 'period'], + }); + expect(result.id).toBe(mockEntryId); + }); + + it('should throw NotFoundException when entry not found', async () => { + entryRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, mockEntryId)).rejects.toThrow(NotFoundException); + await expect(service.findOne(mockTenantId, mockEntryId)).rejects.toThrow('Commission entry not found'); + }); + }); + + describe('update', () => { + it('should update an existing entry', async () => { + const updateDto: UpdateEntryDto = { + notes: 'Updated notes', + periodId: mockPeriodId, + }; + + const updatedEntry = { ...createMockEntry(), notes: 'Updated notes', periodId: mockPeriodId }; + entryRepo.findOne.mockResolvedValue(createMockEntry() as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(updatedEntry as CommissionEntryEntity); + + const result = await service.update(mockTenantId, mockEntryId, updateDto); + + expect(result.notes).toBe('Updated notes'); + expect(result.periodId).toBe(mockPeriodId); + }); + + it('should throw NotFoundException when entry not found', async () => { + entryRepo.findOne.mockResolvedValue(null); + + await expect(service.update(mockTenantId, mockEntryId, { notes: 'Test' })).rejects.toThrow(NotFoundException); + }); + + it('should update status', async () => { + const updateDto: UpdateEntryDto = { + status: EntryStatus.APPROVED, + }; + + const updatedEntry = { ...createMockEntry(), status: EntryStatus.APPROVED }; + entryRepo.findOne.mockResolvedValue(createMockEntry() as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(updatedEntry as CommissionEntryEntity); + + const result = await service.update(mockTenantId, mockEntryId, updateDto); + + expect(result.status).toBe(EntryStatus.APPROVED); + }); + + it('should update metadata', async () => { + const updateDto: UpdateEntryDto = { + metadata: { customField: 'value' }, + }; + + const updatedEntry = { ...createMockEntry(), metadata: { customField: 'value' } }; + entryRepo.findOne.mockResolvedValue(createMockEntry() as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(updatedEntry as CommissionEntryEntity); + + const result = await service.update(mockTenantId, mockEntryId, updateDto); + + expect(result.metadata).toEqual({ customField: 'value' }); + }); + }); + + describe('approve', () => { + it('should approve a pending entry', async () => { + const dto: ApproveEntryDto = { notes: 'Approved by manager' }; + const pendingEntry = { ...createMockEntry(), status: EntryStatus.PENDING }; + const approvedEntry = { + ...createMockEntry(), + status: EntryStatus.APPROVED, + approvedBy: mockApproverId, + approvedAt: expect.any(Date), + notes: 'Approved by manager', + }; + + entryRepo.findOne.mockResolvedValue(pendingEntry as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(approvedEntry as CommissionEntryEntity); + + const result = await service.approve(mockTenantId, mockEntryId, mockApproverId, dto); + + expect(result.status).toBe(EntryStatus.APPROVED); + expect(result.approvedBy).toBe(mockApproverId); + }); + + it('should throw NotFoundException when entry not found', async () => { + entryRepo.findOne.mockResolvedValue(null); + + await expect(service.approve(mockTenantId, mockEntryId, mockApproverId, {})).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when entry is not pending', async () => { + const approvedEntry = { ...createMockEntry(), status: EntryStatus.APPROVED }; + entryRepo.findOne.mockResolvedValue(approvedEntry as CommissionEntryEntity); + + await expect(service.approve(mockTenantId, mockEntryId, mockApproverId, {})).rejects.toThrow(BadRequestException); + await expect(service.approve(mockTenantId, mockEntryId, mockApproverId, {})).rejects.toThrow( + 'Only pending entries can be approved', + ); + }); + + it('should approve without notes', async () => { + const pendingEntry = { ...createMockEntry(), status: EntryStatus.PENDING, notes: 'Original notes' }; + const approvedEntry = { ...pendingEntry, status: EntryStatus.APPROVED, approvedBy: mockApproverId }; + + entryRepo.findOne.mockResolvedValue(pendingEntry as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(approvedEntry as CommissionEntryEntity); + + const result = await service.approve(mockTenantId, mockEntryId, mockApproverId, {}); + + expect(result.status).toBe(EntryStatus.APPROVED); + expect(result.notes).toBe('Original notes'); + }); + }); + + describe('reject', () => { + it('should reject a pending entry', async () => { + const dto: RejectEntryDto = { notes: 'Rejected - invalid sale' }; + const pendingEntry = { ...createMockEntry(), status: EntryStatus.PENDING }; + const rejectedEntry = { + ...createMockEntry(), + status: EntryStatus.REJECTED, + approvedBy: mockApproverId, + approvedAt: expect.any(Date), + notes: 'Rejected - invalid sale', + }; + + entryRepo.findOne.mockResolvedValue(pendingEntry as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(rejectedEntry as CommissionEntryEntity); + + const result = await service.reject(mockTenantId, mockEntryId, mockApproverId, dto); + + expect(result.status).toBe(EntryStatus.REJECTED); + expect(result.notes).toBe('Rejected - invalid sale'); + }); + + it('should throw NotFoundException when entry not found', async () => { + entryRepo.findOne.mockResolvedValue(null); + + await expect(service.reject(mockTenantId, mockEntryId, mockApproverId, { notes: 'Test' })).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when entry is not pending', async () => { + const paidEntry = { ...createMockEntry(), status: EntryStatus.PAID }; + entryRepo.findOne.mockResolvedValue(paidEntry as CommissionEntryEntity); + + await expect(service.reject(mockTenantId, mockEntryId, mockApproverId, { notes: 'Test' })).rejects.toThrow(BadRequestException); + await expect(service.reject(mockTenantId, mockEntryId, mockApproverId, { notes: 'Test' })).rejects.toThrow( + 'Only pending entries can be rejected', + ); + }); + }); + + describe('markAsPaid', () => { + it('should mark approved entry as paid', async () => { + const approvedEntry = { ...createMockEntry(), status: EntryStatus.APPROVED }; + const paidEntry = { + ...createMockEntry(), + status: EntryStatus.PAID, + paidAt: expect.any(Date), + paymentReference: 'PAY-12345', + }; + + entryRepo.findOne.mockResolvedValue(approvedEntry as CommissionEntryEntity); + entryRepo.save.mockResolvedValue(paidEntry as CommissionEntryEntity); + + const result = await service.markAsPaid(mockTenantId, mockEntryId, 'PAY-12345'); + + expect(result.status).toBe(EntryStatus.PAID); + expect(result.paymentReference).toBe('PAY-12345'); + }); + + it('should throw NotFoundException when entry not found', async () => { + entryRepo.findOne.mockResolvedValue(null); + + await expect(service.markAsPaid(mockTenantId, mockEntryId, 'PAY-12345')).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when entry is not approved', async () => { + const pendingEntry = { ...createMockEntry(), status: EntryStatus.PENDING }; + entryRepo.findOne.mockResolvedValue(pendingEntry as CommissionEntryEntity); + + await expect(service.markAsPaid(mockTenantId, mockEntryId, 'PAY-12345')).rejects.toThrow(BadRequestException); + await expect(service.markAsPaid(mockTenantId, mockEntryId, 'PAY-12345')).rejects.toThrow( + 'Only approved entries can be marked as paid', + ); + }); + }); + + describe('calculateCommission', () => { + it('should calculate commission for given parameters', async () => { + const dto: CalculateCommissionDto = { + schemeId: mockSchemeId, + userId: mockUserId, + amount: 1000, + }; + + dataSource.query.mockResolvedValue([{ rate_applied: 10, commission_amount: 100 }]); + + const result = await service.calculateCommission(mockTenantId, dto); + + expect(dataSource.query).toHaveBeenCalledWith( + `SELECT * FROM commissions.calculate_commission($1, $2, $3, $4)`, + [mockSchemeId, mockUserId, 1000, mockTenantId], + ); + expect(result.rateApplied).toBe(10); + expect(result.commissionAmount).toBe(100); + }); + + it('should return zero when no calculation result', async () => { + const dto: CalculateCommissionDto = { + schemeId: mockSchemeId, + userId: mockUserId, + amount: 1000, + }; + + dataSource.query.mockResolvedValue([]); + + const result = await service.calculateCommission(mockTenantId, dto); + + expect(result.rateApplied).toBe(0); + expect(result.commissionAmount).toBe(0); + }); + + it('should convert string values to numbers', async () => { + const dto: CalculateCommissionDto = { + schemeId: mockSchemeId, + userId: mockUserId, + amount: 1000, + }; + + dataSource.query.mockResolvedValue([{ rate_applied: '10.5', commission_amount: '105.00' }]); + + const result = await service.calculateCommission(mockTenantId, dto); + + expect(typeof result.rateApplied).toBe('number'); + expect(typeof result.commissionAmount).toBe('number'); + expect(result.rateApplied).toBe(10.5); + expect(result.commissionAmount).toBe(105); + }); + }); + + describe('bulkApprove', () => { + it('should bulk approve multiple entries', async () => { + const entryIds = ['entry-001', 'entry-002', 'entry-003']; + 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, entryIds, mockApproverId); + + expect(result).toBe(3); + expect(mockQueryBuilder.set).toHaveBeenCalledWith({ + status: EntryStatus.APPROVED, + approvedBy: mockApproverId, + approvedAt: expect.any(Date), + }); + expect(mockQueryBuilder.where).toHaveBeenCalledWith('id IN (:...entryIds)', { entryIds }); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('tenant_id = :tenantId', { tenantId: mockTenantId }); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('status = :status', { status: EntryStatus.PENDING }); + }); + + it('should return 0 when no entries are approved', async () => { + const entryIds = ['entry-001']; + 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, entryIds, mockApproverId); + + expect(result).toBe(0); + }); + + it('should handle null affected result', async () => { + const entryIds = ['entry-001']; + const mockQueryBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: null }), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.bulkApprove(mockTenantId, entryIds, mockApproverId); + + expect(result).toBe(0); + }); + }); + + describe('toResponse (private method via public methods)', () => { + it('should convert entity to response DTO', async () => { + entryRepo.findOne.mockResolvedValue(createMockEntry() as CommissionEntryEntity); + + const result = await service.findOne(mockTenantId, mockEntryId); + + expect(result).toEqual({ + id: mockEntryId, + tenantId: mockTenantId, + userId: mockUserId, + schemeId: mockSchemeId, + assignmentId: mockAssignmentId, + referenceType: 'sale', + referenceId: mockReferenceId, + baseAmount: 1000, + rateApplied: 10, + commissionAmount: 100, + currency: 'USD', + status: EntryStatus.PENDING, + periodId: null, + paidAt: null, + paymentReference: null, + notes: null, + metadata: {}, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + approvedBy: null, + approvedAt: null, + scheme: null, + period: null, + }); + }); + + it('should convert numeric strings to numbers', async () => { + const entryWithStrings = { + ...createMockEntry(), + baseAmount: '1000.00', + rateApplied: '10.5', + commissionAmount: '105.00', + }; + entryRepo.findOne.mockResolvedValue(entryWithStrings as any); + + const result = await service.findOne(mockTenantId, mockEntryId); + + expect(typeof result.baseAmount).toBe('number'); + expect(typeof result.rateApplied).toBe('number'); + expect(typeof result.commissionAmount).toBe('number'); + }); + }); +}); 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..e5ab929 --- /dev/null +++ b/src/modules/commissions/__tests__/periods.service.spec.ts @@ -0,0 +1,617 @@ +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'; +import { + CreatePeriodDto, + UpdatePeriodDto, + ClosePeriodDto, + MarkPaidDto, + PeriodListQueryDto, +} from '../dto'; + +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 mockPeriodId = '550e8400-e29b-41d4-a716-446655440003'; + + const createMockPeriod = (overrides: Partial = {}): Partial => ({ + id: mockPeriodId, + 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(), + createdBy: mockUserId, + ...overrides, + }); + + beforeEach(async () => { + const mockPeriodRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: 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 new period', async () => { + const createDto: CreatePeriodDto = { + name: 'January 2026', + startsAt: '2026-01-01', + endsAt: '2026-01-31', + }; + + periodRepo.create.mockReturnValue(createMockPeriod() as CommissionPeriodEntity); + periodRepo.save.mockResolvedValue(createMockPeriod() as CommissionPeriodEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(periodRepo.create).toHaveBeenCalledWith({ + tenantId: mockTenantId, + name: 'January 2026', + startsAt: expect.any(Date), + endsAt: expect.any(Date), + currency: 'USD', + createdBy: mockUserId, + }); + expect(result.id).toBe(mockPeriodId); + expect(result.name).toBe('January 2026'); + }); + + it('should create period with custom currency', async () => { + const createDto: CreatePeriodDto = { + name: 'January 2026', + startsAt: '2026-01-01', + endsAt: '2026-01-31', + currency: 'EUR', + }; + + const eurPeriod = { ...createMockPeriod(), currency: 'EUR' }; + periodRepo.create.mockReturnValue(eurPeriod as CommissionPeriodEntity); + periodRepo.save.mockResolvedValue(eurPeriod as CommissionPeriodEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(result.currency).toBe('EUR'); + }); + }); + + describe('findAll', () => { + it('should return paginated list of periods', async () => { + const query: PeriodListQueryDto = { page: 1, limit: 10 }; + 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([[createMockPeriod()], 1]), + }; + + periodRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, query); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + }); + + it('should filter by status', async () => { + const query: PeriodListQueryDto = { page: 1, limit: 10, status: PeriodStatus.OPEN }; + 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([[createMockPeriod()], 1]), + }; + + periodRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('p.status = :status', { status: PeriodStatus.OPEN }); + }); + + it('should limit max results to 100', async () => { + const query: PeriodListQueryDto = { page: 1, limit: 500 }; + 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]), + }; + + periodRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.take).toHaveBeenCalledWith(100); + expect(result.limit).toBe(100); + }); + + it('should use default pagination values', async () => { + const query: PeriodListQueryDto = {}; + 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]), + }; + + periodRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0); + expect(mockQueryBuilder.take).toHaveBeenCalledWith(20); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + }); + + describe('findOne', () => { + it('should return a single period', async () => { + periodRepo.findOne.mockResolvedValue(createMockPeriod() as CommissionPeriodEntity); + + const result = await service.findOne(mockTenantId, mockPeriodId); + + expect(periodRepo.findOne).toHaveBeenCalledWith({ + where: { id: mockPeriodId, tenantId: mockTenantId }, + }); + expect(result.id).toBe(mockPeriodId); + }); + + it('should throw NotFoundException when period not found', async () => { + periodRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, mockPeriodId)).rejects.toThrow(NotFoundException); + await expect(service.findOne(mockTenantId, mockPeriodId)).rejects.toThrow('Commission period not found'); + }); + }); + + describe('update', () => { + it('should update an open period', async () => { + const updateDto: UpdatePeriodDto = { + name: 'January 2026 - Updated', + }; + + const updatedPeriod = { ...createMockPeriod(), name: 'January 2026 - Updated' }; + periodRepo.findOne.mockResolvedValue(createMockPeriod() as CommissionPeriodEntity); + periodRepo.save.mockResolvedValue(updatedPeriod as CommissionPeriodEntity); + + const result = await service.update(mockTenantId, mockPeriodId, updateDto); + + expect(result.name).toBe('January 2026 - Updated'); + }); + + it('should throw NotFoundException when period not found', async () => { + periodRepo.findOne.mockResolvedValue(null); + + await expect(service.update(mockTenantId, mockPeriodId, { name: 'Test' })).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when period is not open', async () => { + const closedPeriod = { ...createMockPeriod(), status: PeriodStatus.CLOSED }; + periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity); + + await expect(service.update(mockTenantId, mockPeriodId, { name: 'Test' })).rejects.toThrow(BadRequestException); + await expect(service.update(mockTenantId, mockPeriodId, { name: 'Test' })).rejects.toThrow( + 'Only open periods can be updated', + ); + }); + + it('should update dates', async () => { + const updateDto: UpdatePeriodDto = { + startsAt: '2026-01-15', + endsAt: '2026-02-15', + }; + + periodRepo.findOne.mockResolvedValue(createMockPeriod() as CommissionPeriodEntity); + periodRepo.save.mockImplementation(async (entity) => entity as CommissionPeriodEntity); + + await service.update(mockTenantId, mockPeriodId, updateDto); + + expect(periodRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + startsAt: expect.any(Date), + endsAt: expect.any(Date), + }), + ); + }); + }); + + describe('close', () => { + it('should close an open period', async () => { + const dto: ClosePeriodDto = {}; + const openPeriod = { ...createMockPeriod(), status: PeriodStatus.OPEN }; + const closedPeriod = { + ...createMockPeriod(), + status: PeriodStatus.CLOSED, + closedAt: new Date(), + closedBy: mockUserId, + }; + + periodRepo.findOne + .mockResolvedValueOnce(openPeriod as CommissionPeriodEntity) + .mockResolvedValueOnce(closedPeriod as CommissionPeriodEntity); + dataSource.query.mockResolvedValue([]); + + const result = await service.close(mockTenantId, mockPeriodId, mockUserId, dto); + + expect(dataSource.query).toHaveBeenCalledWith( + `SELECT commissions.close_period($1, $2)`, + [mockPeriodId, mockUserId], + ); + expect(result.status).toBe(PeriodStatus.CLOSED); + }); + + it('should throw NotFoundException when period not found', async () => { + periodRepo.findOne.mockResolvedValue(null); + + await expect(service.close(mockTenantId, mockPeriodId, mockUserId, {})).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when period is not open', async () => { + const closedPeriod = { ...createMockPeriod(), status: PeriodStatus.CLOSED }; + periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity); + + await expect(service.close(mockTenantId, mockPeriodId, mockUserId, {})).rejects.toThrow(BadRequestException); + await expect(service.close(mockTenantId, mockPeriodId, mockUserId, {})).rejects.toThrow( + 'Only open periods can be closed', + ); + }); + }); + + describe('markAsPaid', () => { + it('should mark a closed period as paid', async () => { + const dto: MarkPaidDto = { + paymentReference: 'BATCH-12345', + paymentNotes: 'Paid via bank transfer', + }; + const closedPeriod = { ...createMockPeriod(), status: PeriodStatus.CLOSED }; + const paidPeriod = { + ...createMockPeriod(), + status: PeriodStatus.PAID, + paidAt: new Date(), + paidBy: mockUserId, + paymentReference: 'BATCH-12345', + paymentNotes: 'Paid via bank transfer', + }; + + const mockEntryQueryBuilder = { + 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(mockEntryQueryBuilder as any); + + const result = await service.markAsPaid(mockTenantId, mockPeriodId, mockUserId, dto); + + expect(result.status).toBe(PeriodStatus.PAID); + expect(result.paymentReference).toBe('BATCH-12345'); + expect(result.paymentNotes).toBe('Paid via bank transfer'); + }); + + it('should mark a processing period as paid', async () => { + const dto: MarkPaidDto = { paymentReference: 'BATCH-12345' }; + const processingPeriod = { ...createMockPeriod(), status: PeriodStatus.PROCESSING }; + const paidPeriod = { ...createMockPeriod(), status: PeriodStatus.PAID }; + + const mockEntryQueryBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 0 }), + }; + + periodRepo.findOne.mockResolvedValue(processingPeriod as CommissionPeriodEntity); + periodRepo.save.mockResolvedValue(paidPeriod as CommissionPeriodEntity); + entryRepo.createQueryBuilder.mockReturnValue(mockEntryQueryBuilder as any); + + const result = await service.markAsPaid(mockTenantId, mockPeriodId, mockUserId, dto); + + expect(result.status).toBe(PeriodStatus.PAID); + }); + + it('should throw NotFoundException when period not found', async () => { + periodRepo.findOne.mockResolvedValue(null); + + await expect(service.markAsPaid(mockTenantId, mockPeriodId, mockUserId, {})).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when period is open', async () => { + const openPeriod = { ...createMockPeriod(), status: PeriodStatus.OPEN }; + periodRepo.findOne.mockResolvedValue(openPeriod as CommissionPeriodEntity); + + await expect(service.markAsPaid(mockTenantId, mockPeriodId, mockUserId, {})).rejects.toThrow(BadRequestException); + await expect(service.markAsPaid(mockTenantId, mockPeriodId, mockUserId, {})).rejects.toThrow( + 'Only closed or processing periods can be marked as paid', + ); + }); + + it('should update all approved entries to paid', async () => { + const dto: MarkPaidDto = { paymentReference: 'BATCH-12345' }; + const closedPeriod = { ...createMockPeriod(), status: PeriodStatus.CLOSED }; + const paidPeriod = { ...createMockPeriod(), status: PeriodStatus.PAID }; + + const mockEntryQueryBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 10 }), + }; + + periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity); + periodRepo.save.mockResolvedValue(paidPeriod as CommissionPeriodEntity); + entryRepo.createQueryBuilder.mockReturnValue(mockEntryQueryBuilder as any); + + await service.markAsPaid(mockTenantId, mockPeriodId, mockUserId, dto); + + expect(mockEntryQueryBuilder.set).toHaveBeenCalledWith({ + status: EntryStatus.PAID, + paidAt: expect.any(Date), + paymentReference: 'BATCH-12345', + }); + expect(mockEntryQueryBuilder.where).toHaveBeenCalledWith('period_id = :periodId', { periodId: mockPeriodId }); + expect(mockEntryQueryBuilder.andWhere).toHaveBeenCalledWith('status = :status', { status: EntryStatus.APPROVED }); + }); + }); + + describe('remove', () => { + it('should delete an open period with no entries', async () => { + const period = createMockPeriod(); + periodRepo.findOne.mockResolvedValue(period as CommissionPeriodEntity); + entryRepo.count.mockResolvedValue(0); + periodRepo.remove.mockResolvedValue(period as CommissionPeriodEntity); + + await service.remove(mockTenantId, mockPeriodId); + + expect(periodRepo.remove).toHaveBeenCalledWith( + expect.objectContaining({ id: mockPeriodId }), + ); + }); + + it('should throw NotFoundException when period not found', async () => { + periodRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, mockPeriodId)).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when period is not open', async () => { + const closedPeriod = { ...createMockPeriod(), status: PeriodStatus.CLOSED }; + periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity); + + await expect(service.remove(mockTenantId, mockPeriodId)).rejects.toThrow(BadRequestException); + await expect(service.remove(mockTenantId, mockPeriodId)).rejects.toThrow( + 'Only open periods can be deleted', + ); + }); + + it('should throw BadRequestException when period has entries', async () => { + periodRepo.findOne.mockResolvedValue(createMockPeriod() as CommissionPeriodEntity); + entryRepo.count.mockResolvedValue(5); + + await expect(service.remove(mockTenantId, mockPeriodId)).rejects.toThrow(BadRequestException); + await expect(service.remove(mockTenantId, mockPeriodId)).rejects.toThrow( + 'Cannot delete period with 5 entries', + ); + }); + }); + + describe('getCurrentPeriod', () => { + it('should return the current open period', async () => { + periodRepo.findOne.mockResolvedValue(createMockPeriod() as CommissionPeriodEntity); + + const result = await service.getCurrentPeriod(mockTenantId); + + expect(periodRepo.findOne).toHaveBeenCalledWith({ + where: { + tenantId: mockTenantId, + status: PeriodStatus.OPEN, + }, + order: { startsAt: 'DESC' }, + }); + expect(result).not.toBeNull(); + expect(result!.id).toBe(mockPeriodId); + }); + + it('should return null when no open period exists', 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 entryIds = ['entry-001', 'entry-002', 'entry-003']; + 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, mockPeriodId, entryIds); + + expect(result).toBe(3); + expect(mockQueryBuilder.set).toHaveBeenCalledWith({ periodId: mockPeriodId }); + expect(mockQueryBuilder.where).toHaveBeenCalledWith('id IN (:...entryIds)', { entryIds }); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('tenant_id = :tenantId', { tenantId: mockTenantId }); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('period_id IS NULL'); + }); + + it('should return 0 when no entries are assigned', async () => { + const entryIds = ['entry-001']; + 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, mockPeriodId, entryIds); + + expect(result).toBe(0); + }); + + it('should handle null affected result', async () => { + const entryIds = ['entry-001']; + const mockQueryBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: null }), + }; + + entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.assignEntriesToPeriod(mockTenantId, mockPeriodId, entryIds); + + expect(result).toBe(0); + }); + }); + + describe('toResponse (private method via public methods)', () => { + it('should convert entity to response DTO', async () => { + periodRepo.findOne.mockResolvedValue(createMockPeriod() as CommissionPeriodEntity); + + const result = await service.findOne(mockTenantId, mockPeriodId); + + expect(result).toEqual({ + id: mockPeriodId, + tenantId: mockTenantId, + name: 'January 2026', + startsAt: expect.any(Date), + endsAt: expect.any(Date), + totalEntries: 0, + totalAmount: 0, + currency: 'USD', + status: PeriodStatus.OPEN, + closedAt: null, + closedBy: null, + paidAt: null, + paidBy: null, + paymentReference: null, + paymentNotes: null, + createdAt: expect.any(Date), + createdBy: mockUserId, + }); + }); + + it('should convert totalAmount string to number', async () => { + const periodWithStringAmount = { ...createMockPeriod(), totalAmount: '1500.50' }; + periodRepo.findOne.mockResolvedValue(periodWithStringAmount as any); + + const result = await service.findOne(mockTenantId, mockPeriodId); + + expect(typeof result.totalAmount).toBe('number'); + expect(result.totalAmount).toBe(1500.5); + }); + + it('should handle closed period with all fields', async () => { + const closedPeriod = { + ...createMockPeriod(), + status: PeriodStatus.PAID, + totalEntries: 25, + totalAmount: 5000, + closedAt: new Date(), + closedBy: mockUserId, + paidAt: new Date(), + paidBy: mockUserId, + paymentReference: 'BATCH-12345', + paymentNotes: 'Paid via bank transfer', + }; + periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity); + + const result = await service.findOne(mockTenantId, mockPeriodId); + + expect(result.status).toBe(PeriodStatus.PAID); + expect(result.totalEntries).toBe(25); + expect(result.totalAmount).toBe(5000); + expect(result.closedAt).not.toBeNull(); + expect(result.closedBy).toBe(mockUserId); + expect(result.paidAt).not.toBeNull(); + expect(result.paidBy).toBe(mockUserId); + expect(result.paymentReference).toBe('BATCH-12345'); + expect(result.paymentNotes).toBe('Paid via bank transfer'); + }); + }); +}); 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..1a25e82 --- /dev/null +++ b/src/modules/commissions/__tests__/schemes.service.spec.ts @@ -0,0 +1,505 @@ +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'; +import { CreateSchemeDto, UpdateSchemeDto, SchemeListQueryDto } from '../dto'; + +describe('SchemesService', () => { + let service: SchemesService; + 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 createMockScheme = (overrides: Partial = {}): Partial => ({ + id: mockSchemeId, + tenantId: mockTenantId, + name: 'Standard Commission', + description: 'Standard 10% commission', + type: SchemeType.PERCENTAGE, + rate: 10, + fixedAmount: 0, + tiers: [], + appliesTo: AppliesTo.ALL, + productIds: [], + categoryIds: [], + minAmount: 0, + maxAmount: null, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: mockUserId, + deletedAt: null, + ...overrides, + }); + + beforeEach(async () => { + const mockSchemeRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: 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 new commission scheme', async () => { + const createDto: CreateSchemeDto = { + name: 'Standard Commission', + description: 'Standard 10% commission', + type: SchemeType.PERCENTAGE, + rate: 10, + appliesTo: AppliesTo.ALL, + }; + + schemeRepo.create.mockReturnValue(createMockScheme() as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue(createMockScheme() as CommissionSchemeEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(schemeRepo.create).toHaveBeenCalledWith({ + tenantId: mockTenantId, + name: createDto.name, + description: createDto.description, + type: createDto.type, + rate: createDto.rate, + fixedAmount: 0, + tiers: [], + appliesTo: createDto.appliesTo, + productIds: [], + categoryIds: [], + minAmount: 0, + maxAmount: undefined, + isActive: true, + createdBy: mockUserId, + }); + expect(schemeRepo.save).toHaveBeenCalled(); + expect(result.id).toBe(mockSchemeId); + expect(result.name).toBe('Standard Commission'); + }); + + it('should create a tiered commission scheme', async () => { + const createDto: CreateSchemeDto = { + name: 'Tiered Commission', + type: SchemeType.TIERED, + tiers: [ + { min: 0, max: 1000, rate: 5 }, + { min: 1000, max: 5000, rate: 7.5 }, + { min: 5000, max: null, rate: 10 }, + ], + appliesTo: AppliesTo.ALL, + }; + + const tieredScheme = { ...createMockScheme(), type: SchemeType.TIERED, tiers: createDto.tiers }; + schemeRepo.create.mockReturnValue(tieredScheme as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue(tieredScheme as CommissionSchemeEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(result.type).toBe(SchemeType.TIERED); + expect(result.tiers).toHaveLength(3); + }); + + it('should create a fixed amount commission scheme', async () => { + const createDto: CreateSchemeDto = { + name: 'Fixed Commission', + type: SchemeType.FIXED, + fixedAmount: 50, + appliesTo: AppliesTo.PRODUCTS, + productIds: ['product-001', 'product-002'], + }; + + const fixedScheme = { + ...createMockScheme(), + type: SchemeType.FIXED, + fixedAmount: 50, + appliesTo: AppliesTo.PRODUCTS, + productIds: createDto.productIds, + }; + schemeRepo.create.mockReturnValue(fixedScheme as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue(fixedScheme as CommissionSchemeEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(result.type).toBe(SchemeType.FIXED); + expect(result.fixedAmount).toBe(50); + expect(result.appliesTo).toBe(AppliesTo.PRODUCTS); + }); + + it('should create scheme with min and max amount limits', async () => { + const createDto: CreateSchemeDto = { + name: 'Limited Commission', + type: SchemeType.PERCENTAGE, + rate: 15, + appliesTo: AppliesTo.ALL, + minAmount: 100, + maxAmount: 5000, + }; + + const limitedScheme = { + ...createMockScheme(), + minAmount: 100, + maxAmount: 5000, + }; + schemeRepo.create.mockReturnValue(limitedScheme as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue(limitedScheme as CommissionSchemeEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(result.minAmount).toBe(100); + expect(result.maxAmount).toBe(5000); + }); + }); + + describe('findAll', () => { + it('should return paginated list of schemes', async () => { + const query: SchemeListQueryDto = { page: 1, limit: 10 }; + 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([[createMockScheme()], 1]), + }; + + schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, query); + + 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 query: SchemeListQueryDto = { page: 1, limit: 10, type: SchemeType.PERCENTAGE }; + 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([[createMockScheme()], 1]), + }; + + schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('s.type = :type', { type: SchemeType.PERCENTAGE }); + }); + + it('should filter by isActive', async () => { + const query: SchemeListQueryDto = { page: 1, limit: 10, isActive: true }; + 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([[createMockScheme()], 1]), + }; + + schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('s.is_active = :isActive', { isActive: true }); + }); + + it('should filter by search term', async () => { + const query: SchemeListQueryDto = { page: 1, limit: 10, search: 'standard' }; + 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([[createMockScheme()], 1]), + }; + + schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + '(s.name ILIKE :search OR s.description ILIKE :search)', + { search: '%standard%' }, + ); + }); + + it('should limit max results to 100', async () => { + const query: SchemeListQueryDto = { page: 1, limit: 500 }; + 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); + + const result = await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.take).toHaveBeenCalledWith(100); + expect(result.limit).toBe(100); + }); + + it('should use default pagination values', async () => { + const query: SchemeListQueryDto = {}; + 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); + + const result = await service.findAll(mockTenantId, query); + + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0); + expect(mockQueryBuilder.take).toHaveBeenCalledWith(20); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + }); + + describe('findOne', () => { + it('should return a single scheme', async () => { + schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity); + + const result = await service.findOne(mockTenantId, mockSchemeId); + + expect(schemeRepo.findOne).toHaveBeenCalledWith({ + where: { id: mockSchemeId, tenantId: mockTenantId, deletedAt: null }, + }); + expect(result.id).toBe(mockSchemeId); + expect(result.name).toBe('Standard Commission'); + }); + + it('should throw NotFoundException when scheme not found', async () => { + schemeRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, mockSchemeId)).rejects.toThrow(NotFoundException); + await expect(service.findOne(mockTenantId, mockSchemeId)).rejects.toThrow('Commission scheme not found'); + }); + }); + + describe('update', () => { + it('should update an existing scheme', async () => { + const updateDto: UpdateSchemeDto = { + name: 'Updated Commission', + rate: 15, + }; + + const updatedScheme = { ...createMockScheme(), name: 'Updated Commission', rate: 15 }; + schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue(updatedScheme as CommissionSchemeEntity); + + const result = await service.update(mockTenantId, mockSchemeId, updateDto); + + expect(result.name).toBe('Updated Commission'); + expect(result.rate).toBe(15); + }); + + it('should throw NotFoundException when scheme not found', async () => { + schemeRepo.findOne.mockResolvedValue(null); + + await expect(service.update(mockTenantId, mockSchemeId, { name: 'Test' })).rejects.toThrow(NotFoundException); + }); + + it('should update only provided fields', async () => { + const updateDto: UpdateSchemeDto = { description: 'New description' }; + + schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity); + schemeRepo.save.mockImplementation(async (entity) => entity as CommissionSchemeEntity); + + const result = await service.update(mockTenantId, mockSchemeId, updateDto); + + expect(result.name).toBe('Standard Commission'); + expect(result.description).toBe('New description'); + }); + + it('should update tiers for tiered scheme', async () => { + const updateDto: UpdateSchemeDto = { + tiers: [ + { min: 0, max: 2000, rate: 6 }, + { min: 2000, max: null, rate: 12 }, + ], + }; + + const tieredScheme = { ...createMockScheme(), type: SchemeType.TIERED, tiers: [] }; + schemeRepo.findOne.mockResolvedValue(tieredScheme as CommissionSchemeEntity); + schemeRepo.save.mockImplementation(async (entity) => entity as CommissionSchemeEntity); + + const result = await service.update(mockTenantId, mockSchemeId, updateDto); + + expect(result.tiers).toHaveLength(2); + }); + }); + + describe('remove', () => { + it('should soft delete a scheme', async () => { + schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity); + schemeRepo.save.mockImplementation(async (entity) => entity as CommissionSchemeEntity); + + await service.remove(mockTenantId, mockSchemeId); + + expect(schemeRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ deletedAt: expect.any(Date) }), + ); + }); + + it('should throw NotFoundException when scheme not found', async () => { + schemeRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, mockSchemeId)).rejects.toThrow(NotFoundException); + }); + }); + + describe('activate', () => { + it('should activate a scheme', async () => { + const inactiveScheme = { ...createMockScheme(), isActive: false }; + const activatedScheme = { ...createMockScheme(), isActive: true }; + + schemeRepo.findOne.mockResolvedValue(inactiveScheme as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue(activatedScheme as CommissionSchemeEntity); + + const result = await service.activate(mockTenantId, mockSchemeId); + + expect(result.isActive).toBe(true); + expect(schemeRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true }), + ); + }); + + it('should throw NotFoundException when scheme not found', async () => { + schemeRepo.findOne.mockResolvedValue(null); + + await expect(service.activate(mockTenantId, mockSchemeId)).rejects.toThrow(NotFoundException); + }); + + it('should return scheme when already active', async () => { + schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue(createMockScheme() as CommissionSchemeEntity); + + const result = await service.activate(mockTenantId, mockSchemeId); + + expect(result.isActive).toBe(true); + }); + }); + + describe('deactivate', () => { + it('should deactivate a scheme', async () => { + const activeScheme = { ...createMockScheme(), isActive: true }; + const deactivatedScheme = { ...createMockScheme(), isActive: false }; + + schemeRepo.findOne.mockResolvedValue(activeScheme as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue(deactivatedScheme as CommissionSchemeEntity); + + const result = await service.deactivate(mockTenantId, mockSchemeId); + + expect(result.isActive).toBe(false); + expect(schemeRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ isActive: false }), + ); + }); + + it('should throw NotFoundException when scheme not found', async () => { + schemeRepo.findOne.mockResolvedValue(null); + + await expect(service.deactivate(mockTenantId, mockSchemeId)).rejects.toThrow(NotFoundException); + }); + + it('should return scheme when already inactive', async () => { + const inactiveScheme = { ...createMockScheme(), isActive: false }; + schemeRepo.findOne.mockResolvedValue(inactiveScheme as CommissionSchemeEntity); + schemeRepo.save.mockResolvedValue(inactiveScheme as CommissionSchemeEntity); + + const result = await service.deactivate(mockTenantId, mockSchemeId); + + expect(result.isActive).toBe(false); + }); + }); + + describe('toResponse (private method via public methods)', () => { + it('should convert entity to response DTO', async () => { + schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity); + + const result = await service.findOne(mockTenantId, mockSchemeId); + + expect(result).toEqual({ + id: mockSchemeId, + tenantId: mockTenantId, + name: 'Standard Commission', + description: 'Standard 10% commission', + type: SchemeType.PERCENTAGE, + rate: 10, + fixedAmount: 0, + tiers: [], + appliesTo: AppliesTo.ALL, + productIds: [], + categoryIds: [], + minAmount: 0, + maxAmount: null, + isActive: true, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + createdBy: mockUserId, + }); + }); + + it('should handle null maxAmount correctly', async () => { + const schemeWithNullMax = { ...createMockScheme(), maxAmount: null }; + schemeRepo.findOne.mockResolvedValue(schemeWithNullMax as CommissionSchemeEntity); + + const result = await service.findOne(mockTenantId, mockSchemeId); + + expect(result.maxAmount).toBeNull(); + }); + + it('should convert numeric strings to numbers', async () => { + const schemeWithStringNumbers = { + ...createMockScheme(), + rate: '10.5', + fixedAmount: '25.00', + minAmount: '100.00', + maxAmount: '5000.00', + }; + schemeRepo.findOne.mockResolvedValue(schemeWithStringNumbers as any); + + const result = await service.findOne(mockTenantId, mockSchemeId); + + expect(typeof result.rate).toBe('number'); + expect(typeof result.fixedAmount).toBe('number'); + expect(typeof result.minAmount).toBe('number'); + expect(typeof result.maxAmount).toBe('number'); + }); + }); +}); diff --git a/src/modules/portfolio/__tests__/categories.service.spec.ts b/src/modules/portfolio/__tests__/categories.service.spec.ts new file mode 100644 index 0000000..261ba09 --- /dev/null +++ b/src/modules/portfolio/__tests__/categories.service.spec.ts @@ -0,0 +1,510 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { CategoriesService } from '../services/categories.service'; +import { CategoryEntity } from '../entities/category.entity'; +import { CreateCategoryDto, UpdateCategoryDto, CategoryListQueryDto } from '../dto'; + +describe('CategoriesService', () => { + let service: CategoriesService; + let categoryRepo: jest.Mocked>; + + const tenantId = 'tenant-123'; + const userId = 'user-123'; + + const mockCategory: CategoryEntity = { + id: 'cat-123', + tenantId, + parentId: null, + name: 'Electronics', + slug: 'electronics', + description: 'Electronic devices', + position: 0, + imageUrl: 'https://example.com/image.png', + color: '#3B82F6', + icon: 'laptop', + isActive: true, + metaTitle: 'Electronics Category', + metaDescription: 'All electronic products', + customFields: {}, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + createdBy: userId, + deletedAt: null, + parent: null, + children: [], + products: [], + }; + + const mockChildCategory: CategoryEntity = { + ...mockCategory, + id: 'cat-456', + parentId: 'cat-123', + name: 'Laptops', + slug: 'laptops', + description: 'Laptop computers', + position: 1, + }; + + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockCategory], 1]), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CategoriesService, + { + provide: getRepositoryToken(CategoryEntity), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + create: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }, + }, + ], + }).compile(); + + service = module.get(CategoriesService); + categoryRepo = module.get(getRepositoryToken(CategoryEntity)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + const createDto: CreateCategoryDto = { + name: 'Electronics', + slug: 'electronics', + description: 'Electronic devices', + position: 0, + isActive: true, + }; + + it('should create a category successfully', async () => { + categoryRepo.create.mockReturnValue(mockCategory); + categoryRepo.save.mockResolvedValue(mockCategory); + + const result = await service.create(tenantId, userId, createDto); + + expect(result).toBeDefined(); + expect(result.id).toBe(mockCategory.id); + expect(result.name).toBe(mockCategory.name); + expect(result.slug).toBe(mockCategory.slug); + expect(categoryRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId, + name: createDto.name, + slug: createDto.slug, + createdBy: userId, + }), + ); + expect(categoryRepo.save).toHaveBeenCalled(); + }); + + it('should create a category with parent', async () => { + const dtoWithParent: CreateCategoryDto = { + ...createDto, + parentId: 'parent-123', + }; + + categoryRepo.findOne.mockResolvedValue(mockCategory); // Parent exists + categoryRepo.create.mockReturnValue({ ...mockCategory, parentId: 'parent-123' }); + categoryRepo.save.mockResolvedValue({ ...mockCategory, parentId: 'parent-123' }); + + const result = await service.create(tenantId, userId, dtoWithParent); + + expect(result.parentId).toBe('parent-123'); + expect(categoryRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'parent-123', tenantId, deletedAt: expect.anything() }, + }); + }); + + it('should throw BadRequestException when parent not found', async () => { + const dtoWithParent: CreateCategoryDto = { + ...createDto, + parentId: 'non-existent-parent', + }; + + categoryRepo.findOne.mockResolvedValue(null); + + await expect(service.create(tenantId, userId, dtoWithParent)).rejects.toThrow( + BadRequestException, + ); + await expect(service.create(tenantId, userId, dtoWithParent)).rejects.toThrow( + 'Parent category not found', + ); + }); + + it('should create category with default values when optional fields not provided', async () => { + const minimalDto: CreateCategoryDto = { + name: 'Simple', + slug: 'simple', + }; + + const createdCategory = { + ...mockCategory, + name: minimalDto.name, + slug: minimalDto.slug, + position: 0, + color: '#3B82F6', + isActive: true, + customFields: {}, + }; + + categoryRepo.create.mockReturnValue(createdCategory); + categoryRepo.save.mockResolvedValue(createdCategory); + + const result = await service.create(tenantId, userId, minimalDto); + + expect(result.position).toBe(0); + expect(result.color).toBe('#3B82F6'); + expect(result.isActive).toBe(true); + }); + }); + + describe('findAll', () => { + const query: CategoryListQueryDto = { + page: 1, + limit: 20, + }; + + it('should return paginated categories', async () => { + mockQueryBuilder.getManyAndCount.mockResolvedValue([[mockCategory], 1]); + + const result = await service.findAll(tenantId, query); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + expect(result.totalPages).toBe(1); + }); + + it('should filter by parentId', async () => { + const queryWithParent: CategoryListQueryDto = { + ...query, + parentId: 'cat-123', + }; + + await service.findAll(tenantId, queryWithParent); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'c.parent_id = :parentId', + { parentId: 'cat-123' }, + ); + }); + + it('should filter root categories when parentId is null or empty', async () => { + const queryRootOnly: CategoryListQueryDto = { + ...query, + parentId: '', + }; + + await service.findAll(tenantId, queryRootOnly); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('c.parent_id IS NULL'); + }); + + it('should filter by isActive', async () => { + const queryActive: CategoryListQueryDto = { + ...query, + isActive: true, + }; + + await service.findAll(tenantId, queryActive); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'c.is_active = :isActive', + { isActive: true }, + ); + }); + + it('should filter by search term', async () => { + const querySearch: CategoryListQueryDto = { + ...query, + search: 'elect', + }; + + await service.findAll(tenantId, querySearch); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + '(c.name ILIKE :search OR c.slug ILIKE :search)', + { search: '%elect%' }, + ); + }); + + it('should use default pagination when not provided', async () => { + const emptyQuery: CategoryListQueryDto = {}; + + await service.findAll(tenantId, emptyQuery); + + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0); + expect(mockQueryBuilder.take).toHaveBeenCalledWith(20); + }); + + it('should limit max results to 100', async () => { + const queryLargeLimit: CategoryListQueryDto = { + limit: 500, + }; + + await service.findAll(tenantId, queryLargeLimit); + + expect(mockQueryBuilder.take).toHaveBeenCalledWith(100); + }); + }); + + describe('findOne', () => { + it('should return a category by id', async () => { + categoryRepo.findOne.mockResolvedValue(mockCategory); + + const result = await service.findOne(tenantId, 'cat-123'); + + expect(result).toBeDefined(); + expect(result.id).toBe('cat-123'); + expect(result.name).toBe('Electronics'); + expect(categoryRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'cat-123', tenantId, deletedAt: expect.anything() }, + }); + }); + + it('should throw NotFoundException when category not found', async () => { + categoryRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(tenantId, 'non-existent')).rejects.toThrow( + NotFoundException, + ); + await expect(service.findOne(tenantId, 'non-existent')).rejects.toThrow( + 'Category not found', + ); + }); + }); + + describe('getTree', () => { + it('should return category tree structure', async () => { + const parentCategory = { ...mockCategory }; + const childCategory = { ...mockChildCategory, parentId: mockCategory.id }; + + categoryRepo.find.mockResolvedValue([parentCategory, childCategory]); + + const result = await service.getTree(tenantId); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(mockCategory.id); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].id).toBe(childCategory.id); + expect(result[0].depth).toBe(0); + expect(result[0].children[0].depth).toBe(1); + }); + + it('should return empty array when no categories exist', async () => { + categoryRepo.find.mockResolvedValue([]); + + const result = await service.getTree(tenantId); + + expect(result).toHaveLength(0); + }); + + it('should handle multiple root categories', async () => { + const anotherRoot = { + ...mockCategory, + id: 'cat-789', + name: 'Clothing', + slug: 'clothing', + parentId: null, + }; + + categoryRepo.find.mockResolvedValue([mockCategory, anotherRoot]); + + const result = await service.getTree(tenantId); + + expect(result).toHaveLength(2); + }); + + it('should handle deeply nested categories', async () => { + const level0 = { ...mockCategory, parentId: null }; + const level1 = { ...mockCategory, id: 'cat-l1', parentId: mockCategory.id }; + const level2 = { ...mockCategory, id: 'cat-l2', parentId: 'cat-l1' }; + + categoryRepo.find.mockResolvedValue([level0, level1, level2]); + + const result = await service.getTree(tenantId); + + expect(result[0].depth).toBe(0); + expect(result[0].children[0].depth).toBe(1); + expect(result[0].children[0].children[0].depth).toBe(2); + }); + }); + + describe('update', () => { + const updateDto: UpdateCategoryDto = { + name: 'Updated Electronics', + description: 'Updated description', + }; + + it('should update a category successfully', async () => { + const updatedCategory = { ...mockCategory, ...updateDto }; + categoryRepo.findOne.mockResolvedValue({ ...mockCategory }); + categoryRepo.save.mockResolvedValue(updatedCategory); + + const result = await service.update(tenantId, 'cat-123', updateDto); + + expect(result.name).toBe('Updated Electronics'); + expect(result.description).toBe('Updated description'); + expect(categoryRepo.save).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when category not found', async () => { + categoryRepo.findOne.mockResolvedValue(null); + + await expect(service.update(tenantId, 'non-existent', updateDto)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should update parent successfully', async () => { + const updateWithParent: UpdateCategoryDto = { + parentId: 'new-parent-123', + }; + + categoryRepo.findOne + .mockResolvedValueOnce({ ...mockCategory }) // Category to update + .mockResolvedValueOnce({ ...mockCategory, id: 'new-parent-123' }); // New parent + categoryRepo.save.mockResolvedValue({ ...mockCategory, parentId: 'new-parent-123' }); + + const result = await service.update(tenantId, 'cat-123', updateWithParent); + + expect(result.parentId).toBe('new-parent-123'); + }); + + it('should throw BadRequestException when setting category as its own parent', async () => { + const updateSelfParent: UpdateCategoryDto = { + parentId: 'cat-123', + }; + + categoryRepo.findOne.mockResolvedValue({ ...mockCategory }); + + await expect(service.update(tenantId, 'cat-123', updateSelfParent)).rejects.toThrow( + BadRequestException, + ); + await expect(service.update(tenantId, 'cat-123', updateSelfParent)).rejects.toThrow( + 'Category cannot be its own parent', + ); + }); + + it('should throw BadRequestException when new parent not found', async () => { + const updateWithParent: UpdateCategoryDto = { + parentId: 'non-existent-parent', + }; + + categoryRepo.findOne + .mockResolvedValueOnce({ ...mockCategory }) + .mockResolvedValueOnce(null); + + await expect(service.update(tenantId, 'cat-123', updateWithParent)).rejects.toThrow( + new BadRequestException('Parent category not found'), + ); + }); + + it('should allow setting parentId to null (make root)', async () => { + const updateToRoot: UpdateCategoryDto = { + parentId: null, + }; + + categoryRepo.findOne.mockResolvedValue({ ...mockCategory, parentId: 'some-parent' }); + categoryRepo.save.mockResolvedValue({ ...mockCategory, parentId: null }); + + const result = await service.update(tenantId, 'cat-123', updateToRoot); + + expect(result.parentId).toBeNull(); + }); + + it('should only update provided fields', async () => { + const partialUpdate: UpdateCategoryDto = { + name: 'Only Name', + }; + + categoryRepo.findOne.mockResolvedValue({ ...mockCategory }); + categoryRepo.save.mockImplementation((cat) => Promise.resolve(cat as CategoryEntity)); + + const result = await service.update(tenantId, 'cat-123', partialUpdate); + + expect(result.name).toBe('Only Name'); + expect(result.slug).toBe(mockCategory.slug); + expect(result.description).toBe(mockCategory.description); + }); + }); + + describe('remove', () => { + it('should soft-delete a category successfully', async () => { + categoryRepo.findOne.mockResolvedValue({ ...mockCategory }); + categoryRepo.count.mockResolvedValue(0); + categoryRepo.save.mockResolvedValue({ ...mockCategory, deletedAt: new Date() }); + + await expect(service.remove(tenantId, 'cat-123')).resolves.toBeUndefined(); + + expect(categoryRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ deletedAt: expect.any(Date) }), + ); + }); + + it('should throw NotFoundException when category not found', async () => { + categoryRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(tenantId, 'non-existent')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException when category has children', async () => { + categoryRepo.findOne.mockResolvedValue({ ...mockCategory }); + categoryRepo.count.mockResolvedValue(2); + + await expect(service.remove(tenantId, 'cat-123')).rejects.toThrow( + BadRequestException, + ); + await expect(service.remove(tenantId, 'cat-123')).rejects.toThrow( + 'Cannot delete category with child categories', + ); + }); + }); + + describe('toResponse mapping', () => { + it('should correctly map entity to response DTO', async () => { + categoryRepo.findOne.mockResolvedValue(mockCategory); + + const result = await service.findOne(tenantId, 'cat-123'); + + expect(result).toEqual({ + id: mockCategory.id, + tenantId: mockCategory.tenantId, + parentId: mockCategory.parentId, + name: mockCategory.name, + slug: mockCategory.slug, + description: mockCategory.description, + position: mockCategory.position, + imageUrl: mockCategory.imageUrl, + color: mockCategory.color, + icon: mockCategory.icon, + isActive: mockCategory.isActive, + metaTitle: mockCategory.metaTitle, + metaDescription: mockCategory.metaDescription, + customFields: mockCategory.customFields, + createdAt: mockCategory.createdAt, + updatedAt: mockCategory.updatedAt, + createdBy: mockCategory.createdBy, + }); + }); + }); +}); diff --git a/src/modules/portfolio/__tests__/products.service.spec.ts b/src/modules/portfolio/__tests__/products.service.spec.ts new file mode 100644 index 0000000..93900c4 --- /dev/null +++ b/src/modules/portfolio/__tests__/products.service.spec.ts @@ -0,0 +1,997 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { ProductsService } from '../services/products.service'; +import { ProductEntity, ProductStatus, ProductType, VariantEntity, PriceEntity } from '../entities'; +import { PriceType } from '../entities/price.entity'; +import { + CreateProductDto, + UpdateProductDto, + UpdateProductStatusDto, + CreateVariantDto, + UpdateVariantDto, + CreatePriceDto, + UpdatePriceDto, + ProductListQueryDto, +} from '../dto'; + +describe('ProductsService', () => { + let service: ProductsService; + let productRepo: jest.Mocked>; + let variantRepo: jest.Mocked>; + let priceRepo: jest.Mocked>; + + const tenantId = 'tenant-123'; + const userId = 'user-123'; + + const mockProduct: ProductEntity = { + id: 'prod-123', + tenantId, + categoryId: 'cat-123', + name: 'Test Product', + slug: 'test-product', + sku: 'SKU-001', + barcode: '1234567890', + description: 'Test description', + shortDescription: 'Short desc', + productType: ProductType.PHYSICAL, + status: ProductStatus.DRAFT, + basePrice: 99.99, + costPrice: 50, + compareAtPrice: 129.99, + currency: 'USD', + trackInventory: true, + stockQuantity: 100, + lowStockThreshold: 10, + allowBackorder: false, + weight: 1.5, + weightUnit: 'kg', + length: 10, + width: 5, + height: 3, + dimensionUnit: 'cm', + images: ['image1.jpg', 'image2.jpg'], + featuredImageUrl: 'featured.jpg', + metaTitle: 'Test Product', + metaDescription: 'Meta description', + tags: ['tag1', 'tag2'], + isVisible: true, + isFeatured: false, + hasVariants: false, + variantAttributes: [], + customFields: {}, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + createdBy: userId, + publishedAt: null, + deletedAt: null, + category: null, + variants: [], + prices: [], + }; + + const mockVariant: VariantEntity = { + id: 'var-123', + tenantId, + productId: 'prod-123', + sku: 'SKU-001-RED', + barcode: '1234567891', + name: 'Red Variant', + attributes: { color: 'red', size: 'M' }, + price: 109.99, + costPrice: 55, + compareAtPrice: 139.99, + stockQuantity: 50, + lowStockThreshold: 5, + weight: 1.6, + imageUrl: 'red-variant.jpg', + isActive: true, + position: 0, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + deletedAt: null, + product: mockProduct, + prices: [], + }; + + const mockPrice: PriceEntity = { + id: 'price-123', + tenantId, + productId: 'prod-123', + variantId: null, + priceType: PriceType.ONE_TIME, + currency: 'USD', + amount: 99.99, + compareAtAmount: 129.99, + billingPeriod: null, + billingInterval: null, + minQuantity: 1, + maxQuantity: null, + validFrom: null, + validUntil: null, + priority: 0, + isActive: true, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + deletedAt: null, + product: null, + variant: null, + }; + + 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([[mockProduct], 1]), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProductsService, + { + provide: getRepositoryToken(ProductEntity), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + create: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }, + }, + { + provide: getRepositoryToken(VariantEntity), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + create: jest.fn(), + count: jest.fn(), + }, + }, + { + provide: getRepositoryToken(PriceEntity), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + create: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ProductsService); + productRepo = module.get(getRepositoryToken(ProductEntity)); + variantRepo = module.get(getRepositoryToken(VariantEntity)); + priceRepo = module.get(getRepositoryToken(PriceEntity)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ============================================ + // Products Tests + // ============================================ + + describe('create', () => { + const createDto: CreateProductDto = { + name: 'Test Product', + slug: 'test-product', + categoryId: 'cat-123', + sku: 'SKU-001', + basePrice: 99.99, + }; + + it('should create a product successfully', async () => { + productRepo.create.mockReturnValue(mockProduct); + productRepo.save.mockResolvedValue(mockProduct); + + const result = await service.create(tenantId, userId, createDto); + + expect(result).toBeDefined(); + expect(result.id).toBe(mockProduct.id); + expect(result.name).toBe(mockProduct.name); + expect(productRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId, + name: createDto.name, + slug: createDto.slug, + createdBy: userId, + }), + ); + expect(productRepo.save).toHaveBeenCalled(); + }); + + it('should create product with default values', async () => { + const minimalDto: CreateProductDto = { + name: 'Minimal', + slug: 'minimal', + }; + + const createdProduct = { + ...mockProduct, + categoryId: null, + status: ProductStatus.DRAFT, + basePrice: 0, + currency: 'USD', + trackInventory: true, + stockQuantity: 0, + lowStockThreshold: 5, + allowBackorder: false, + images: [], + tags: [], + isVisible: true, + isFeatured: false, + hasVariants: false, + customFields: {}, + }; + + productRepo.create.mockReturnValue(createdProduct); + productRepo.save.mockResolvedValue(createdProduct); + + const result = await service.create(tenantId, userId, minimalDto); + + expect(result.status).toBe(ProductStatus.DRAFT); + expect(result.basePrice).toBe(0); + expect(result.currency).toBe('USD'); + }); + }); + + describe('findAll', () => { + const query: ProductListQueryDto = { + page: 1, + limit: 20, + }; + + it('should return paginated products', async () => { + mockQueryBuilder.getManyAndCount.mockResolvedValue([[mockProduct], 1]); + + const result = await service.findAll(tenantId, query); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + expect(result.totalPages).toBe(1); + }); + + it('should filter by categoryId', async () => { + const queryWithCategory: ProductListQueryDto = { + ...query, + categoryId: 'cat-123', + }; + + await service.findAll(tenantId, queryWithCategory); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'p.category_id = :categoryId', + { categoryId: 'cat-123' }, + ); + }); + + it('should filter by productType', async () => { + const queryWithType: ProductListQueryDto = { + ...query, + productType: ProductType.DIGITAL, + }; + + await service.findAll(tenantId, queryWithType); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'p.product_type = :productType', + { productType: ProductType.DIGITAL }, + ); + }); + + it('should filter by status', async () => { + const queryWithStatus: ProductListQueryDto = { + ...query, + status: ProductStatus.ACTIVE, + }; + + await service.findAll(tenantId, queryWithStatus); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'p.status = :status', + { status: ProductStatus.ACTIVE }, + ); + }); + + it('should filter by price range', async () => { + const queryWithPrice: ProductListQueryDto = { + ...query, + minPrice: 50, + maxPrice: 200, + }; + + await service.findAll(tenantId, queryWithPrice); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'p.base_price >= :minPrice', + { minPrice: 50 }, + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'p.base_price <= :maxPrice', + { maxPrice: 200 }, + ); + }); + + it('should filter by search term', async () => { + const queryWithSearch: ProductListQueryDto = { + ...query, + search: 'test', + }; + + await service.findAll(tenantId, queryWithSearch); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + '(p.name ILIKE :search OR p.slug ILIKE :search OR p.sku ILIKE :search OR p.description ILIKE :search)', + { search: '%test%' }, + ); + }); + + it('should filter by visibility and featured', async () => { + const queryWithFlags: ProductListQueryDto = { + ...query, + isVisible: true, + isFeatured: true, + }; + + await service.findAll(tenantId, queryWithFlags); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'p.is_visible = :isVisible', + { isVisible: true }, + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'p.is_featured = :isFeatured', + { isFeatured: true }, + ); + }); + + it('should filter by tags', async () => { + const queryWithTags: ProductListQueryDto = { + ...query, + tags: ['tag1', 'tag2'], + }; + + await service.findAll(tenantId, queryWithTags); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'p.tags ?| :tags', + { tags: ['tag1', 'tag2'] }, + ); + }); + + it('should apply custom sorting', async () => { + const queryWithSort: ProductListQueryDto = { + ...query, + sortBy: 'base_price', + sortOrder: 'ASC', + }; + + await service.findAll(tenantId, queryWithSort); + + expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('p.base_price', 'ASC'); + }); + + it('should limit max results to 100', async () => { + const queryLargeLimit: ProductListQueryDto = { + limit: 500, + }; + + await service.findAll(tenantId, queryLargeLimit); + + expect(mockQueryBuilder.take).toHaveBeenCalledWith(100); + }); + }); + + describe('findOne', () => { + it('should return a product by id with variant count', async () => { + productRepo.findOne.mockResolvedValue(mockProduct); + variantRepo.count.mockResolvedValue(3); + + const result = await service.findOne(tenantId, 'prod-123'); + + expect(result).toBeDefined(); + expect(result.id).toBe('prod-123'); + expect(result.variantCount).toBe(3); + }); + + it('should throw NotFoundException when product not found', async () => { + productRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(tenantId, 'non-existent')).rejects.toThrow( + NotFoundException, + ); + await expect(service.findOne(tenantId, 'non-existent')).rejects.toThrow( + 'Product not found', + ); + }); + }); + + describe('update', () => { + const updateDto: UpdateProductDto = { + name: 'Updated Product', + basePrice: 149.99, + }; + + it('should update a product successfully', async () => { + const updatedProduct = { ...mockProduct, ...updateDto }; + productRepo.findOne.mockResolvedValue({ ...mockProduct }); + productRepo.save.mockResolvedValue(updatedProduct); + + const result = await service.update(tenantId, 'prod-123', updateDto); + + expect(result.name).toBe('Updated Product'); + expect(result.basePrice).toBe(149.99); + }); + + it('should throw NotFoundException when product not found', async () => { + productRepo.findOne.mockResolvedValue(null); + + await expect(service.update(tenantId, 'non-existent', updateDto)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should only update provided fields', async () => { + productRepo.findOne.mockResolvedValue({ ...mockProduct }); + productRepo.save.mockImplementation((prod) => Promise.resolve(prod as ProductEntity)); + + const result = await service.update(tenantId, 'prod-123', { name: 'Only Name' }); + + expect(result.name).toBe('Only Name'); + expect(result.slug).toBe(mockProduct.slug); + expect(result.basePrice).toBe(mockProduct.basePrice); + }); + }); + + describe('updateStatus', () => { + it('should update product status successfully', async () => { + const statusDto: UpdateProductStatusDto = { + status: ProductStatus.ACTIVE, + }; + + productRepo.findOne.mockResolvedValue({ ...mockProduct }); + productRepo.save.mockResolvedValue({ + ...mockProduct, + status: ProductStatus.ACTIVE, + publishedAt: new Date(), + }); + + const result = await service.updateStatus(tenantId, 'prod-123', statusDto); + + expect(result.status).toBe(ProductStatus.ACTIVE); + }); + + it('should set publishedAt when activating product', async () => { + const statusDto: UpdateProductStatusDto = { + status: ProductStatus.ACTIVE, + }; + + productRepo.findOne.mockResolvedValue({ ...mockProduct, publishedAt: null }); + productRepo.save.mockImplementation((prod) => Promise.resolve(prod as ProductEntity)); + + await service.updateStatus(tenantId, 'prod-123', statusDto); + + expect(productRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ publishedAt: expect.any(Date) }), + ); + }); + + it('should not update publishedAt if already set', async () => { + const existingDate = new Date('2026-01-01'); + const statusDto: UpdateProductStatusDto = { + status: ProductStatus.ACTIVE, + }; + + productRepo.findOne.mockResolvedValue({ ...mockProduct, publishedAt: existingDate }); + productRepo.save.mockImplementation((prod) => Promise.resolve(prod as ProductEntity)); + + await service.updateStatus(tenantId, 'prod-123', statusDto); + + expect(productRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ publishedAt: existingDate }), + ); + }); + + it('should throw NotFoundException when product not found', async () => { + productRepo.findOne.mockResolvedValue(null); + + await expect( + service.updateStatus(tenantId, 'non-existent', { status: ProductStatus.ACTIVE }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('duplicate', () => { + it('should duplicate a product successfully', async () => { + const duplicated = { + ...mockProduct, + id: 'prod-456', + name: 'Test Product (Copy)', + slug: expect.stringContaining('test-product-copy-'), + sku: 'SKU-001-COPY', + status: ProductStatus.DRAFT, + publishedAt: null, + }; + + productRepo.findOne.mockResolvedValue(mockProduct); + productRepo.create.mockReturnValue(duplicated); + productRepo.save.mockResolvedValue(duplicated); + + const result = await service.duplicate(tenantId, userId, 'prod-123'); + + expect(result.name).toContain('(Copy)'); + expect(result.status).toBe(ProductStatus.DRAFT); + expect(result.publishedAt).toBeNull(); + }); + + it('should throw NotFoundException when product not found', async () => { + productRepo.findOne.mockResolvedValue(null); + + await expect(service.duplicate(tenantId, userId, 'non-existent')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('remove', () => { + it('should soft-delete a product successfully', async () => { + productRepo.findOne.mockResolvedValue({ ...mockProduct }); + productRepo.save.mockResolvedValue({ ...mockProduct, deletedAt: new Date() }); + + await expect(service.remove(tenantId, 'prod-123')).resolves.toBeUndefined(); + + expect(productRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ deletedAt: expect.any(Date) }), + ); + }); + + it('should throw NotFoundException when product not found', async () => { + productRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(tenantId, 'non-existent')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + // ============================================ + // Variants Tests + // ============================================ + + describe('getVariants', () => { + it('should return variants for a product', async () => { + productRepo.count.mockResolvedValue(1); + variantRepo.find.mockResolvedValue([mockVariant]); + + const result = await service.getVariants(tenantId, 'prod-123'); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(mockVariant.id); + }); + + it('should throw NotFoundException when product not found', async () => { + productRepo.count.mockResolvedValue(0); + + await expect(service.getVariants(tenantId, 'non-existent')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should return empty array when no variants exist', async () => { + productRepo.count.mockResolvedValue(1); + variantRepo.find.mockResolvedValue([]); + + const result = await service.getVariants(tenantId, 'prod-123'); + + expect(result).toHaveLength(0); + }); + }); + + describe('createVariant', () => { + const createVariantDto: CreateVariantDto = { + sku: 'SKU-001-RED', + name: 'Red Variant', + attributes: { color: 'red' }, + price: 109.99, + }; + + it('should create a variant successfully', async () => { + productRepo.count.mockResolvedValue(1); + variantRepo.create.mockReturnValue(mockVariant); + variantRepo.save.mockResolvedValue(mockVariant); + + const result = await service.createVariant(tenantId, 'prod-123', createVariantDto); + + expect(result).toBeDefined(); + expect(result.id).toBe(mockVariant.id); + expect(variantRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId, + productId: 'prod-123', + sku: createVariantDto.sku, + }), + ); + }); + + it('should throw NotFoundException when product not found', async () => { + productRepo.count.mockResolvedValue(0); + + await expect( + service.createVariant(tenantId, 'non-existent', createVariantDto), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('updateVariant', () => { + const updateVariantDto: UpdateVariantDto = { + name: 'Updated Red Variant', + price: 119.99, + }; + + it('should update a variant successfully', async () => { + productRepo.count.mockResolvedValue(1); + variantRepo.findOne.mockResolvedValue({ ...mockVariant }); + variantRepo.save.mockResolvedValue({ ...mockVariant, ...updateVariantDto }); + + const result = await service.updateVariant( + tenantId, + 'prod-123', + 'var-123', + updateVariantDto, + ); + + expect(result.name).toBe('Updated Red Variant'); + expect(result.price).toBe(119.99); + }); + + it('should throw NotFoundException when product not found', async () => { + productRepo.count.mockResolvedValue(0); + + await expect( + service.updateVariant(tenantId, 'non-existent', 'var-123', updateVariantDto), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException when variant not found', async () => { + productRepo.count.mockResolvedValue(1); + variantRepo.findOne.mockResolvedValue(null); + + await expect( + service.updateVariant(tenantId, 'prod-123', 'non-existent', updateVariantDto), + ).rejects.toThrow(NotFoundException); + await expect( + service.updateVariant(tenantId, 'prod-123', 'non-existent', updateVariantDto), + ).rejects.toThrow('Variant not found'); + }); + }); + + describe('removeVariant', () => { + it('should soft-delete a variant successfully', async () => { + productRepo.count.mockResolvedValue(1); + variantRepo.findOne.mockResolvedValue({ ...mockVariant }); + variantRepo.save.mockResolvedValue({ ...mockVariant, deletedAt: new Date() }); + + await expect(service.removeVariant(tenantId, 'prod-123', 'var-123')).resolves.toBeUndefined(); + + expect(variantRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ deletedAt: expect.any(Date) }), + ); + }); + + it('should throw NotFoundException when variant not found', async () => { + productRepo.count.mockResolvedValue(1); + variantRepo.findOne.mockResolvedValue(null); + + await expect( + service.removeVariant(tenantId, 'prod-123', 'non-existent'), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ============================================ + // Prices Tests + // ============================================ + + describe('getPrices', () => { + it('should return prices for a product', async () => { + productRepo.count.mockResolvedValue(1); + priceRepo.find.mockResolvedValue([mockPrice]); + + const result = await service.getPrices(tenantId, 'prod-123'); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(mockPrice.id); + }); + + it('should throw NotFoundException when product not found', async () => { + productRepo.count.mockResolvedValue(0); + + await expect(service.getPrices(tenantId, 'non-existent')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('createPrice', () => { + const createPriceDto: CreatePriceDto = { + currency: 'USD', + amount: 99.99, + priceType: PriceType.ONE_TIME, + }; + + it('should create a price successfully', async () => { + productRepo.count.mockResolvedValue(1); + priceRepo.create.mockReturnValue(mockPrice); + priceRepo.save.mockResolvedValue(mockPrice); + + const result = await service.createPrice(tenantId, 'prod-123', createPriceDto); + + expect(result).toBeDefined(); + expect(result.id).toBe(mockPrice.id); + expect(priceRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId, + productId: 'prod-123', + currency: createPriceDto.currency, + amount: createPriceDto.amount, + }), + ); + }); + + it('should create a recurring price', async () => { + const recurringPriceDto: CreatePriceDto = { + ...createPriceDto, + priceType: PriceType.RECURRING, + billingPeriod: 'month', + billingInterval: 1, + }; + + const recurringPrice = { + ...mockPrice, + priceType: PriceType.RECURRING, + billingPeriod: 'month', + billingInterval: 1, + }; + + productRepo.count.mockResolvedValue(1); + priceRepo.create.mockReturnValue(recurringPrice); + priceRepo.save.mockResolvedValue(recurringPrice); + + const result = await service.createPrice(tenantId, 'prod-123', recurringPriceDto); + + expect(result.priceType).toBe(PriceType.RECURRING); + expect(result.billingPeriod).toBe('month'); + }); + + it('should create a price with validity period', async () => { + const validityPriceDto: CreatePriceDto = { + ...createPriceDto, + validFrom: '2026-01-01', + validUntil: '2026-12-31', + }; + + productRepo.count.mockResolvedValue(1); + priceRepo.create.mockReturnValue({ + ...mockPrice, + validFrom: new Date('2026-01-01'), + validUntil: new Date('2026-12-31'), + }); + priceRepo.save.mockResolvedValue({ + ...mockPrice, + validFrom: new Date('2026-01-01'), + validUntil: new Date('2026-12-31'), + }); + + const result = await service.createPrice(tenantId, 'prod-123', validityPriceDto); + + expect(result.validFrom).toBeDefined(); + expect(result.validUntil).toBeDefined(); + }); + + it('should throw NotFoundException when product not found', async () => { + productRepo.count.mockResolvedValue(0); + + await expect( + service.createPrice(tenantId, 'non-existent', createPriceDto), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('updatePrice', () => { + const updatePriceDto: UpdatePriceDto = { + amount: 149.99, + isActive: false, + }; + + it('should update a price successfully', async () => { + productRepo.count.mockResolvedValue(1); + priceRepo.findOne.mockResolvedValue({ ...mockPrice }); + priceRepo.save.mockResolvedValue({ ...mockPrice, ...updatePriceDto }); + + const result = await service.updatePrice( + tenantId, + 'prod-123', + 'price-123', + updatePriceDto, + ); + + expect(result.amount).toBe(149.99); + expect(result.isActive).toBe(false); + }); + + it('should throw NotFoundException when price not found', async () => { + productRepo.count.mockResolvedValue(1); + priceRepo.findOne.mockResolvedValue(null); + + await expect( + service.updatePrice(tenantId, 'prod-123', 'non-existent', updatePriceDto), + ).rejects.toThrow(NotFoundException); + await expect( + service.updatePrice(tenantId, 'prod-123', 'non-existent', updatePriceDto), + ).rejects.toThrow('Price not found'); + }); + + it('should allow clearing validity dates', async () => { + const clearValidityDto: UpdatePriceDto = { + validFrom: null, + validUntil: null, + }; + + productRepo.count.mockResolvedValue(1); + priceRepo.findOne.mockResolvedValue({ + ...mockPrice, + validFrom: new Date(), + validUntil: new Date(), + }); + priceRepo.save.mockImplementation((p) => Promise.resolve(p as PriceEntity)); + + await service.updatePrice(tenantId, 'prod-123', 'price-123', clearValidityDto); + + expect(priceRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ validFrom: null, validUntil: null }), + ); + }); + }); + + describe('removePrice', () => { + it('should soft-delete a price successfully', async () => { + productRepo.count.mockResolvedValue(1); + priceRepo.findOne.mockResolvedValue({ ...mockPrice }); + priceRepo.save.mockResolvedValue({ ...mockPrice, deletedAt: new Date() }); + + await expect(service.removePrice(tenantId, 'prod-123', 'price-123')).resolves.toBeUndefined(); + + expect(priceRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ deletedAt: expect.any(Date) }), + ); + }); + + it('should throw NotFoundException when price not found', async () => { + productRepo.count.mockResolvedValue(1); + priceRepo.findOne.mockResolvedValue(null); + + await expect( + service.removePrice(tenantId, 'prod-123', 'non-existent'), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ============================================ + // Response Mapping Tests + // ============================================ + + describe('toProductResponse mapping', () => { + it('should correctly map entity to response DTO', async () => { + const productWithCategory = { + ...mockProduct, + category: { + id: 'cat-123', + name: 'Electronics', + slug: 'electronics', + }, + }; + + productRepo.findOne.mockResolvedValue(productWithCategory); + variantRepo.count.mockResolvedValue(0); + + const result = await service.findOne(tenantId, 'prod-123'); + + expect(result.category).toEqual({ + id: 'cat-123', + name: 'Electronics', + slug: 'electronics', + }); + }); + + it('should handle null category', async () => { + productRepo.findOne.mockResolvedValue({ ...mockProduct, category: null }); + variantRepo.count.mockResolvedValue(0); + + const result = await service.findOne(tenantId, 'prod-123'); + + expect(result.category).toBeNull(); + }); + + it('should convert decimal fields to numbers', async () => { + productRepo.findOne.mockResolvedValue(mockProduct); + variantRepo.count.mockResolvedValue(0); + + const result = await service.findOne(tenantId, 'prod-123'); + + expect(typeof result.basePrice).toBe('number'); + expect(typeof result.costPrice).toBe('number'); + expect(typeof result.compareAtPrice).toBe('number'); + }); + }); + + describe('toVariantResponse mapping', () => { + it('should correctly map variant entity to response DTO', async () => { + productRepo.count.mockResolvedValue(1); + variantRepo.find.mockResolvedValue([mockVariant]); + + const result = await service.getVariants(tenantId, 'prod-123'); + + expect(result[0]).toEqual({ + id: mockVariant.id, + tenantId: mockVariant.tenantId, + productId: mockVariant.productId, + sku: mockVariant.sku, + barcode: mockVariant.barcode, + name: mockVariant.name, + attributes: mockVariant.attributes, + price: Number(mockVariant.price), + costPrice: Number(mockVariant.costPrice), + compareAtPrice: Number(mockVariant.compareAtPrice), + stockQuantity: mockVariant.stockQuantity, + lowStockThreshold: mockVariant.lowStockThreshold, + weight: Number(mockVariant.weight), + imageUrl: mockVariant.imageUrl, + isActive: mockVariant.isActive, + position: mockVariant.position, + createdAt: mockVariant.createdAt, + updatedAt: mockVariant.updatedAt, + }); + }); + }); + + describe('toPriceResponse mapping', () => { + it('should correctly map price entity to response DTO', async () => { + productRepo.count.mockResolvedValue(1); + priceRepo.find.mockResolvedValue([mockPrice]); + + const result = await service.getPrices(tenantId, 'prod-123'); + + expect(result[0]).toEqual({ + id: mockPrice.id, + tenantId: mockPrice.tenantId, + productId: mockPrice.productId, + variantId: mockPrice.variantId, + priceType: mockPrice.priceType, + currency: mockPrice.currency, + amount: Number(mockPrice.amount), + compareAtAmount: Number(mockPrice.compareAtAmount), + billingPeriod: mockPrice.billingPeriod, + billingInterval: mockPrice.billingInterval, + minQuantity: mockPrice.minQuantity, + maxQuantity: mockPrice.maxQuantity, + validFrom: mockPrice.validFrom, + validUntil: mockPrice.validUntil, + priority: mockPrice.priority, + isActive: mockPrice.isActive, + createdAt: mockPrice.createdAt, + updatedAt: mockPrice.updatedAt, + }); + }); + }); +}); diff --git a/src/modules/sales/__tests__/activities.service.spec.ts b/src/modules/sales/__tests__/activities.service.spec.ts new file mode 100644 index 0000000..8a70f33 --- /dev/null +++ b/src/modules/sales/__tests__/activities.service.spec.ts @@ -0,0 +1,573 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; + +import { ActivitiesService } from '../services/activities.service'; +import { ActivityEntity, ActivityType, ActivityStatus } from '../entities'; + +describe('ActivitiesService', () => { + let service: ActivitiesService; + let activityRepo: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockActivityId = '550e8400-e29b-41d4-a716-446655440003'; + const mockLeadId = '550e8400-e29b-41d4-a716-446655440004'; + const mockOpportunityId = '550e8400-e29b-41d4-a716-446655440005'; + + const mockActivity: Partial = { + id: mockActivityId, + tenantId: mockTenantId, + type: ActivityType.CALL, + status: ActivityStatus.PENDING, + subject: 'Follow-up call', + description: 'Call to discuss next steps', + leadId: mockLeadId, + opportunityId: null, + dueDate: new Date('2026-02-01'), + dueTime: '10:00:00', + durationMinutes: 30, + assignedTo: mockUserId, + createdBy: mockUserId, + callDirection: 'outbound', + location: null, + meetingUrl: null, + attendees: [], + reminderAt: new Date('2026-02-01T09:30:00Z'), + reminderSent: false, + customFields: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const mockActivityRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ActivitiesService, + { provide: getRepositoryToken(ActivityEntity), useValue: mockActivityRepo }, + ], + }).compile(); + + service = module.get(ActivitiesService); + activityRepo = module.get(getRepositoryToken(ActivityEntity)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create a new activity with leadId', async () => { + const createDto = { + type: ActivityType.CALL, + subject: 'Follow-up call', + description: 'Call to discuss next steps', + leadId: mockLeadId, + dueDate: '2026-02-01', + dueTime: '10:00:00', + durationMinutes: 30, + assignedTo: mockUserId, + }; + + activityRepo.create.mockReturnValue(mockActivity as ActivityEntity); + activityRepo.save.mockResolvedValue(mockActivity as ActivityEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(activityRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + tenantId: mockTenantId, + type: ActivityType.CALL, + subject: 'Follow-up call', + leadId: mockLeadId, + createdBy: mockUserId, + })); + expect(result.id).toBe(mockActivityId); + }); + + it('should create a new activity with opportunityId', async () => { + const createDto = { + type: ActivityType.MEETING, + subject: 'Sales meeting', + opportunityId: mockOpportunityId, + dueDate: '2026-02-05', + location: 'Conference Room A', + attendees: ['john@example.com', 'jane@example.com'], + }; + + const activityWithOpportunity = { ...mockActivity, opportunityId: mockOpportunityId, leadId: null }; + activityRepo.create.mockReturnValue(activityWithOpportunity as ActivityEntity); + activityRepo.save.mockResolvedValue(activityWithOpportunity as ActivityEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(activityRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + opportunityId: mockOpportunityId, + type: ActivityType.MEETING, + })); + }); + + it('should throw BadRequestException when neither leadId nor opportunityId provided', async () => { + const createDto = { + type: ActivityType.TASK, + subject: 'Some task', + }; + + await expect(service.create(mockTenantId, mockUserId, createDto)) + .rejects.toThrow(BadRequestException); + }); + + it('should create activity with reminder', async () => { + const createDto = { + type: ActivityType.EMAIL, + subject: 'Send proposal', + leadId: mockLeadId, + reminderAt: '2026-02-01T09:00:00Z', + }; + + activityRepo.create.mockReturnValue(mockActivity as ActivityEntity); + activityRepo.save.mockResolvedValue(mockActivity as ActivityEntity); + + await service.create(mockTenantId, mockUserId, createDto); + + expect(activityRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + reminderAt: expect.any(Date), + })); + }); + + it('should create meeting activity with meeting URL', async () => { + const createDto = { + type: ActivityType.MEETING, + subject: 'Video call', + leadId: mockLeadId, + meetingUrl: 'https://zoom.us/j/123456789', + }; + + const meetingActivity = { ...mockActivity, meetingUrl: 'https://zoom.us/j/123456789' }; + activityRepo.create.mockReturnValue(meetingActivity as ActivityEntity); + activityRepo.save.mockResolvedValue(meetingActivity as ActivityEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(activityRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + meetingUrl: 'https://zoom.us/j/123456789', + })); + }); + }); + + describe('findAll', () => { + it('should return paginated activities', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockActivity], 1]), + }; + + activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, { page: 1, limit: 20 }); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.totalPages).toBe(1); + }); + + it('should filter by type', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockActivity], 1]), + }; + + activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { type: ActivityType.CALL }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'a.type = :type', + { type: ActivityType.CALL } + ); + }); + + it('should filter by status', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockActivity], 1]), + }; + + activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { status: ActivityStatus.PENDING }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'a.status = :status', + { status: ActivityStatus.PENDING } + ); + }); + + it('should filter by leadId', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockActivity], 1]), + }; + + activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { leadId: mockLeadId }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'a.lead_id = :leadId', + { leadId: mockLeadId } + ); + }); + + it('should filter by opportunityId', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockActivity], 1]), + }; + + activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { opportunityId: mockOpportunityId }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'a.opportunity_id = :opportunityId', + { opportunityId: mockOpportunityId } + ); + }); + + it('should filter by assignedTo', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockActivity], 1]), + }; + + activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { assignedTo: mockUserId }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'a.assigned_to = :assignedTo', + { assignedTo: mockUserId } + ); + }); + + it('should limit results to max 100', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + + activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { limit: 500 }); + + expect(mockQueryBuilder.take).toHaveBeenCalledWith(100); + }); + }); + + describe('findOne', () => { + it('should return an activity by id', async () => { + activityRepo.findOne.mockResolvedValue(mockActivity as ActivityEntity); + + const result = await service.findOne(mockTenantId, mockActivityId); + + expect(result.id).toBe(mockActivityId); + expect(result.subject).toBe('Follow-up call'); + expect(activityRepo.findOne).toHaveBeenCalledWith({ + where: { id: mockActivityId, tenantId: mockTenantId, deletedAt: null }, + relations: ['lead', 'opportunity'], + }); + }); + + it('should throw NotFoundException when activity not found', async () => { + activityRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'non-existent-id')) + .rejects.toThrow(NotFoundException); + }); + }); + + describe('update', () => { + it('should update an activity successfully', async () => { + const updateDto = { + subject: 'Updated follow-up call', + dueDate: '2026-02-05', + durationMinutes: 45, + }; + + const updatedActivity = { ...mockActivity, ...updateDto }; + activityRepo.findOne.mockResolvedValue(mockActivity as ActivityEntity); + activityRepo.save.mockResolvedValue(updatedActivity as ActivityEntity); + + const result = await service.update(mockTenantId, mockActivityId, updateDto); + + expect(result.subject).toBe('Updated follow-up call'); + expect(result.durationMinutes).toBe(45); + }); + + it('should throw NotFoundException when activity not found', async () => { + activityRepo.findOne.mockResolvedValue(null); + + await expect(service.update(mockTenantId, 'non-existent-id', { subject: 'Test' })) + .rejects.toThrow(NotFoundException); + }); + + it('should update activity type', async () => { + const updateDto = { + type: ActivityType.MEETING, + location: 'Conference Room B', + }; + + const updatedActivity = { ...mockActivity, type: ActivityType.MEETING, location: 'Conference Room B' }; + activityRepo.findOne.mockResolvedValue(mockActivity as ActivityEntity); + activityRepo.save.mockResolvedValue(updatedActivity as ActivityEntity); + + const result = await service.update(mockTenantId, mockActivityId, updateDto); + + expect(result.type).toBe(ActivityType.MEETING); + expect(result.location).toBe('Conference Room B'); + }); + + it('should update activity status', async () => { + const updateDto = { + status: ActivityStatus.CANCELLED, + }; + + const updatedActivity = { ...mockActivity, status: ActivityStatus.CANCELLED }; + activityRepo.findOne.mockResolvedValue(mockActivity as ActivityEntity); + activityRepo.save.mockResolvedValue(updatedActivity as ActivityEntity); + + const result = await service.update(mockTenantId, mockActivityId, updateDto); + + expect(result.status).toBe(ActivityStatus.CANCELLED); + }); + + it('should update attendees list', async () => { + const updateDto = { + attendees: ['john@example.com', 'jane@example.com', 'bob@example.com'], + }; + + const updatedActivity = { ...mockActivity, attendees: updateDto.attendees }; + activityRepo.findOne.mockResolvedValue(mockActivity as ActivityEntity); + activityRepo.save.mockResolvedValue(updatedActivity as ActivityEntity); + + const result = await service.update(mockTenantId, mockActivityId, updateDto); + + expect(result.attendees).toHaveLength(3); + }); + }); + + describe('complete', () => { + it('should complete an activity', async () => { + const completeDto = { + outcome: 'Successfully discussed next steps', + }; + + const completedActivity = { + ...mockActivity, + status: ActivityStatus.COMPLETED, + completedAt: new Date(), + outcome: completeDto.outcome, + }; + + activityRepo.findOne.mockResolvedValue(mockActivity as ActivityEntity); + activityRepo.save.mockResolvedValue(completedActivity as ActivityEntity); + + const result = await service.complete(mockTenantId, mockActivityId, completeDto); + + expect(result.status).toBe(ActivityStatus.COMPLETED); + expect(result.outcome).toBe('Successfully discussed next steps'); + expect(activityRepo.save).toHaveBeenCalledWith(expect.objectContaining({ + status: ActivityStatus.COMPLETED, + completedAt: expect.any(Date), + })); + }); + + it('should throw NotFoundException when activity not found', async () => { + activityRepo.findOne.mockResolvedValue(null); + + await expect(service.complete(mockTenantId, 'non-existent-id', {})) + .rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when activity is already completed', async () => { + const completedActivity = { ...mockActivity, status: ActivityStatus.COMPLETED }; + activityRepo.findOne.mockResolvedValue(completedActivity as ActivityEntity); + + await expect(service.complete(mockTenantId, mockActivityId, {})) + .rejects.toThrow(BadRequestException); + }); + + it('should complete activity without outcome', async () => { + const pendingActivity = { + ...mockActivity, + status: ActivityStatus.PENDING, + }; + const completedActivity = { + ...mockActivity, + status: ActivityStatus.COMPLETED, + completedAt: new Date(), + }; + + activityRepo.findOne.mockResolvedValue(pendingActivity as ActivityEntity); + activityRepo.save.mockResolvedValue(completedActivity as ActivityEntity); + + const result = await service.complete(mockTenantId, mockActivityId, {}); + + expect(result.status).toBe(ActivityStatus.COMPLETED); + }); + }); + + describe('remove', () => { + it('should soft delete an activity', async () => { + activityRepo.findOne.mockResolvedValue(mockActivity as ActivityEntity); + activityRepo.save.mockResolvedValue({ ...mockActivity, deletedAt: new Date() } as ActivityEntity); + + await service.remove(mockTenantId, mockActivityId); + + expect(activityRepo.save).toHaveBeenCalledWith(expect.objectContaining({ + deletedAt: expect.any(Date), + })); + }); + + it('should throw NotFoundException when activity not found', async () => { + activityRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, 'non-existent-id')) + .rejects.toThrow(NotFoundException); + }); + }); + + describe('getUpcoming', () => { + it('should return upcoming activities within default 7 days', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockActivity]), + }; + + activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getUpcoming(mockTenantId); + + expect(result).toHaveLength(1); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'a.status = :status', + { status: ActivityStatus.PENDING } + ); + expect(mockQueryBuilder.take).toHaveBeenCalledWith(50); + }); + + it('should filter by userId when provided', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockActivity]), + }; + + activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.getUpcoming(mockTenantId, mockUserId); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'a.assigned_to = :userId', + { userId: mockUserId } + ); + }); + + it('should return upcoming activities within custom days', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockActivity]), + }; + + activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.getUpcoming(mockTenantId, undefined, 14); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'a.due_date <= :endDate', + expect.objectContaining({ + endDate: expect.any(Date), + }) + ); + }); + + it('should return empty array when no upcoming activities', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + }; + + activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getUpcoming(mockTenantId); + + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/src/modules/sales/__tests__/leads.service.spec.ts b/src/modules/sales/__tests__/leads.service.spec.ts new file mode 100644 index 0000000..940f657 --- /dev/null +++ b/src/modules/sales/__tests__/leads.service.spec.ts @@ -0,0 +1,402 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; + +import { LeadsService } from '../services/leads.service'; +import { LeadEntity, LeadStatus, LeadSource } from '../entities'; + +describe('LeadsService', () => { + let service: LeadsService; + let leadRepo: jest.Mocked>; + let dataSource: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockLeadId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockLead: Partial = { + id: mockLeadId, + tenantId: mockTenantId, + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + phone: '+1234567890', + company: 'Acme Inc', + jobTitle: 'CEO', + website: 'https://acme.com', + source: LeadSource.WEBSITE, + status: LeadStatus.NEW, + score: 50, + assignedTo: mockUserId, + notes: 'Important lead', + customFields: {}, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: mockUserId, + }; + + beforeEach(async () => { + const mockLeadRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockDataSource = { + query: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LeadsService, + { provide: getRepositoryToken(LeadEntity), useValue: mockLeadRepo }, + { provide: DataSource, useValue: mockDataSource }, + ], + }).compile(); + + service = module.get(LeadsService); + leadRepo = module.get(getRepositoryToken(LeadEntity)); + dataSource = module.get(DataSource); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create a new lead successfully', async () => { + const createDto = { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + phone: '+1234567890', + company: 'Acme Inc', + source: LeadSource.WEBSITE, + status: LeadStatus.NEW, + }; + + leadRepo.create.mockReturnValue(mockLead as LeadEntity); + leadRepo.save.mockResolvedValue(mockLead as LeadEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(leadRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + tenantId: mockTenantId, + firstName: createDto.firstName, + lastName: createDto.lastName, + email: createDto.email, + createdBy: mockUserId, + })); + expect(leadRepo.save).toHaveBeenCalled(); + expect(result.id).toBe(mockLeadId); + expect(result.fullName).toBe('John Doe'); + }); + + it('should create lead with default score of 0', async () => { + const createDto = { + firstName: 'Jane', + lastName: 'Smith', + }; + + leadRepo.create.mockReturnValue({ ...mockLead, score: 0 } as LeadEntity); + leadRepo.save.mockResolvedValue({ ...mockLead, score: 0 } as LeadEntity); + + await service.create(mockTenantId, mockUserId, createDto); + + expect(leadRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + score: 0, + })); + }); + }); + + describe('findAll', () => { + it('should return paginated leads', 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([[mockLead], 1]), + }; + + leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, { page: 1, limit: 20 }); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.totalPages).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([[mockLead], 1]), + }; + + leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { status: LeadStatus.NEW }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'l.status = :status', + { status: LeadStatus.NEW } + ); + }); + + it('should filter by source', 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([[mockLead], 1]), + }; + + leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { source: LeadSource.WEBSITE }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'l.source = :source', + { source: LeadSource.WEBSITE } + ); + }); + + it('should filter by assignedTo', 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([[mockLead], 1]), + }; + + leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { assignedTo: mockUserId }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'l.assigned_to = :assignedTo', + { assignedTo: mockUserId } + ); + }); + + it('should search by name, email, or company', 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([[mockLead], 1]), + }; + + leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { search: 'John' }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + '(l.first_name ILIKE :search OR l.last_name ILIKE :search OR l.email ILIKE :search OR l.company ILIKE :search)', + { search: '%John%' } + ); + }); + + it('should limit results to max 100', 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]), + }; + + leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { limit: 500 }); + + expect(mockQueryBuilder.take).toHaveBeenCalledWith(100); + }); + }); + + describe('findOne', () => { + it('should return a lead by id', async () => { + leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity); + + const result = await service.findOne(mockTenantId, mockLeadId); + + expect(result.id).toBe(mockLeadId); + expect(result.email).toBe('john.doe@example.com'); + }); + + it('should throw NotFoundException when lead not found', async () => { + leadRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'non-existent-id')) + .rejects.toThrow(NotFoundException); + }); + }); + + describe('update', () => { + it('should update a lead successfully', async () => { + const updateDto = { + firstName: 'Jane', + email: 'jane.doe@example.com', + }; + + const updatedLead = { ...mockLead, ...updateDto }; + leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity); + leadRepo.save.mockResolvedValue(updatedLead as LeadEntity); + + const result = await service.update(mockTenantId, mockLeadId, updateDto); + + expect(result.firstName).toBe('Jane'); + expect(result.email).toBe('jane.doe@example.com'); + }); + + it('should throw NotFoundException when lead not found', async () => { + leadRepo.findOne.mockResolvedValue(null); + + await expect(service.update(mockTenantId, 'non-existent-id', { firstName: 'Test' })) + .rejects.toThrow(NotFoundException); + }); + + it('should update status to qualified', async () => { + const updateDto = { status: LeadStatus.QUALIFIED }; + const updatedLead = { ...mockLead, status: LeadStatus.QUALIFIED }; + + leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity); + leadRepo.save.mockResolvedValue(updatedLead as LeadEntity); + + const result = await service.update(mockTenantId, mockLeadId, updateDto); + + expect(result.status).toBe(LeadStatus.QUALIFIED); + }); + }); + + describe('remove', () => { + it('should soft delete a lead', async () => { + leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity); + leadRepo.save.mockResolvedValue({ ...mockLead, deletedAt: new Date() } as LeadEntity); + + await service.remove(mockTenantId, mockLeadId); + + expect(leadRepo.save).toHaveBeenCalledWith(expect.objectContaining({ + deletedAt: expect.any(Date), + })); + }); + + it('should throw NotFoundException when lead not found', async () => { + leadRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, 'non-existent-id')) + .rejects.toThrow(NotFoundException); + }); + }); + + describe('convert', () => { + it('should convert a lead to opportunity', async () => { + const convertDto = { + opportunityName: 'New Deal', + amount: 10000, + expectedCloseDate: '2026-03-01', + }; + + const mockOpportunityId = '550e8400-e29b-41d4-a716-446655440010'; + leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity); + dataSource.query.mockResolvedValue([{ opportunity_id: mockOpportunityId }]); + + const result = await service.convert(mockTenantId, mockLeadId, convertDto); + + expect(result.opportunityId).toBe(mockOpportunityId); + expect(dataSource.query).toHaveBeenCalledWith( + expect.stringContaining('convert_lead_to_opportunity'), + [mockLeadId, convertDto.opportunityName, convertDto.amount, convertDto.expectedCloseDate] + ); + }); + + it('should throw NotFoundException when lead not found', async () => { + leadRepo.findOne.mockResolvedValue(null); + + await expect(service.convert(mockTenantId, 'non-existent-id', {})) + .rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when lead is already converted', async () => { + const convertedLead = { ...mockLead, status: LeadStatus.CONVERTED }; + leadRepo.findOne.mockResolvedValue(convertedLead as LeadEntity); + + await expect(service.convert(mockTenantId, mockLeadId, {})) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('calculateScore', () => { + it('should calculate and update lead score', async () => { + const expectedScore = 75; + leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity); + dataSource.query.mockResolvedValue([{ score: expectedScore }]); + leadRepo.save.mockResolvedValue({ ...mockLead, score: expectedScore } as LeadEntity); + + const result = await service.calculateScore(mockTenantId, mockLeadId); + + expect(result.score).toBe(expectedScore); + expect(dataSource.query).toHaveBeenCalledWith( + expect.stringContaining('calculate_lead_score'), + [mockLeadId] + ); + }); + + it('should throw NotFoundException when lead not found', async () => { + leadRepo.findOne.mockResolvedValue(null); + + await expect(service.calculateScore(mockTenantId, 'non-existent-id')) + .rejects.toThrow(NotFoundException); + }); + + it('should default to 0 when score calculation returns null', async () => { + leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity); + dataSource.query.mockResolvedValue([{ score: null }]); + leadRepo.save.mockResolvedValue({ ...mockLead, score: 0 } as LeadEntity); + + const result = await service.calculateScore(mockTenantId, mockLeadId); + + expect(result.score).toBe(0); + }); + }); + + describe('assignTo', () => { + it('should assign a lead to a user', async () => { + const newAssignee = '550e8400-e29b-41d4-a716-446655440099'; + const assignedLead = { ...mockLead, assignedTo: newAssignee }; + + leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity); + leadRepo.save.mockResolvedValue(assignedLead as LeadEntity); + + const result = await service.assignTo(mockTenantId, mockLeadId, newAssignee); + + expect(result.assignedTo).toBe(newAssignee); + expect(leadRepo.save).toHaveBeenCalledWith(expect.objectContaining({ + assignedTo: newAssignee, + })); + }); + + it('should throw NotFoundException when lead not found', async () => { + leadRepo.findOne.mockResolvedValue(null); + + await expect(service.assignTo(mockTenantId, 'non-existent-id', mockUserId)) + .rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/src/modules/sales/__tests__/opportunities.service.spec.ts b/src/modules/sales/__tests__/opportunities.service.spec.ts new file mode 100644 index 0000000..697a35a --- /dev/null +++ b/src/modules/sales/__tests__/opportunities.service.spec.ts @@ -0,0 +1,490 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; + +import { OpportunitiesService } from '../services/opportunities.service'; +import { OpportunityEntity, OpportunityStage } from '../entities'; + +describe('OpportunitiesService', () => { + let service: OpportunitiesService; + let opportunityRepo: jest.Mocked>; + let dataSource: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockOpportunityId = '550e8400-e29b-41d4-a716-446655440003'; + const mockLeadId = '550e8400-e29b-41d4-a716-446655440004'; + + const mockOpportunity: Partial = { + id: mockOpportunityId, + tenantId: mockTenantId, + name: 'Big Deal', + description: 'A very big deal', + leadId: mockLeadId, + stage: OpportunityStage.QUALIFICATION, + stageId: '550e8400-e29b-41d4-a716-446655440005', + amount: 50000, + currency: 'USD', + probability: 60, + expectedCloseDate: new Date('2026-06-01'), + assignedTo: mockUserId, + contactName: 'Jane Doe', + contactEmail: 'jane@example.com', + contactPhone: '+1234567890', + companyName: 'Acme Inc', + notes: 'Important opportunity', + customFields: {}, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: mockUserId, + }; + + beforeEach(async () => { + const mockOpportunityRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockDataSource = { + query: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OpportunitiesService, + { provide: getRepositoryToken(OpportunityEntity), useValue: mockOpportunityRepo }, + { provide: DataSource, useValue: mockDataSource }, + ], + }).compile(); + + service = module.get(OpportunitiesService); + opportunityRepo = module.get(getRepositoryToken(OpportunityEntity)); + dataSource = module.get(DataSource); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create a new opportunity successfully', async () => { + const createDto = { + name: 'Big Deal', + description: 'A very big deal', + leadId: mockLeadId, + amount: 50000, + probability: 60, + expectedCloseDate: '2026-06-01', + contactName: 'Jane Doe', + companyName: 'Acme Inc', + }; + + opportunityRepo.create.mockReturnValue(mockOpportunity as OpportunityEntity); + opportunityRepo.save.mockResolvedValue(mockOpportunity as OpportunityEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(opportunityRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + tenantId: mockTenantId, + name: createDto.name, + leadId: createDto.leadId, + createdBy: mockUserId, + })); + expect(result.id).toBe(mockOpportunityId); + expect(result.name).toBe('Big Deal'); + }); + + it('should create opportunity with default stage PROSPECTING', async () => { + const createDto = { + name: 'New Opportunity', + }; + + opportunityRepo.create.mockReturnValue({ + ...mockOpportunity, + stage: OpportunityStage.PROSPECTING + } as OpportunityEntity); + opportunityRepo.save.mockResolvedValue({ + ...mockOpportunity, + stage: OpportunityStage.PROSPECTING + } as OpportunityEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(opportunityRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + stage: OpportunityStage.PROSPECTING, + })); + }); + + it('should create opportunity with custom stage', async () => { + const createDto = { + name: 'Hot Lead', + stage: OpportunityStage.PROPOSAL, + }; + + opportunityRepo.create.mockReturnValue({ + ...mockOpportunity, + stage: OpportunityStage.PROPOSAL + } as OpportunityEntity); + opportunityRepo.save.mockResolvedValue({ + ...mockOpportunity, + stage: OpportunityStage.PROPOSAL + } as OpportunityEntity); + + const result = await service.create(mockTenantId, mockUserId, createDto); + + expect(result.stage).toBe(OpportunityStage.PROPOSAL); + }); + + it('should create opportunity with default currency USD', async () => { + const createDto = { + name: 'International Deal', + amount: 10000, + }; + + opportunityRepo.create.mockReturnValue(mockOpportunity as OpportunityEntity); + opportunityRepo.save.mockResolvedValue(mockOpportunity as OpportunityEntity); + + await service.create(mockTenantId, mockUserId, createDto); + + expect(opportunityRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + currency: 'USD', + })); + }); + }); + + describe('findAll', () => { + it('should return paginated opportunities', 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([[mockOpportunity], 1]), + }; + + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId, { page: 1, limit: 20 }); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.totalPages).toBe(1); + }); + + it('should filter by stage', 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([[mockOpportunity], 1]), + }; + + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { stage: OpportunityStage.QUALIFICATION }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'o.stage = :stage', + { stage: OpportunityStage.QUALIFICATION } + ); + }); + + it('should filter by stageId', 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([[mockOpportunity], 1]), + }; + + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + const stageId = '550e8400-e29b-41d4-a716-446655440005'; + + await service.findAll(mockTenantId, { stageId }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'o.stage_id = :stageId', + { stageId } + ); + }); + + it('should filter by assignedTo', 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([[mockOpportunity], 1]), + }; + + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { assignedTo: mockUserId }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'o.assigned_to = :assignedTo', + { assignedTo: mockUserId } + ); + }); + + it('should search by name, company, or contact', 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([[mockOpportunity], 1]), + }; + + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { search: 'Acme' }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + '(o.name ILIKE :search OR o.company_name ILIKE :search OR o.contact_name ILIKE :search)', + { search: '%Acme%' } + ); + }); + + it('should limit results to max 100', 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([[], 0]), + }; + + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.findAll(mockTenantId, { limit: 500 }); + + expect(mockQueryBuilder.take).toHaveBeenCalledWith(100); + }); + }); + + describe('findOne', () => { + it('should return an opportunity by id', async () => { + opportunityRepo.findOne.mockResolvedValue(mockOpportunity as OpportunityEntity); + + const result = await service.findOne(mockTenantId, mockOpportunityId); + + expect(result.id).toBe(mockOpportunityId); + expect(result.name).toBe('Big Deal'); + expect(opportunityRepo.findOne).toHaveBeenCalledWith({ + where: { id: mockOpportunityId, tenantId: mockTenantId, deletedAt: null }, + relations: ['lead', 'pipelineStage'], + }); + }); + + it('should throw NotFoundException when opportunity not found', async () => { + opportunityRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'non-existent-id')) + .rejects.toThrow(NotFoundException); + }); + }); + + describe('update', () => { + it('should update an opportunity successfully', async () => { + const updateDto = { + name: 'Bigger Deal', + amount: 75000, + probability: 80, + }; + + const updatedOpportunity = { ...mockOpportunity, ...updateDto }; + opportunityRepo.findOne.mockResolvedValue(mockOpportunity as OpportunityEntity); + opportunityRepo.save.mockResolvedValue(updatedOpportunity as OpportunityEntity); + + const result = await service.update(mockTenantId, mockOpportunityId, updateDto); + + expect(result.name).toBe('Bigger Deal'); + expect(result.amount).toBe(75000); + expect(result.probability).toBe(80); + }); + + it('should throw NotFoundException when opportunity not found', async () => { + opportunityRepo.findOne.mockResolvedValue(null); + + await expect(service.update(mockTenantId, 'non-existent-id', { name: 'Test' })) + .rejects.toThrow(NotFoundException); + }); + + it('should update stage and probability', async () => { + const updateDto = { + stage: OpportunityStage.NEGOTIATION, + probability: 90, + }; + + const updatedOpportunity = { ...mockOpportunity, ...updateDto }; + opportunityRepo.findOne.mockResolvedValue(mockOpportunity as OpportunityEntity); + opportunityRepo.save.mockResolvedValue(updatedOpportunity as OpportunityEntity); + + const result = await service.update(mockTenantId, mockOpportunityId, updateDto); + + expect(result.stage).toBe(OpportunityStage.NEGOTIATION); + expect(result.probability).toBe(90); + }); + + it('should update lost reason when losing deal', async () => { + const updateDto = { + stage: OpportunityStage.CLOSED_LOST, + lostReason: 'Price too high', + }; + + const updatedOpportunity = { ...mockOpportunity, ...updateDto }; + opportunityRepo.findOne.mockResolvedValue(mockOpportunity as OpportunityEntity); + opportunityRepo.save.mockResolvedValue(updatedOpportunity as OpportunityEntity); + + const result = await service.update(mockTenantId, mockOpportunityId, updateDto); + + expect(result.stage).toBe(OpportunityStage.CLOSED_LOST); + expect(result.lostReason).toBe('Price too high'); + }); + }); + + describe('updateStage', () => { + it('should update opportunity stage using database function', async () => { + const updateStageDto = { + stage: OpportunityStage.PROPOSAL, + notes: 'Moving to proposal stage', + }; + + const updatedOpportunity = { ...mockOpportunity, stage: OpportunityStage.PROPOSAL }; + opportunityRepo.findOne + .mockResolvedValueOnce(mockOpportunity as OpportunityEntity) + .mockResolvedValueOnce(updatedOpportunity as OpportunityEntity); + dataSource.query.mockResolvedValue([]); + + const result = await service.updateStage(mockTenantId, mockOpportunityId, updateStageDto); + + expect(dataSource.query).toHaveBeenCalledWith( + expect.stringContaining('update_opportunity_stage'), + [mockOpportunityId, OpportunityStage.PROPOSAL, 'Moving to proposal stage'] + ); + expect(result.stage).toBe(OpportunityStage.PROPOSAL); + }); + + it('should throw NotFoundException when opportunity not found', async () => { + opportunityRepo.findOne.mockResolvedValue(null); + + await expect(service.updateStage(mockTenantId, 'non-existent-id', { stage: OpportunityStage.PROPOSAL })) + .rejects.toThrow(NotFoundException); + }); + + it('should update stage without notes', async () => { + const updateStageDto = { + stage: OpportunityStage.CLOSED_WON, + }; + + const updatedOpportunity = { ...mockOpportunity, stage: OpportunityStage.CLOSED_WON }; + opportunityRepo.findOne + .mockResolvedValueOnce(mockOpportunity as OpportunityEntity) + .mockResolvedValueOnce(updatedOpportunity as OpportunityEntity); + dataSource.query.mockResolvedValue([]); + + await service.updateStage(mockTenantId, mockOpportunityId, updateStageDto); + + expect(dataSource.query).toHaveBeenCalledWith( + expect.stringContaining('update_opportunity_stage'), + [mockOpportunityId, OpportunityStage.CLOSED_WON, null] + ); + }); + }); + + describe('remove', () => { + it('should soft delete an opportunity', async () => { + opportunityRepo.findOne.mockResolvedValue(mockOpportunity as OpportunityEntity); + opportunityRepo.save.mockResolvedValue({ ...mockOpportunity, deletedAt: new Date() } as OpportunityEntity); + + await service.remove(mockTenantId, mockOpportunityId); + + expect(opportunityRepo.save).toHaveBeenCalledWith(expect.objectContaining({ + deletedAt: expect.any(Date), + })); + }); + + it('should throw NotFoundException when opportunity not found', async () => { + opportunityRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, 'non-existent-id')) + .rejects.toThrow(NotFoundException); + }); + }); + + describe('getPipelineSummary', () => { + it('should return pipeline summary by stage', async () => { + const mockPipelineData = [ + { stage: OpportunityStage.PROSPECTING, count: 10, total_amount: 100000, avg_probability: 20 }, + { stage: OpportunityStage.QUALIFICATION, count: 8, total_amount: 150000, avg_probability: 40 }, + { stage: OpportunityStage.PROPOSAL, count: 5, total_amount: 200000, avg_probability: 60 }, + { stage: OpportunityStage.CLOSED_WON, count: 3, total_amount: 75000, avg_probability: 100 }, + ]; + + dataSource.query.mockResolvedValue(mockPipelineData); + + const result = await service.getPipelineSummary(mockTenantId); + + expect(result).toHaveLength(4); + expect(result[0].stage).toBe(OpportunityStage.PROSPECTING); + expect(result[0].count).toBe(10); + expect(result[0].totalAmount).toBe(100000); + expect(result[0].avgProbability).toBe(20); + expect(dataSource.query).toHaveBeenCalledWith( + expect.stringContaining('get_pipeline_summary'), + [mockTenantId] + ); + }); + + it('should return empty array when no opportunities', async () => { + dataSource.query.mockResolvedValue([]); + + const result = await service.getPipelineSummary(mockTenantId); + + expect(result).toHaveLength(0); + }); + }); + + describe('assignTo', () => { + it('should assign an opportunity to a user', async () => { + const newAssignee = '550e8400-e29b-41d4-a716-446655440099'; + const assignedOpportunity = { ...mockOpportunity, assignedTo: newAssignee }; + + opportunityRepo.findOne.mockResolvedValue(mockOpportunity as OpportunityEntity); + opportunityRepo.save.mockResolvedValue(assignedOpportunity as OpportunityEntity); + + const result = await service.assignTo(mockTenantId, mockOpportunityId, newAssignee); + + expect(result.assignedTo).toBe(newAssignee); + expect(opportunityRepo.save).toHaveBeenCalledWith(expect.objectContaining({ + assignedTo: newAssignee, + })); + }); + + it('should throw NotFoundException when opportunity not found', async () => { + opportunityRepo.findOne.mockResolvedValue(null); + + await expect(service.assignTo(mockTenantId, 'non-existent-id', mockUserId)) + .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..e05a32d --- /dev/null +++ b/src/modules/sales/__tests__/pipeline.service.spec.ts @@ -0,0 +1,483 @@ +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 mockStageId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockStage: Partial = { + id: mockStageId, + tenantId: mockTenantId, + name: 'Qualification', + position: 2, + color: '#3B82F6', + isWon: false, + isLost: false, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockStages: Partial[] = [ + { id: '1', tenantId: mockTenantId, name: 'Prospecting', position: 1, color: '#9CA3AF', isWon: false, isLost: false, isActive: true }, + { id: '2', tenantId: mockTenantId, name: 'Qualification', position: 2, color: '#3B82F6', isWon: false, isLost: false, isActive: true }, + { id: '3', tenantId: mockTenantId, name: 'Proposal', position: 3, color: '#F59E0B', isWon: false, isLost: false, isActive: true }, + { id: '4', tenantId: mockTenantId, name: 'Negotiation', position: 4, color: '#8B5CF6', isWon: false, isLost: false, isActive: true }, + { id: '5', tenantId: mockTenantId, name: 'Closed Won', position: 5, color: '#10B981', isWon: true, isLost: false, isActive: true }, + { id: '6', tenantId: mockTenantId, name: 'Closed Lost', position: 6, color: '#EF4444', isWon: false, isLost: true, isActive: true }, + ]; + + beforeEach(async () => { + const mockStageRepo = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: 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( + expect.stringContaining('initialize_default_stages'), + [mockTenantId] + ); + }); + }); + + describe('findAll', () => { + it('should return all pipeline stages with opportunity stats', async () => { + const mockOpportunityStats = [ + { stageId: '1', count: 10, total: 100000 }, + { stageId: '2', count: 8, total: 150000 }, + { stageId: '3', count: 5, total: 200000 }, + ]; + + 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(mockOpportunityStats), + }; + + stageRepo.find.mockResolvedValue(mockStages as PipelineStageEntity[]); + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId); + + expect(result).toHaveLength(6); + expect(result[0].name).toBe('Prospecting'); + expect(result[0].opportunityCount).toBe(10); + expect(result[0].totalAmount).toBe(100000); + expect(stageRepo.find).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId }, + order: { position: 'ASC' }, + }); + }); + + it('should return stages with zero counts 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(mockStages as PipelineStageEntity[]); + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId); + + expect(result).toHaveLength(6); + result.forEach(stage => { + expect(stage.opportunityCount).toBe(0); + expect(stage.totalAmount).toBe(0); + }); + }); + + it('should return empty array when no stages exist', 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([]); + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.findAll(mockTenantId); + + expect(result).toHaveLength(0); + }); + }); + + describe('findOne', () => { + it('should return a stage by id', async () => { + stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity); + + const result = await service.findOne(mockTenantId, mockStageId); + + expect(result.id).toBe(mockStageId); + expect(result.name).toBe('Qualification'); + expect(stageRepo.findOne).toHaveBeenCalledWith({ + where: { id: mockStageId, tenantId: mockTenantId }, + }); + }); + + it('should throw NotFoundException when stage not found', async () => { + stageRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'non-existent-id')) + .rejects.toThrow(NotFoundException); + }); + }); + + describe('create', () => { + it('should create a new pipeline stage', async () => { + const createDto = { + name: 'New Stage', + color: '#FF5733', + isWon: false, + isLost: false, + }; + + 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, ...createDto, position: 6 } as PipelineStageEntity); + stageRepo.save.mockResolvedValue({ ...mockStage, ...createDto, position: 6 } as PipelineStageEntity); + + const result = await service.create(mockTenantId, createDto); + + expect(result.name).toBe('New Stage'); + expect(result.color).toBe('#FF5733'); + expect(stageRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + tenantId: mockTenantId, + name: 'New Stage', + position: 6, + })); + }); + + it('should create stage with custom position', async () => { + const createDto = { + name: 'Custom Stage', + position: 3, + }; + + 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, ...createDto } as PipelineStageEntity); + stageRepo.save.mockResolvedValue({ ...mockStage, ...createDto } as PipelineStageEntity); + + const result = await service.create(mockTenantId, createDto); + + expect(result.position).toBe(3); + }); + + it('should create stage with default values', async () => { + const createDto = { + name: 'Basic Stage', + }; + + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ max: null }), + }; + + stageRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + stageRepo.create.mockReturnValue({ + ...createDto, + tenantId: mockTenantId, + position: 1, + color: '#3B82F6', + isWon: false, + isLost: false, + isActive: true, + } as PipelineStageEntity); + stageRepo.save.mockResolvedValue({ + ...createDto, + tenantId: mockTenantId, + position: 1, + color: '#3B82F6', + isWon: false, + isLost: false, + isActive: true, + } as PipelineStageEntity); + + const result = await service.create(mockTenantId, createDto); + + expect(result.color).toBe('#3B82F6'); + expect(result.isWon).toBe(false); + expect(result.isLost).toBe(false); + expect(result.isActive).toBe(true); + }); + + it('should create won stage', async () => { + const createDto = { + name: 'Won', + isWon: true, + color: '#10B981', + }; + + 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, ...createDto, isWon: true } as PipelineStageEntity); + stageRepo.save.mockResolvedValue({ ...mockStage, ...createDto, isWon: true } as PipelineStageEntity); + + const result = await service.create(mockTenantId, createDto); + + expect(result.isWon).toBe(true); + }); + + it('should create lost stage', async () => { + const createDto = { + name: 'Lost', + isLost: true, + color: '#EF4444', + }; + + 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, ...createDto, isLost: true } as PipelineStageEntity); + stageRepo.save.mockResolvedValue({ ...mockStage, ...createDto, isLost: true } as PipelineStageEntity); + + const result = await service.create(mockTenantId, createDto); + + expect(result.isLost).toBe(true); + }); + }); + + describe('update', () => { + it('should update a pipeline stage', async () => { + const updateDto = { + name: 'Updated Stage', + color: '#FF0000', + }; + + const updatedStage = { ...mockStage, ...updateDto }; + stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity); + stageRepo.save.mockResolvedValue(updatedStage as PipelineStageEntity); + + const result = await service.update(mockTenantId, mockStageId, updateDto); + + expect(result.name).toBe('Updated Stage'); + expect(result.color).toBe('#FF0000'); + }); + + it('should throw NotFoundException when stage not found', async () => { + stageRepo.findOne.mockResolvedValue(null); + + await expect(service.update(mockTenantId, 'non-existent-id', { name: 'Test' })) + .rejects.toThrow(NotFoundException); + }); + + it('should update stage position', async () => { + const updateDto = { + position: 4, + }; + + const updatedStage = { ...mockStage, position: 4 }; + stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity); + stageRepo.save.mockResolvedValue(updatedStage as PipelineStageEntity); + + const result = await service.update(mockTenantId, mockStageId, updateDto); + + expect(result.position).toBe(4); + }); + + it('should update isActive status', async () => { + const updateDto = { + isActive: false, + }; + + const updatedStage = { ...mockStage, isActive: false }; + stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity); + stageRepo.save.mockResolvedValue(updatedStage as PipelineStageEntity); + + const result = await service.update(mockTenantId, mockStageId, updateDto); + + expect(result.isActive).toBe(false); + }); + }); + + describe('remove', () => { + it('should delete a pipeline stage without opportunities', async () => { + stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity); + opportunityRepo.count.mockResolvedValue(0); + + await service.remove(mockTenantId, mockStageId); + + expect(stageRepo.remove).toHaveBeenCalledWith(mockStage); + }); + + it('should throw NotFoundException when stage not found', async () => { + stageRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, 'non-existent-id')) + .rejects.toThrow(NotFoundException); + }); + + it('should throw error when stage has opportunities', async () => { + stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity); + opportunityRepo.count.mockResolvedValue(5); + + await expect(service.remove(mockTenantId, mockStageId)) + .rejects.toThrow(NotFoundException); + }); + }); + + describe('reorder', () => { + it('should reorder pipeline stages', async () => { + const reorderDto = { + stageIds: ['3', '1', '2', '4', '5', '6'], + }; + + 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([] as PipelineStageEntity[]); + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.reorder(mockTenantId, reorderDto); + + expect(stageRepo.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should skip non-existent stage ids', async () => { + const reorderDto = { + stageIds: ['1', 'non-existent', '2'], + }; + + 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.slice(0, 2) as PipelineStageEntity[]); + stageRepo.save.mockResolvedValue([] as PipelineStageEntity[]); + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + await service.reorder(mockTenantId, reorderDto); + + expect(stageRepo.save).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: '1', position: 1 }), + expect.objectContaining({ id: '2', position: 3 }), + ]) + ); + }); + + it('should return updated stages after reorder', async () => { + const reorderDto = { + stageIds: ['2', '1', '3'], + }; + + const reorderedStages = [ + { ...mockStages[1], position: 1 }, + { ...mockStages[0], position: 2 }, + { ...mockStages[2], position: 3 }, + ]; + + 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 + .mockResolvedValueOnce(mockStages.slice(0, 3) as PipelineStageEntity[]) + .mockResolvedValueOnce(reorderedStages as PipelineStageEntity[]); + stageRepo.save.mockResolvedValue([] as PipelineStageEntity[]); + opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.reorder(mockTenantId, reorderDto); + + expect(result).toBeDefined(); + }); + }); +});