diff --git a/src/modules/portfolio/__tests__/products.controller.spec.ts b/src/modules/portfolio/__tests__/products.controller.spec.ts new file mode 100644 index 0000000..bffb133 --- /dev/null +++ b/src/modules/portfolio/__tests__/products.controller.spec.ts @@ -0,0 +1,362 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ProductsController } from '../controllers/products.controller'; +import { ProductsService } from '../services/products.service'; +import { ProductType, ProductStatus } from '../entities/product.entity'; +import { PriceType } from '../entities/price.entity'; +import { + CreateProductDto, + UpdateProductDto, + ProductListQueryDto, + CreateVariantDto, + CreatePriceDto, +} from '../dto'; + +describe('ProductsController', () => { + let controller: ProductsController; + let productsService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockProductId = '550e8400-e29b-41d4-a716-446655440003'; + const mockVariantId = '550e8400-e29b-41d4-a716-446655440004'; + const mockPriceId = '550e8400-e29b-41d4-a716-446655440005'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockProduct = { + id: mockProductId, + tenantId: mockTenantId, + categoryId: null, + name: 'Test Product', + slug: 'test-product', + sku: 'SKU-001', + barcode: null, + description: 'Test description', + shortDescription: 'Short desc', + productType: ProductType.PHYSICAL, + status: ProductStatus.ACTIVE, + basePrice: 99.99, + costPrice: 50.00, + 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 Meta', + 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: mockUserId, + publishedAt: new Date('2026-01-01'), + category: null, + variantCount: 0, + }; + + const mockVariant = { + id: mockVariantId, + tenantId: mockTenantId, + productId: mockProductId, + sku: 'SKU-001-VAR1', + barcode: null, + name: 'Size: Large', + attributes: { size: 'large', color: 'blue' }, + price: 109.99, + costPrice: 55.00, + compareAtPrice: 139.99, + stockQuantity: 50, + lowStockThreshold: 5, + weight: 1.6, + imageUrl: 'variant.jpg', + isActive: true, + position: 0, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }; + + const mockPrice = { + id: mockPriceId, + tenantId: mockTenantId, + productId: mockProductId, + 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'), + }; + + const mockPaginatedProducts = { + items: [mockProduct], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + beforeEach(async () => { + const mockProductsService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateStatus: jest.fn(), + duplicate: jest.fn(), + remove: jest.fn(), + getVariants: jest.fn(), + createVariant: jest.fn(), + updateVariant: jest.fn(), + removeVariant: jest.fn(), + getPrices: jest.fn(), + createPrice: jest.fn(), + updatePrice: jest.fn(), + removePrice: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ProductsController], + providers: [ + { + provide: ProductsService, + useValue: mockProductsService, + }, + ], + }).compile(); + + controller = module.get(ProductsController); + productsService = module.get(ProductsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated products', async () => { + const query: ProductListQueryDto = { page: 1, limit: 20 }; + productsService.findAll.mockResolvedValue(mockPaginatedProducts); + + const result = await controller.findAll(mockRequestUser, query); + + expect(productsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + expect(result).toEqual(mockPaginatedProducts); + expect(result.items.length).toBe(1); + }); + + it('should filter by status', async () => { + const query: ProductListQueryDto = { status: ProductStatus.ACTIVE }; + productsService.findAll.mockResolvedValue(mockPaginatedProducts); + + await controller.findAll(mockRequestUser, query); + + expect(productsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + }); + + describe('findOne', () => { + it('should return a product by id', async () => { + productsService.findOne.mockResolvedValue(mockProduct); + + const result = await controller.findOne(mockRequestUser, mockProductId); + + expect(productsService.findOne).toHaveBeenCalledWith(mockTenantId, mockProductId); + expect(result).toEqual(mockProduct); + }); + }); + + describe('create', () => { + it('should create a new product', async () => { + const createDto: CreateProductDto = { + name: 'New Product', + slug: 'new-product', + basePrice: 49.99, + }; + productsService.create.mockResolvedValue(mockProduct); + + const result = await controller.create(mockRequestUser, createDto); + + expect(productsService.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + expect(result).toEqual(mockProduct); + }); + }); + + describe('update', () => { + it('should update a product', async () => { + const updateDto: UpdateProductDto = { + name: 'Updated Product', + basePrice: 79.99, + }; + const updatedProduct = { ...mockProduct, ...updateDto }; + productsService.update.mockResolvedValue(updatedProduct); + + const result = await controller.update(mockRequestUser, mockProductId, updateDto); + + expect(productsService.update).toHaveBeenCalledWith(mockTenantId, mockProductId, updateDto); + expect(result.name).toBe('Updated Product'); + }); + }); + + describe('updateStatus', () => { + it('should update product status', async () => { + const statusDto = { status: ProductStatus.INACTIVE }; + const updatedProduct = { ...mockProduct, status: ProductStatus.INACTIVE }; + productsService.updateStatus.mockResolvedValue(updatedProduct); + + const result = await controller.updateStatus(mockRequestUser, mockProductId, statusDto); + + expect(productsService.updateStatus).toHaveBeenCalledWith(mockTenantId, mockProductId, statusDto); + expect(result.status).toBe(ProductStatus.INACTIVE); + }); + }); + + describe('duplicate', () => { + it('should duplicate a product', async () => { + const duplicatedProduct = { ...mockProduct, id: 'new-id', name: 'Test Product (Copy)' }; + productsService.duplicate.mockResolvedValue(duplicatedProduct); + + const result = await controller.duplicate(mockRequestUser, mockProductId); + + expect(productsService.duplicate).toHaveBeenCalledWith(mockTenantId, mockUserId, mockProductId); + expect(result.name).toContain('Copy'); + }); + }); + + describe('remove', () => { + it('should delete a product', async () => { + productsService.remove.mockResolvedValue(undefined); + + const result = await controller.remove(mockRequestUser, mockProductId); + + expect(productsService.remove).toHaveBeenCalledWith(mockTenantId, mockProductId); + expect(result).toEqual({ message: 'Product deleted successfully' }); + }); + }); + + describe('getVariants', () => { + it('should return variants for a product', async () => { + productsService.getVariants.mockResolvedValue([mockVariant]); + + const result = await controller.getVariants(mockRequestUser, mockProductId); + + expect(productsService.getVariants).toHaveBeenCalledWith(mockTenantId, mockProductId); + expect(result).toEqual([mockVariant]); + }); + }); + + describe('createVariant', () => { + it('should create a variant', async () => { + const createDto: CreateVariantDto = { + attributes: { size: 'medium' }, + price: 99.99, + }; + productsService.createVariant.mockResolvedValue(mockVariant); + + const result = await controller.createVariant(mockRequestUser, mockProductId, createDto); + + expect(productsService.createVariant).toHaveBeenCalledWith(mockTenantId, mockProductId, createDto); + expect(result).toEqual(mockVariant); + }); + }); + + describe('updateVariant', () => { + it('should update a variant', async () => { + const updateDto = { price: 119.99 }; + const updatedVariant = { ...mockVariant, price: 119.99 }; + productsService.updateVariant.mockResolvedValue(updatedVariant); + + const result = await controller.updateVariant(mockRequestUser, mockProductId, mockVariantId, updateDto); + + expect(productsService.updateVariant).toHaveBeenCalledWith(mockTenantId, mockProductId, mockVariantId, updateDto); + expect(result.price).toBe(119.99); + }); + }); + + describe('removeVariant', () => { + it('should delete a variant', async () => { + productsService.removeVariant.mockResolvedValue(undefined); + + const result = await controller.removeVariant(mockRequestUser, mockProductId, mockVariantId); + + expect(productsService.removeVariant).toHaveBeenCalledWith(mockTenantId, mockProductId, mockVariantId); + expect(result).toEqual({ message: 'Variant deleted successfully' }); + }); + }); + + describe('getPrices', () => { + it('should return prices for a product', async () => { + productsService.getPrices.mockResolvedValue([mockPrice]); + + const result = await controller.getPrices(mockRequestUser, mockProductId); + + expect(productsService.getPrices).toHaveBeenCalledWith(mockTenantId, mockProductId); + expect(result).toEqual([mockPrice]); + }); + }); + + describe('createPrice', () => { + it('should create a price', async () => { + const createDto: CreatePriceDto = { + currency: 'USD', + amount: 89.99, + }; + productsService.createPrice.mockResolvedValue(mockPrice); + + const result = await controller.createPrice(mockRequestUser, mockProductId, createDto); + + expect(productsService.createPrice).toHaveBeenCalledWith(mockTenantId, mockProductId, createDto); + expect(result).toEqual(mockPrice); + }); + }); + + describe('updatePrice', () => { + it('should update a price', async () => { + const updateDto = { amount: 109.99 }; + const updatedPrice = { ...mockPrice, amount: 109.99 }; + productsService.updatePrice.mockResolvedValue(updatedPrice); + + const result = await controller.updatePrice(mockRequestUser, mockProductId, mockPriceId, updateDto); + + expect(productsService.updatePrice).toHaveBeenCalledWith(mockTenantId, mockProductId, mockPriceId, updateDto); + expect(result.amount).toBe(109.99); + }); + }); + + describe('removePrice', () => { + it('should delete a price', async () => { + productsService.removePrice.mockResolvedValue(undefined); + + const result = await controller.removePrice(mockRequestUser, mockProductId, mockPriceId); + + expect(productsService.removePrice).toHaveBeenCalledWith(mockTenantId, mockProductId, mockPriceId); + expect(result).toEqual({ message: 'Price deleted successfully' }); + }); + }); +}); diff --git a/src/modules/sales/__tests__/activities.controller.spec.ts b/src/modules/sales/__tests__/activities.controller.spec.ts new file mode 100644 index 0000000..2e6138c --- /dev/null +++ b/src/modules/sales/__tests__/activities.controller.spec.ts @@ -0,0 +1,205 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ActivitiesController } from '../controllers/activities.controller'; +import { ActivitiesService } from '../services/activities.service'; +import { ActivityType, ActivityStatus } from '../entities/activity.entity'; +import { CreateActivityDto, UpdateActivityDto, ActivityListQueryDto } from '../dto'; + +describe('ActivitiesController', () => { + let controller: ActivitiesController; + let activitiesService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockActivityId = '550e8400-e29b-41d4-a716-446655440003'; + const mockLeadId = '550e8400-e29b-41d4-a716-446655440004'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockActivity = { + id: mockActivityId, + tenantId: mockTenantId, + type: ActivityType.CALL, + status: ActivityStatus.PENDING, + subject: 'Follow-up call', + description: 'Discuss pricing options', + leadId: mockLeadId, + opportunityId: null, + dueDate: new Date('2026-02-10'), + dueTime: '14:00', + durationMinutes: 30, + completedAt: null, + outcome: null, + assignedTo: mockUserId, + createdBy: mockUserId, + callDirection: 'outbound', + callRecordingUrl: null, + location: null, + meetingUrl: null, + attendees: [], + reminderAt: new Date('2026-02-10T13:45:00'), + reminderSent: false, + customFields: {}, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + lead: null, + opportunity: null, + }; + + const mockPaginatedActivities = { + items: [mockActivity], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + beforeEach(async () => { + const mockActivitiesService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + complete: jest.fn(), + remove: jest.fn(), + getUpcoming: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ActivitiesController], + providers: [ + { + provide: ActivitiesService, + useValue: mockActivitiesService, + }, + ], + }).compile(); + + controller = module.get(ActivitiesController); + activitiesService = module.get(ActivitiesService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated activities', async () => { + const query: ActivityListQueryDto = { page: 1, limit: 20 }; + activitiesService.findAll.mockResolvedValue(mockPaginatedActivities); + + const result = await controller.findAll(mockRequestUser, query); + + expect(activitiesService.findAll).toHaveBeenCalledWith(mockTenantId, query); + expect(result).toEqual(mockPaginatedActivities); + expect(result.items.length).toBe(1); + }); + + it('should filter by type', async () => { + const query: ActivityListQueryDto = { type: ActivityType.MEETING }; + activitiesService.findAll.mockResolvedValue({ ...mockPaginatedActivities, items: [] }); + + await controller.findAll(mockRequestUser, query); + + expect(activitiesService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + + it('should filter by status', async () => { + const query: ActivityListQueryDto = { status: ActivityStatus.COMPLETED }; + activitiesService.findAll.mockResolvedValue({ ...mockPaginatedActivities, items: [] }); + + await controller.findAll(mockRequestUser, query); + + expect(activitiesService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + }); + + describe('getUpcoming', () => { + it('should return upcoming activities', async () => { + activitiesService.getUpcoming.mockResolvedValue([mockActivity]); + + const result = await controller.getUpcoming(mockRequestUser); + + expect(activitiesService.getUpcoming).toHaveBeenCalledWith(mockTenantId, mockUserId); + expect(result).toEqual([mockActivity]); + }); + }); + + describe('findOne', () => { + it('should return an activity by id', async () => { + activitiesService.findOne.mockResolvedValue(mockActivity); + + const result = await controller.findOne(mockRequestUser, mockActivityId); + + expect(activitiesService.findOne).toHaveBeenCalledWith(mockTenantId, mockActivityId); + expect(result).toEqual(mockActivity); + }); + }); + + describe('create', () => { + it('should create a new activity', async () => { + const createDto: CreateActivityDto = { + type: ActivityType.CALL, + subject: 'New Call', + leadId: mockLeadId, + dueDate: '2026-02-15', + }; + activitiesService.create.mockResolvedValue(mockActivity); + + const result = await controller.create(mockRequestUser, createDto); + + expect(activitiesService.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + expect(result).toEqual(mockActivity); + }); + }); + + describe('update', () => { + it('should update an activity', async () => { + const updateDto: UpdateActivityDto = { + subject: 'Updated Subject', + durationMinutes: 60, + }; + const updatedActivity = { ...mockActivity, subject: 'Updated Subject', durationMinutes: 60 }; + activitiesService.update.mockResolvedValue(updatedActivity); + + const result = await controller.update(mockRequestUser, mockActivityId, updateDto); + + expect(activitiesService.update).toHaveBeenCalledWith(mockTenantId, mockActivityId, updateDto); + expect(result.subject).toBe('Updated Subject'); + }); + }); + + describe('complete', () => { + it('should complete an activity', async () => { + const completeDto = { outcome: 'Customer interested, follow up next week' }; + const completedActivity = { + ...mockActivity, + status: ActivityStatus.COMPLETED, + completedAt: new Date(), + outcome: completeDto.outcome, + }; + activitiesService.complete.mockResolvedValue(completedActivity); + + const result = await controller.complete(mockRequestUser, mockActivityId, completeDto); + + expect(activitiesService.complete).toHaveBeenCalledWith(mockTenantId, mockActivityId, completeDto); + expect(result.status).toBe(ActivityStatus.COMPLETED); + expect(result.outcome).toBe(completeDto.outcome); + }); + }); + + describe('remove', () => { + it('should delete an activity', async () => { + activitiesService.remove.mockResolvedValue(undefined); + + const result = await controller.remove(mockRequestUser, mockActivityId); + + expect(activitiesService.remove).toHaveBeenCalledWith(mockTenantId, mockActivityId); + expect(result).toEqual({ message: 'Activity deleted successfully' }); + }); + }); +}); diff --git a/src/modules/sales/__tests__/dashboard.controller.spec.ts b/src/modules/sales/__tests__/dashboard.controller.spec.ts new file mode 100644 index 0000000..d179580 --- /dev/null +++ b/src/modules/sales/__tests__/dashboard.controller.spec.ts @@ -0,0 +1,191 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SalesDashboardController } from '../controllers/dashboard.controller'; +import { SalesDashboardService } from '../services'; +import { LeadStatus, LeadSource, OpportunityStage } from '../entities'; +import { + SalesDashboardDto, + LeadsByStatusDto, + LeadsBySourceDto, + OpportunitiesByStageDto, + ConversionFunnelDto, + SalesPerformanceDto, +} from '../dto'; + +describe('SalesDashboardController', () => { + let controller: SalesDashboardController; + let dashboardService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockSummary: SalesDashboardDto = { + totalLeads: 150, + totalOpportunities: 45, + totalPipelineValue: 1250000, + wonDeals: 12, + conversionRate: 26.7, + averageDealSize: 104166.67, + activitiesThisWeek: 35, + currency: 'USD', + }; + + const mockLeadsByStatus: LeadsByStatusDto[] = [ + { status: LeadStatus.NEW, count: 50, percentage: 33.3 }, + { status: LeadStatus.CONTACTED, count: 40, percentage: 26.7 }, + { status: LeadStatus.QUALIFIED, count: 35, percentage: 23.3 }, + { status: LeadStatus.CONVERTED, count: 25, percentage: 16.7 }, + ]; + + const mockLeadsBySource: LeadsBySourceDto[] = [ + { source: LeadSource.WEBSITE, count: 60, percentage: 40.0 }, + { source: LeadSource.REFERRAL, count: 45, percentage: 30.0 }, + { source: LeadSource.EVENT, count: 25, percentage: 16.7 }, + { source: LeadSource.COLD_CALL, count: 20, percentage: 13.3 }, + ]; + + const mockOpportunitiesByStage: OpportunitiesByStageDto[] = [ + { stage: OpportunityStage.QUALIFICATION, count: 15, totalAmount: 375000, percentage: 33.3 }, + { stage: OpportunityStage.PROPOSAL, count: 12, totalAmount: 480000, percentage: 26.7 }, + { stage: OpportunityStage.NEGOTIATION, count: 10, totalAmount: 350000, percentage: 22.2 }, + { stage: OpportunityStage.CLOSED_WON, count: 8, totalAmount: 320000, percentage: 17.8 }, + ]; + + const mockConversionFunnel: ConversionFunnelDto[] = [ + { stage: 'leads', count: 150, percentage: 100, conversionFromPrevious: 100 }, + { stage: 'qualified', count: 75, percentage: 50, conversionFromPrevious: 50 }, + { stage: 'opportunities', count: 45, percentage: 30, conversionFromPrevious: 60 }, + { stage: 'proposals', count: 30, percentage: 20, conversionFromPrevious: 66.7 }, + { stage: 'won', count: 12, percentage: 8, conversionFromPrevious: 40 }, + ]; + + const mockSalesPerformance: SalesPerformanceDto[] = [ + { + userId: 'user-001', + userName: 'John Doe', + leadsAssigned: 45, + opportunitiesWon: 5, + totalRevenue: 125000, + conversionRate: 11.1, + activitiesCompleted: 120, + }, + { + userId: 'user-002', + userName: 'Jane Smith', + leadsAssigned: 52, + opportunitiesWon: 7, + totalRevenue: 175000, + conversionRate: 13.5, + activitiesCompleted: 145, + }, + ]; + + beforeEach(async () => { + const mockDashboardService = { + getDashboardSummary: jest.fn(), + getLeadsByStatus: jest.fn(), + getLeadsBySource: jest.fn(), + getOpportunitiesByStage: jest.fn(), + getConversionFunnel: jest.fn(), + getSalesPerformance: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [SalesDashboardController], + providers: [ + { + provide: SalesDashboardService, + useValue: mockDashboardService, + }, + ], + }).compile(); + + controller = module.get(SalesDashboardController); + dashboardService = module.get(SalesDashboardService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getSummary', () => { + it('should return dashboard summary', async () => { + dashboardService.getDashboardSummary.mockResolvedValue(mockSummary); + + const result = await controller.getSummary(mockRequestUser); + + expect(dashboardService.getDashboardSummary).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockSummary); + expect(result.totalLeads).toBe(150); + expect(result.conversionRate).toBe(26.7); + }); + }); + + describe('getLeadsByStatus', () => { + it('should return leads grouped by status', async () => { + dashboardService.getLeadsByStatus.mockResolvedValue(mockLeadsByStatus); + + const result = await controller.getLeadsByStatus(mockRequestUser); + + expect(dashboardService.getLeadsByStatus).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockLeadsByStatus); + expect(result.length).toBe(4); + }); + }); + + describe('getLeadsBySource', () => { + it('should return leads grouped by source', async () => { + dashboardService.getLeadsBySource.mockResolvedValue(mockLeadsBySource); + + const result = await controller.getLeadsBySource(mockRequestUser); + + expect(dashboardService.getLeadsBySource).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockLeadsBySource); + expect(result.length).toBe(4); + }); + }); + + describe('getOpportunitiesByStage', () => { + it('should return opportunities grouped by stage', async () => { + dashboardService.getOpportunitiesByStage.mockResolvedValue(mockOpportunitiesByStage); + + const result = await controller.getOpportunitiesByStage(mockRequestUser); + + expect(dashboardService.getOpportunitiesByStage).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockOpportunitiesByStage); + expect(result.length).toBe(4); + }); + }); + + describe('getConversionFunnel', () => { + it('should return conversion funnel data', async () => { + dashboardService.getConversionFunnel.mockResolvedValue(mockConversionFunnel); + + const result = await controller.getConversionFunnel(mockRequestUser); + + expect(dashboardService.getConversionFunnel).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockConversionFunnel); + expect(result.length).toBe(5); + expect(result[0].stage).toBe('leads'); + }); + }); + + describe('getSalesPerformance', () => { + it('should return sales performance data', async () => { + dashboardService.getSalesPerformance.mockResolvedValue(mockSalesPerformance); + + const result = await controller.getSalesPerformance(mockRequestUser); + + expect(dashboardService.getSalesPerformance).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual(mockSalesPerformance); + expect(result.length).toBe(2); + expect(result[0].totalRevenue).toBe(125000); + }); + }); +}); diff --git a/src/modules/sales/__tests__/leads.controller.spec.ts b/src/modules/sales/__tests__/leads.controller.spec.ts new file mode 100644 index 0000000..2afb1cc --- /dev/null +++ b/src/modules/sales/__tests__/leads.controller.spec.ts @@ -0,0 +1,218 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LeadsController } from '../controllers/leads.controller'; +import { LeadsService } from '../services/leads.service'; +import { LeadStatus, LeadSource } from '../entities/lead.entity'; +import { CreateLeadDto, UpdateLeadDto, ConvertLeadDto, LeadListQueryDto } from '../dto'; + +describe('LeadsController', () => { + let controller: LeadsController; + let leadsService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockLeadId = '550e8400-e29b-41d4-a716-446655440003'; + const mockOpportunityId = '550e8400-e29b-41d4-a716-446655440004'; + + const mockRequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + role: 'admin', + }; + + const mockLead = { + id: mockLeadId, + tenantId: mockTenantId, + firstName: 'John', + lastName: 'Doe', + fullName: 'John Doe', + email: 'john.doe@example.com', + phone: '+1234567890', + company: 'Acme Corp', + jobTitle: 'CTO', + website: 'https://acme.com', + source: LeadSource.WEBSITE, + status: LeadStatus.NEW, + score: 75, + assignedTo: mockUserId, + notes: 'Interested in enterprise plan', + convertedAt: null, + convertedToOpportunityId: null, + addressLine1: '123 Main St', + addressLine2: null, + city: 'San Francisco', + state: 'CA', + postalCode: '94102', + country: 'USA', + customFields: {}, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + createdBy: mockUserId, + }; + + const mockPaginatedLeads = { + items: [mockLead], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + beforeEach(async () => { + const mockLeadsService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + convert: jest.fn(), + calculateScore: jest.fn(), + assignTo: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [LeadsController], + providers: [ + { + provide: LeadsService, + useValue: mockLeadsService, + }, + ], + }).compile(); + + controller = module.get(LeadsController); + leadsService = module.get(LeadsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated leads', async () => { + const query: LeadListQueryDto = { page: 1, limit: 20 }; + leadsService.findAll.mockResolvedValue(mockPaginatedLeads); + + const result = await controller.findAll(mockRequestUser, query); + + expect(leadsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + expect(result).toEqual(mockPaginatedLeads); + expect(result.items.length).toBe(1); + }); + + it('should filter by status', async () => { + const query: LeadListQueryDto = { status: LeadStatus.QUALIFIED }; + leadsService.findAll.mockResolvedValue({ ...mockPaginatedLeads, items: [] }); + + await controller.findAll(mockRequestUser, query); + + expect(leadsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + + it('should filter by source', async () => { + const query: LeadListQueryDto = { source: LeadSource.REFERRAL }; + leadsService.findAll.mockResolvedValue({ ...mockPaginatedLeads, items: [] }); + + await controller.findAll(mockRequestUser, query); + + expect(leadsService.findAll).toHaveBeenCalledWith(mockTenantId, query); + }); + }); + + describe('findOne', () => { + it('should return a lead by id', async () => { + leadsService.findOne.mockResolvedValue(mockLead); + + const result = await controller.findOne(mockRequestUser, mockLeadId); + + expect(leadsService.findOne).toHaveBeenCalledWith(mockTenantId, mockLeadId); + expect(result).toEqual(mockLead); + }); + }); + + describe('create', () => { + it('should create a new lead', async () => { + const createDto: CreateLeadDto = { + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@example.com', + company: 'Tech Inc', + source: LeadSource.WEBSITE, + }; + leadsService.create.mockResolvedValue(mockLead); + + const result = await controller.create(mockRequestUser, createDto); + + expect(leadsService.create).toHaveBeenCalledWith(mockTenantId, mockUserId, createDto); + expect(result).toEqual(mockLead); + }); + }); + + describe('update', () => { + it('should update a lead', async () => { + const updateDto: UpdateLeadDto = { + status: LeadStatus.CONTACTED, + score: 80, + }; + const updatedLead = { ...mockLead, status: LeadStatus.CONTACTED, score: 80 }; + leadsService.update.mockResolvedValue(updatedLead); + + const result = await controller.update(mockRequestUser, mockLeadId, updateDto); + + expect(leadsService.update).toHaveBeenCalledWith(mockTenantId, mockLeadId, updateDto); + expect(result.status).toBe(LeadStatus.CONTACTED); + expect(result.score).toBe(80); + }); + }); + + describe('remove', () => { + it('should delete a lead', async () => { + leadsService.remove.mockResolvedValue(undefined); + + const result = await controller.remove(mockRequestUser, mockLeadId); + + expect(leadsService.remove).toHaveBeenCalledWith(mockTenantId, mockLeadId); + expect(result).toEqual({ message: 'Lead deleted successfully' }); + }); + }); + + describe('convert', () => { + it('should convert a lead to an opportunity', async () => { + const convertDto: ConvertLeadDto = { + opportunityName: 'Acme Corp Deal', + amount: 50000, + expectedCloseDate: '2026-03-31', + }; + leadsService.convert.mockResolvedValue({ opportunityId: mockOpportunityId }); + + const result = await controller.convert(mockRequestUser, mockLeadId, convertDto); + + expect(leadsService.convert).toHaveBeenCalledWith(mockTenantId, mockLeadId, convertDto); + expect(result).toEqual({ opportunityId: mockOpportunityId }); + }); + }); + + describe('calculateScore', () => { + it('should calculate lead score', async () => { + leadsService.calculateScore.mockResolvedValue({ score: 85 }); + + const result = await controller.calculateScore(mockRequestUser, mockLeadId); + + expect(leadsService.calculateScore).toHaveBeenCalledWith(mockTenantId, mockLeadId); + expect(result).toEqual({ score: 85 }); + }); + }); + + describe('assign', () => { + it('should assign lead to a user', async () => { + const newUserId = 'new-user-id'; + const assignedLead = { ...mockLead, assignedTo: newUserId }; + leadsService.assignTo.mockResolvedValue(assignedLead); + + const result = await controller.assign(mockRequestUser, mockLeadId, newUserId); + + expect(leadsService.assignTo).toHaveBeenCalledWith(mockTenantId, mockLeadId, newUserId); + expect(result.assignedTo).toBe(newUserId); + }); + }); +});