diff --git a/src/modules/billing-usage/__tests__/invoices.service.spec.ts b/src/modules/billing-usage/__tests__/invoices.service.spec.ts new file mode 100644 index 0000000..ce8a459 --- /dev/null +++ b/src/modules/billing-usage/__tests__/invoices.service.spec.ts @@ -0,0 +1,360 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DataSource, Repository } from 'typeorm'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { InvoicesService } from '../services/invoices.service'; +import { Invoice, InvoiceItem, InvoiceStatus, PaymentStatus } from '../entities'; +import { CreateInvoiceDto, UpdateInvoiceDto } from '../dto'; + +describe('InvoicesService', () => { + let service: InvoicesService; + let invoiceRepository: Repository; + let invoiceItemRepository: Repository; + let dataSource: DataSource; + + const mockInvoice = { + id: 'uuid-1', + tenantId: 'tenant-1', + customerId: 'customer-1', + number: 'INV-2024-001', + status: InvoiceStatus.DRAFT, + paymentStatus: PaymentStatus.PENDING, + issueDate: new Date('2024-01-01'), + dueDate: new Date('2024-01-15'), + subtotal: 1000, + taxAmount: 160, + totalAmount: 1160, + currency: 'USD', + notes: null, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockInvoiceItem = { + id: 'item-1', + invoiceId: 'uuid-1', + productId: 'product-1', + description: 'Test Product', + quantity: 2, + unitPrice: 500, + discount: 0, + taxRate: 0.08, + total: 1080, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + InvoicesService, + { + provide: DataSource, + useValue: { + getRepository: jest.fn(), + query: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(InvoicesService); + dataSource = module.get(DataSource); + invoiceRepository = module.get>( + getRepositoryToken(Invoice), + ); + invoiceItemRepository = module.get>( + getRepositoryToken(InvoiceItem), + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a new invoice successfully', async () => { + const dto: CreateInvoiceDto = { + customerId: 'customer-1', + issueDate: new Date('2024-01-01'), + dueDate: new Date('2024-01-15'), + items: [ + { + productId: 'product-1', + description: 'Test Product', + quantity: 2, + unitPrice: 500, + discount: 0, + taxRate: 0.08, + }, + ], + notes: 'Test invoice', + }; + + jest.spyOn(invoiceRepository, 'create').mockReturnValue(mockInvoice as any); + jest.spyOn(invoiceRepository, 'save').mockResolvedValue(mockInvoice); + jest.spyOn(invoiceItemRepository, 'create').mockReturnValue(mockInvoiceItem as any); + jest.spyOn(invoiceItemRepository, 'save').mockResolvedValue(mockInvoiceItem); + + const result = await service.create(dto); + + expect(invoiceRepository.create).toHaveBeenCalled(); + expect(invoiceRepository.save).toHaveBeenCalled(); + expect(invoiceItemRepository.create).toHaveBeenCalled(); + expect(invoiceItemRepository.save).toHaveBeenCalled(); + expect(result).toEqual(mockInvoice); + }); + + it('should calculate totals correctly', async () => { + const dto: CreateInvoiceDto = { + customerId: 'customer-1', + issueDate: new Date('2024-01-01'), + dueDate: new Date('2024-01-15'), + items: [ + { + productId: 'product-1', + description: 'Test Product 1', + quantity: 2, + unitPrice: 500, + discount: 50, + taxRate: 0.08, + }, + { + productId: 'product-2', + description: 'Test Product 2', + quantity: 1, + unitPrice: 300, + discount: 0, + taxRate: 0.08, + }, + ], + }; + + const expectedInvoice = { + ...mockInvoice, + subtotal: 1000, + taxAmount: 120, + totalAmount: 1120, + }; + + jest.spyOn(invoiceRepository, 'create').mockReturnValue(expectedInvoice as any); + jest.spyOn(invoiceRepository, 'save').mockResolvedValue(expectedInvoice); + jest.spyOn(invoiceItemRepository, 'create').mockReturnValue(mockInvoiceItem as any); + jest.spyOn(invoiceItemRepository, 'save').mockResolvedValue(mockInvoiceItem); + + const result = await service.create(dto); + + expect(result.subtotal).toBe(1000); + expect(result.taxAmount).toBe(120); + expect(result.totalAmount).toBe(1120); + }); + }); + + describe('findById', () => { + it('should find invoice by id', async () => { + jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(mockInvoice as any); + + const result = await service.findById('uuid-1'); + + expect(invoiceRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'uuid-1' }, + relations: ['items'], + }); + expect(result).toEqual(mockInvoice); + }); + + it('should return null if invoice not found', async () => { + jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(null); + + const result = await service.findById('invalid-id'); + + expect(result).toBeNull(); + }); + }); + + describe('findByTenant', () => { + it('should find invoices by tenant', async () => { + const mockInvoices = [mockInvoice, { ...mockInvoice, id: 'uuid-2' }]; + jest.spyOn(invoiceRepository, 'find').mockResolvedValue(mockInvoices as any); + + const result = await service.findByTenant('tenant-1', { + page: 1, + limit: 10, + }); + + expect(invoiceRepository.find).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1' }, + relations: ['items'], + order: { createdAt: 'DESC' }, + skip: 0, + take: 10, + }); + expect(result).toEqual(mockInvoices); + }); + }); + + describe('update', () => { + it('should update invoice successfully', async () => { + const dto: UpdateInvoiceDto = { + status: InvoiceStatus.SENT, + notes: 'Updated notes', + }; + + const updatedInvoice = { ...mockInvoice, status: InvoiceStatus.SENT, notes: 'Updated notes' }; + + jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(mockInvoice as any); + jest.spyOn(invoiceRepository, 'save').mockResolvedValue(updatedInvoice as any); + + const result = await service.update('uuid-1', dto); + + expect(invoiceRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'uuid-1' }, + }); + expect(invoiceRepository.save).toHaveBeenCalled(); + expect(result.status).toBe(InvoiceStatus.SENT); + expect(result.notes).toBe('Updated notes'); + }); + + it('should throw error if invoice not found', async () => { + const dto: UpdateInvoiceDto = { + status: InvoiceStatus.SENT, + }; + + jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(null); + + await expect(service.update('invalid-id', dto)).rejects.toThrow('Invoice not found'); + }); + }); + + describe('updateStatus', () => { + it('should update invoice status', async () => { + jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(mockInvoice as any); + jest.spyOn(invoiceRepository, 'save').mockResolvedValue({ + ...mockInvoice, + status: InvoiceStatus.PAID, + } as any); + + const result = await service.updateStatus('uuid-1', InvoiceStatus.PAID); + + expect(result.status).toBe(InvoiceStatus.PAID); + }); + }); + + describe('updatePaymentStatus', () => { + it('should update payment status', async () => { + jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(mockInvoice as any); + jest.spyOn(invoiceRepository, 'save').mockResolvedValue({ + ...mockInvoice, + paymentStatus: PaymentStatus.PAID, + } as any); + + const result = await service.updatePaymentStatus('uuid-1', PaymentStatus.PAID); + + expect(result.paymentStatus).toBe(PaymentStatus.PAID); + }); + }); + + describe('delete', () => { + it('should delete invoice successfully', async () => { + jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(mockInvoice as any); + jest.spyOn(invoiceRepository, 'remove').mockResolvedValue(undefined); + + await service.delete('uuid-1'); + + expect(invoiceRepository.remove).toHaveBeenCalledWith(mockInvoice); + }); + + it('should throw error if invoice not found', async () => { + jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(null); + + await expect(service.delete('invalid-id')).rejects.toThrow('Invoice not found'); + }); + }); + + describe('addItem', () => { + it('should add item to invoice', async () => { + const itemDto = { + productId: 'product-2', + description: 'New Product', + quantity: 1, + unitPrice: 300, + discount: 0, + taxRate: 0.08, + }; + + jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(mockInvoice as any); + jest.spyOn(invoiceItemRepository, 'create').mockReturnValue(mockInvoiceItem as any); + jest.spyOn(invoiceItemRepository, 'save').mockResolvedValue(mockInvoiceItem); + jest.spyOn(invoiceRepository, 'save').mockResolvedValue({ + ...mockInvoice, + subtotal: 1500, + taxAmount: 120, + totalAmount: 1620, + } as any); + + const result = await service.addItem('uuid-1', itemDto); + + expect(invoiceItemRepository.create).toHaveBeenCalled(); + expect(invoiceItemRepository.save).toHaveBeenCalled(); + expect(result.totalAmount).toBe(1620); + }); + }); + + describe('removeItem', () => { + it('should remove item from invoice', async () => { + jest.spyOn(invoiceItemRepository, 'findOne').mockResolvedValue(mockInvoiceItem as any); + jest.spyOn(invoiceItemRepository, 'remove').mockResolvedValue(undefined); + jest.spyOn(invoiceRepository, 'save').mockResolvedValue({ + ...mockInvoice, + subtotal: 500, + taxAmount: 40, + totalAmount: 540, + } as any); + + const result = await service.removeItem('uuid-1', 'item-1'); + + expect(invoiceItemRepository.remove).toHaveBeenCalled(); + expect(result.totalAmount).toBe(540); + }); + }); + + describe('sendInvoice', () => { + it('should mark invoice as sent', async () => { + jest.spyOn(invoiceRepository, 'findOne').mockResolvedValue(mockInvoice as any); + jest.spyOn(invoiceRepository, 'save').mockResolvedValue({ + ...mockInvoice, + status: InvoiceStatus.SENT, + sentAt: new Date(), + } as any); + + const result = await service.sendInvoice('uuid-1'); + + expect(result.status).toBe(InvoiceStatus.SENT); + expect(result.sentAt).toBeDefined(); + }); + }); + + describe('calculateTotals', () => { + it('should calculate totals from items', () => { + const items = [ + { quantity: 2, unitPrice: 500, discount: 50, taxRate: 0.08 }, + { quantity: 1, unitPrice: 300, discount: 0, taxRate: 0.08 }, + ]; + + const totals = service.calculateTotals(items); + + expect(totals.subtotal).toBe(1000); + expect(totals.taxAmount).toBe(120); + expect(totals.totalAmount).toBe(1120); + }); + + it('should handle empty items array', () => { + const totals = service.calculateTotals([]); + + expect(totals.subtotal).toBe(0); + expect(totals.taxAmount).toBe(0); + expect(totals.totalAmount).toBe(0); + }); + }); +}); diff --git a/src/modules/billing-usage/__tests__/subscriptions.service.spec.ts b/src/modules/billing-usage/__tests__/subscriptions.service.spec.ts new file mode 100644 index 0000000..ef55229 --- /dev/null +++ b/src/modules/billing-usage/__tests__/subscriptions.service.spec.ts @@ -0,0 +1,307 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DataSource, Repository } from 'typeorm'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { SubscriptionsService } from '../services/subscriptions.service'; +import { TenantSubscription, SubscriptionPlan, BillingCycle, SubscriptionStatus } from '../entities'; +import { CreateTenantSubscriptionDto, UpdateTenantSubscriptionDto, CancelSubscriptionDto, ChangePlanDto } from '../dto'; + +describe('SubscriptionsService', () => { + let service: SubscriptionsService; + let subscriptionRepository: Repository; + let planRepository: Repository; + let dataSource: DataSource; + + const mockSubscription = { + id: 'uuid-1', + tenantId: 'tenant-1', + planId: 'plan-1', + status: SubscriptionStatus.ACTIVE, + billingCycle: BillingCycle.MONTHLY, + currentPeriodStart: new Date('2024-01-01'), + currentPeriodEnd: new Date('2024-02-01'), + trialEnd: null, + cancelledAt: null, + paymentMethodId: 'pm-1', + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockPlan = { + id: 'plan-1', + name: 'Basic Plan', + description: 'Basic subscription plan', + price: 9.99, + billingCycle: BillingCycle.MONTHLY, + features: ['feature1', 'feature2'], + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SubscriptionsService, + { + provide: DataSource, + useValue: { + getRepository: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(SubscriptionsService); + dataSource = module.get(DataSource); + subscriptionRepository = module.get>( + getRepositoryToken(TenantSubscription), + ); + planRepository = module.get>( + getRepositoryToken(SubscriptionPlan), + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a new subscription successfully', async () => { + const dto: CreateTenantSubscriptionDto = { + tenantId: 'tenant-1', + planId: 'plan-1', + billingCycle: BillingCycle.MONTHLY, + paymentMethodId: 'pm-1', + }; + + jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(null); + jest.spyOn(planRepository, 'findOne').mockResolvedValue(mockPlan as any); + jest.spyOn(subscriptionRepository, 'create').mockReturnValue(mockSubscription as any); + jest.spyOn(subscriptionRepository, 'save').mockResolvedValue(mockSubscription); + + const result = await service.create(dto); + + expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ + where: { tenantId: dto.tenantId }, + }); + expect(planRepository.findOne).toHaveBeenCalledWith({ where: { id: dto.planId } }); + expect(subscriptionRepository.create).toHaveBeenCalled(); + expect(subscriptionRepository.save).toHaveBeenCalled(); + expect(result).toEqual(mockSubscription); + }); + + it('should throw error if tenant already has subscription', async () => { + const dto: CreateTenantSubscriptionDto = { + tenantId: 'tenant-1', + planId: 'plan-1', + billingCycle: BillingCycle.MONTHLY, + paymentMethodId: 'pm-1', + }; + + jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any); + + await expect(service.create(dto)).rejects.toThrow('Tenant already has a subscription'); + }); + + it('should throw error if plan not found', async () => { + const dto: CreateTenantSubscriptionDto = { + tenantId: 'tenant-1', + planId: 'invalid-plan', + billingCycle: BillingCycle.MONTHLY, + paymentMethodId: 'pm-1', + }; + + jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(null); + jest.spyOn(planRepository, 'findOne').mockResolvedValue(null); + + await expect(service.create(dto)).rejects.toThrow('Plan not found'); + }); + }); + + describe('findByTenant', () => { + it('should find subscription by tenant id', async () => { + jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any); + + const result = await service.findByTenant('tenant-1'); + + expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1' }, + }); + expect(result).toEqual(mockSubscription); + }); + + it('should return null if no subscription found', async () => { + jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(null); + + const result = await service.findByTenant('invalid-tenant'); + + expect(result).toBeNull(); + }); + }); + + describe('update', () => { + it('should update subscription successfully', async () => { + const dto: UpdateTenantSubscriptionDto = { + paymentMethodId: 'pm-2', + }; + + const updatedSubscription = { ...mockSubscription, paymentMethodId: 'pm-2' }; + + jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any); + jest.spyOn(subscriptionRepository, 'save').mockResolvedValue(updatedSubscription as any); + + const result = await service.update('uuid-1', dto); + + expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); + expect(subscriptionRepository.save).toHaveBeenCalled(); + expect(result).toEqual(updatedSubscription); + }); + + it('should throw error if subscription not found', async () => { + const dto: UpdateTenantSubscriptionDto = { + paymentMethodId: 'pm-2', + }; + + jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(null); + + await expect(service.update('invalid-id', dto)).rejects.toThrow('Subscription not found'); + }); + }); + + describe('cancel', () => { + it('should cancel subscription successfully', async () => { + const dto: CancelSubscriptionDto = { + reason: 'Customer request', + effectiveImmediately: false, + }; + + jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any); + jest.spyOn(subscriptionRepository, 'save').mockResolvedValue({ + ...mockSubscription, + status: SubscriptionStatus.CANCELLED, + cancelledAt: new Date(), + } as any); + + const result = await service.cancel('uuid-1', dto); + + expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); + expect(subscriptionRepository.save).toHaveBeenCalled(); + expect(result.status).toBe(SubscriptionStatus.CANCELLED); + expect(result.cancelledAt).toBeDefined(); + }); + + it('should cancel subscription immediately if requested', async () => { + const dto: CancelSubscriptionDto = { + reason: 'Customer request', + effectiveImmediately: true, + }; + + jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any); + jest.spyOn(subscriptionRepository, 'save').mockResolvedValue({ + ...mockSubscription, + status: SubscriptionStatus.CANCELLED, + cancelledAt: new Date(), + currentPeriodEnd: new Date(), + } as any); + + const result = await service.cancel('uuid-1', dto); + + expect(result.status).toBe(SubscriptionStatus.CANCELLED); + expect(result.cancelledAt).toBeDefined(); + }); + }); + + describe('changePlan', () => { + it('should change subscription plan successfully', async () => { + const newPlan = { ...mockPlan, id: 'plan-2', price: 19.99 }; + const dto: ChangePlanDto = { + newPlanId: 'plan-2', + billingCycle: BillingCycle.YEARLY, + prorate: true, + }; + + jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any); + jest.spyOn(planRepository, 'findOne').mockResolvedValue(newPlan as any); + jest.spyOn(subscriptionRepository, 'save').mockResolvedValue({ + ...mockSubscription, + planId: 'plan-2', + billingCycle: BillingCycle.YEARLY, + } as any); + + const result = await service.changePlan('uuid-1', dto); + + expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); + expect(planRepository.findOne).toHaveBeenCalledWith({ where: { id: 'plan-2' } }); + expect(subscriptionRepository.save).toHaveBeenCalled(); + expect(result.planId).toBe('plan-2'); + expect(result.billingCycle).toBe(BillingCycle.YEARLY); + }); + + it('should throw error if new plan not found', async () => { + const dto: ChangePlanDto = { + newPlanId: 'invalid-plan', + billingCycle: BillingCycle.MONTHLY, + prorate: false, + }; + + jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any); + jest.spyOn(planRepository, 'findOne').mockResolvedValue(null); + + await expect(service.changePlan('uuid-1', dto)).rejects.toThrow('New plan not found'); + }); + }); + + describe('getUsage', () => { + it('should get subscription usage', async () => { + const mockUsage = { + currentUsage: 850, + limits: { + apiCalls: 1000, + storage: 5368709120, // 5GB in bytes + users: 10, + }, + periodStart: new Date('2024-01-01'), + periodEnd: new Date('2024-02-01'), + }; + + jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any); + jest.spyOn(dataSource, 'query').mockResolvedValue([{ current_usage: 850 }]); + + const result = await service.getUsage('uuid-1'); + + expect(result.currentUsage).toBe(850); + expect(result.limits).toBeDefined(); + }); + }); + + describe('reactivate', () => { + it('should reactivate cancelled subscription', async () => { + const cancelledSubscription = { + ...mockSubscription, + status: SubscriptionStatus.CANCELLED, + cancelledAt: new Date(), + }; + + jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(cancelledSubscription as any); + jest.spyOn(subscriptionRepository, 'save').mockResolvedValue({ + ...cancelledSubscription, + status: SubscriptionStatus.ACTIVE, + cancelledAt: null, + } as any); + + const result = await service.reactivate('uuid-1'); + + expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); + expect(subscriptionRepository.save).toHaveBeenCalled(); + expect(result.status).toBe(SubscriptionStatus.ACTIVE); + expect(result.cancelledAt).toBeNull(); + }); + + it('should throw error if subscription is not cancelled', async () => { + jest.spyOn(subscriptionRepository, 'findOne').mockResolvedValue(mockSubscription as any); + + await expect(service.reactivate('uuid-1')).rejects.toThrow('Cannot reactivate active subscription'); + }); + }); +}); diff --git a/src/modules/financial/__tests__/payments.service.spec.ts b/src/modules/financial/__tests__/payments.service.spec.ts new file mode 100644 index 0000000..df68f2d --- /dev/null +++ b/src/modules/financial/__tests__/payments.service.spec.ts @@ -0,0 +1,335 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Repository } from 'typeorm'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { PaymentsService } from '../payments.service'; +import { Payment, PaymentMethod, PaymentStatus } from '../entities'; +import { CreatePaymentDto, UpdatePaymentDto } from '../dto'; + +describe('PaymentsService', () => { + let service: PaymentsService; + let paymentRepository: Repository; + let paymentMethodRepository: Repository; + + const mockPayment = { + id: 'uuid-1', + tenantId: 'tenant-1', + invoiceId: 'invoice-1', + paymentMethodId: 'method-1', + amount: 1000, + currency: 'USD', + status: PaymentStatus.PENDING, + paymentDate: new Date('2024-01-15'), + reference: 'REF-001', + notes: 'Payment for invoice #001', + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockPaymentMethod = { + id: 'method-1', + tenantId: 'tenant-1', + name: 'Bank Transfer', + type: 'BANK_TRANSFER', + isActive: true, + config: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PaymentsService, + { + provide: getRepositoryToken(Payment), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + { + provide: getRepositoryToken(PaymentMethod), + useValue: { + findOne: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(PaymentsService); + paymentRepository = module.get>(getRepositoryToken(Payment)); + paymentMethodRepository = module.get>(getRepositoryToken(PaymentMethod)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a new payment successfully', async () => { + const dto: CreatePaymentDto = { + invoiceId: 'invoice-1', + paymentMethodId: 'method-1', + amount: 1000, + currency: 'USD', + paymentDate: new Date('2024-01-15'), + reference: 'REF-001', + notes: 'Payment for invoice #001', + }; + + jest.spyOn(paymentMethodRepository, 'findOne').mockResolvedValue(mockPaymentMethod as any); + jest.spyOn(paymentRepository, 'create').mockReturnValue(mockPayment as any); + jest.spyOn(paymentRepository, 'save').mockResolvedValue(mockPayment); + + const result = await service.create(dto); + + expect(paymentMethodRepository.findOne).toHaveBeenCalledWith({ + where: { id: dto.paymentMethodId }, + }); + expect(paymentRepository.create).toHaveBeenCalled(); + expect(paymentRepository.save).toHaveBeenCalled(); + expect(result).toEqual(mockPayment); + }); + + it('should throw error if payment method not found', async () => { + const dto: CreatePaymentDto = { + invoiceId: 'invoice-1', + paymentMethodId: 'invalid-method', + amount: 1000, + currency: 'USD', + }; + + jest.spyOn(paymentMethodRepository, 'findOne').mockResolvedValue(null); + + await expect(service.create(dto)).rejects.toThrow('Payment method not found'); + }); + + it('should throw error if payment method is inactive', async () => { + const inactiveMethod = { ...mockPaymentMethod, isActive: false }; + const dto: CreatePaymentDto = { + invoiceId: 'invoice-1', + paymentMethodId: 'method-1', + amount: 1000, + currency: 'USD', + }; + + jest.spyOn(paymentMethodRepository, 'findOne').mockResolvedValue(inactiveMethod as any); + + await expect(service.create(dto)).rejects.toThrow('Payment method is not active'); + }); + }); + + describe('findById', () => { + it('should find payment by id', async () => { + jest.spyOn(paymentRepository, 'findOne').mockResolvedValue(mockPayment as any); + + const result = await service.findById('uuid-1'); + + expect(paymentRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'uuid-1' }, + relations: ['paymentMethod', 'invoice'], + }); + expect(result).toEqual(mockPayment); + }); + + it('should return null if payment not found', async () => { + jest.spyOn(paymentRepository, 'findOne').mockResolvedValue(null); + + const result = await service.findById('invalid-id'); + + expect(result).toBeNull(); + }); + }); + + describe('findByInvoice', () => { + it('should find payments by invoice', async () => { + const mockPayments = [mockPayment, { ...mockPayment, id: 'uuid-2' }]; + jest.spyOn(paymentRepository, 'find').mockResolvedValue(mockPayments as any); + + const result = await service.findByInvoice('invoice-1'); + + expect(paymentRepository.find).toHaveBeenCalledWith({ + where: { invoiceId: 'invoice-1' }, + relations: ['paymentMethod'], + order: { createdAt: 'DESC' }, + }); + expect(result).toEqual(mockPayments); + }); + }); + + describe('update', () => { + it('should update payment successfully', async () => { + const dto: UpdatePaymentDto = { + status: PaymentStatus.COMPLETED, + notes: 'Payment completed', + }; + + const updatedPayment = { ...mockPayment, status: PaymentStatus.COMPLETED }; + + jest.spyOn(paymentRepository, 'findOne').mockResolvedValue(mockPayment as any); + jest.spyOn(paymentRepository, 'save').mockResolvedValue(updatedPayment as any); + + const result = await service.update('uuid-1', dto); + + expect(paymentRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); + expect(paymentRepository.save).toHaveBeenCalled(); + expect(result.status).toBe(PaymentStatus.COMPLETED); + }); + + it('should throw error if payment not found', async () => { + const dto: UpdatePaymentDto = { status: PaymentStatus.COMPLETED }; + + jest.spyOn(paymentRepository, 'findOne').mockResolvedValue(null); + + await expect(service.update('invalid-id', dto)).rejects.toThrow('Payment not found'); + }); + }); + + describe('updateStatus', () => { + it('should update payment status', async () => { + jest.spyOn(paymentRepository, 'findOne').mockResolvedValue(mockPayment as any); + jest.spyOn(paymentRepository, 'save').mockResolvedValue({ + ...mockPayment, + status: PaymentStatus.COMPLETED, + } as any); + + const result = await service.updateStatus('uuid-1', PaymentStatus.COMPLETED); + + expect(result.status).toBe(PaymentStatus.COMPLETED); + }); + }); + + describe('delete', () => { + it('should delete payment successfully', async () => { + jest.spyOn(paymentRepository, 'findOne').mockResolvedValue(mockPayment as any); + jest.spyOn(paymentRepository, 'remove').mockResolvedValue(undefined); + + await service.delete('uuid-1'); + + expect(paymentRepository.remove).toHaveBeenCalledWith(mockPayment); + }); + + it('should throw error if payment not found', async () => { + jest.spyOn(paymentRepository, 'findOne').mockResolvedValue(null); + + await expect(service.delete('invalid-id')).rejects.toThrow('Payment not found'); + }); + + it('should throw error if payment is completed', async () => { + const completedPayment = { ...mockPayment, status: PaymentStatus.COMPLETED }; + jest.spyOn(paymentRepository, 'findOne').mockResolvedValue(completedPayment as any); + + await expect(service.delete('uuid-1')).rejects.toThrow('Cannot delete completed payment'); + }); + }); + + describe('getTotalPaid', () => { + it('should get total paid for invoice', async () => { + jest.spyOn(paymentRepository, 'createQueryBuilder').mockReturnValue({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ total: 1500 }), + } as any); + + const result = await service.getTotalPaid('invoice-1'); + + expect(result).toBe(1500); + }); + + it('should return 0 if no payments found', async () => { + jest.spyOn(paymentRepository, 'createQueryBuilder').mockReturnValue({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue(null), + } as any); + + const result = await service.getTotalPaid('invoice-1'); + + expect(result).toBe(0); + }); + }); + + describe('processRefund', () => { + it('should process refund successfully', async () => { + const refundAmount = 500; + jest.spyOn(paymentRepository, 'findOne').mockResolvedValue(mockPayment as any); + jest.spyOn(paymentRepository, 'save').mockResolvedValue({ + ...mockPayment, + status: PaymentStatus.REFUNDED, + refundedAmount: refundAmount, + } as any); + + const result = await service.processRefund('uuid-1', refundAmount, 'Customer request'); + + expect(result.status).toBe(PaymentStatus.REFUNDED); + expect(result.refundedAmount).toBe(refundAmount); + }); + + it('should throw error if refund amount exceeds payment amount', async () => { + jest.spyOn(paymentRepository, 'findOne').mockResolvedValue(mockPayment as any); + + await expect(service.processRefund('uuid-1', 1500, 'Over refund')).rejects.toThrow('Refund amount cannot exceed payment amount'); + }); + + it('should throw error if payment is not completed', async () => { + jest.spyOn(paymentRepository, 'findOne').mockResolvedValue(mockPayment as any); + + await expect(service.processRefund('uuid-1', 500, 'Refund pending payment')).rejects.toThrow('Can only refund completed payments'); + }); + }); + + describe('getPaymentsByDateRange', () => { + it('should get payments by date range', async () => { + const startDate = new Date('2024-01-01'); + const endDate = new Date('2024-01-31'); + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockPayment]), + }; + + jest.spyOn(paymentRepository, 'createQueryBuilder').mockReturnValue(mockQueryBuilder as any); + + const result = await service.getPaymentsByDateRange('tenant-1', startDate, endDate); + + expect(paymentRepository.createQueryBuilder).toHaveBeenCalledWith('payment'); + expect(mockQueryBuilder.where).toHaveBeenCalledWith('payment.tenantId = :tenantId', { tenantId: 'tenant-1' }); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('payment.paymentDate >= :startDate', { startDate }); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('payment.paymentDate <= :endDate', { endDate }); + expect(result).toEqual([mockPayment]); + }); + }); + + describe('getPaymentSummary', () => { + it('should get payment summary for tenant', async () => { + const mockSummary = { + totalPayments: 10, + totalAmount: 10000, + completedPayments: 8, + completedAmount: 8500, + pendingPayments: 2, + pendingAmount: 1500, + }; + + jest.spyOn(paymentRepository, 'createQueryBuilder').mockReturnValue({ + where: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ total: 10, amount: 10000 }), + } as any); + + const result = await service.getPaymentSummary('tenant-1', new Date('2024-01-01'), new Date('2024-01-31')); + + expect(result.totalPayments).toBe(10); + expect(result.totalAmount).toBe(10000); + }); + }); +}); diff --git a/src/modules/inventory/__tests__/warehouses-new.service.spec.ts b/src/modules/inventory/__tests__/warehouses-new.service.spec.ts new file mode 100644 index 0000000..cdf1ab1 --- /dev/null +++ b/src/modules/inventory/__tests__/warehouses-new.service.spec.ts @@ -0,0 +1,319 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Repository } from 'typeorm'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { WarehousesService } from '../warehouses.service'; +import { Warehouse, WarehouseStatus } from '../entities'; +import { CreateWarehouseDto, UpdateWarehouseDto } from '../dto'; + +describe('WarehousesService', () => { + let service: WarehousesService; + let warehouseRepository: Repository; + + const mockWarehouse = { + id: 'uuid-1', + tenantId: 'tenant-1', + code: 'WH-001', + name: 'Main Warehouse', + description: 'Primary storage facility', + address: { + street: '123 Storage St', + city: 'Storage City', + state: 'SC', + zipCode: '12345', + country: 'US', + }, + contact: { + name: 'John Manager', + email: 'john@company.com', + phone: '+1234567890', + }, + status: WarehouseStatus.ACTIVE, + capacity: 10000, + currentOccupancy: 3500, + operatingHours: { + monday: { open: '08:00', close: '18:00' }, + tuesday: { open: '08:00', close: '18:00' }, + wednesday: { open: '08:00', close: '18:00' }, + thursday: { open: '08:00', close: '18:00' }, + friday: { open: '08:00', close: '18:00' }, + saturday: { open: '09:00', close: '14:00' }, + sunday: { open: null, close: null }, + }, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WarehousesService, + { + provide: getRepositoryToken(Warehouse), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(WarehousesService); + warehouseRepository = module.get>(getRepositoryToken(Warehouse)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a new warehouse successfully', async () => { + const dto: CreateWarehouseDto = { + code: 'WH-001', + name: 'Main Warehouse', + description: 'Primary storage facility', + address: { + street: '123 Storage St', + city: 'Storage City', + state: 'SC', + zipCode: '12345', + country: 'US', + }, + contact: { + name: 'John Manager', + email: 'john@company.com', + phone: '+1234567890', + }, + capacity: 10000, + operatingHours: { + monday: { open: '08:00', close: '18:00' }, + tuesday: { open: '08:00', close: '18:00' }, + }, + }; + + jest.spyOn(warehouseRepository, 'findOne').mockResolvedValue(null); + jest.spyOn(warehouseRepository, 'create').mockReturnValue(mockWarehouse as any); + jest.spyOn(warehouseRepository, 'save').mockResolvedValue(mockWarehouse); + + const result = await service.create(dto); + + expect(warehouseRepository.findOne).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', code: dto.code }, + }); + expect(warehouseRepository.create).toHaveBeenCalled(); + expect(warehouseRepository.save).toHaveBeenCalled(); + expect(result).toEqual(mockWarehouse); + }); + + it('should throw error if warehouse code already exists', async () => { + const dto: CreateWarehouseDto = { + code: 'WH-001', + name: 'Main Warehouse', + }; + + jest.spyOn(warehouseRepository, 'findOne').mockResolvedValue(mockWarehouse as any); + + await expect(service.create(dto)).rejects.toThrow('Warehouse code already exists'); + }); + }); + + describe('findById', () => { + it('should find warehouse by id', async () => { + jest.spyOn(warehouseRepository, 'findOne').mockResolvedValue(mockWarehouse as any); + + const result = await service.findById('uuid-1'); + + expect(warehouseRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'uuid-1' }, + }); + expect(result).toEqual(mockWarehouse); + }); + + it('should return null if warehouse not found', async () => { + jest.spyOn(warehouseRepository, 'findOne').mockResolvedValue(null); + + const result = await service.findById('invalid-id'); + + expect(result).toBeNull(); + }); + }); + + describe('findByTenant', () => { + it('should find warehouses by tenant', async () => { + const mockWarehouses = [mockWarehouse, { ...mockWarehouse, id: 'uuid-2' }]; + jest.spyOn(warehouseRepository, 'find').mockResolvedValue(mockWarehouses as any); + + const result = await service.findByTenant('tenant-1'); + + expect(warehouseRepository.find).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1' }, + order: { code: 'ASC' }, + }); + expect(result).toEqual(mockWarehouses); + }); + + it('should filter by status', async () => { + jest.spyOn(warehouseRepository, 'find').mockResolvedValue([mockWarehouse] as any); + + const result = await service.findByTenant('tenant-1', { status: WarehouseStatus.ACTIVE }); + + expect(warehouseRepository.find).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', status: WarehouseStatus.ACTIVE }, + order: { code: 'ASC' }, + }); + expect(result).toEqual([mockWarehouse]); + }); + }); + + describe('update', () => { + it('should update warehouse successfully', async () => { + const dto: UpdateWarehouseDto = { + name: 'Updated Warehouse', + capacity: 12000, + }; + + jest.spyOn(warehouseRepository, 'findOne').mockResolvedValue(mockWarehouse as any); + jest.spyOn(warehouseRepository, 'save').mockResolvedValue({ + ...mockWarehouse, + name: 'Updated Warehouse', + capacity: 12000, + } as any); + + const result = await service.update('uuid-1', dto); + + expect(warehouseRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); + expect(warehouseRepository.save).toHaveBeenCalled(); + expect(result.name).toBe('Updated Warehouse'); + expect(result.capacity).toBe(12000); + }); + + it('should throw error if warehouse not found', async () => { + const dto: UpdateWarehouseDto = { name: 'Updated' }; + + jest.spyOn(warehouseRepository, 'findOne').mockResolvedValue(null); + + await expect(service.update('invalid-id', dto)).rejects.toThrow('Warehouse not found'); + }); + }); + + describe('updateOccupancy', () => { + it('should update warehouse occupancy', async () => { + jest.spyOn(warehouseRepository, 'findOne').mockResolvedValue(mockWarehouse as any); + jest.spyOn(warehouseRepository, 'save').mockResolvedValue({ + ...mockWarehouse, + currentOccupancy: 4000, + } as any); + + const result = await service.updateOccupancy('uuid-1', 4000); + + expect(warehouseRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); + expect(warehouseRepository.save).toHaveBeenCalled(); + expect(result.currentOccupancy).toBe(4000); + }); + + it('should throw error if occupancy exceeds capacity', async () => { + jest.spyOn(warehouseRepository, 'findOne').mockResolvedValue(mockWarehouse as any); + + await expect(service.updateOccupancy('uuid-1', 15000)).rejects.toThrow('Occupancy cannot exceed warehouse capacity'); + }); + }); + + describe('getAvailableCapacity', () => { + it('should calculate available capacity', async () => { + jest.spyOn(warehouseRepository, 'findOne').mockResolvedValue(mockWarehouse as any); + + const result = await service.getAvailableCapacity('uuid-1'); + + expect(result).toBe(6500); // 10000 - 3500 + }); + }); + + describe('delete', () => { + it('should delete warehouse successfully', async () => { + jest.spyOn(warehouseRepository, 'findOne').mockResolvedValue(mockWarehouse as any); + jest.spyOn(warehouseRepository, 'remove').mockResolvedValue(undefined); + + await service.delete('uuid-1'); + + expect(warehouseRepository.remove).toHaveBeenCalledWith(mockWarehouse); + }); + + it('should throw error if warehouse not found', async () => { + jest.spyOn(warehouseRepository, 'findOne').mockResolvedValue(null); + + await expect(service.delete('invalid-id')).rejects.toThrow('Warehouse not found'); + }); + + it('should throw error if warehouse has stock', async () => { + const warehouseWithStock = { ...mockWarehouse, currentOccupancy: 1000 }; + jest.spyOn(warehouseRepository, 'findOne').mockResolvedValue(warehouseWithStock as any); + + await expect(service.delete('uuid-1')).rejects.toThrow('Cannot delete warehouse with existing stock'); + }); + }); + + describe('getUtilizationRate', () => { + it('should calculate utilization rate', async () => { + jest.spyOn(warehouseRepository, 'findOne').mockResolvedValue(mockWarehouse as any); + + const result = await service.getUtilizationRate('uuid-1'); + + expect(result).toBe(35); // (3500 / 10000) * 100 + }); + }); + + describe('getWarehousesByCity', () => { + it('should get warehouses by city', async () => { + jest.spyOn(warehouseRepository, 'find').mockResolvedValue([mockWarehouse] as any); + + const result = await service.getWarehousesByCity('tenant-1', 'Storage City'); + + expect(warehouseRepository.find).toHaveBeenCalledWith({ + where: { + tenantId: 'tenant-1', + 'address.city': 'Storage City', + }, + }); + expect(result).toEqual([mockWarehouse]); + }); + }); + + describe('updateStatus', () => { + it('should update warehouse status', async () => { + jest.spyOn(warehouseRepository, 'findOne').mockResolvedValue(mockWarehouse as any); + jest.spyOn(warehouseRepository, 'save').mockResolvedValue({ + ...mockWarehouse, + status: WarehouseStatus.INACTIVE, + } as any); + + const result = await service.updateStatus('uuid-1', WarehouseStatus.INACTIVE); + + expect(result.status).toBe(WarehouseStatus.INACTIVE); + }); + }); + + describe('getWarehouseStats', () => { + it('should get warehouse statistics', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ total: 5, active: 4, inactive: 1, totalCapacity: 50000, totalOccupancy: 17500 }), + }; + + jest.spyOn(warehouseRepository, 'createQueryBuilder').mockReturnValue(mockQueryBuilder as any); + + const result = await service.getWarehouseStats('tenant-1'); + + expect(result.totalWarehouses).toBe(5); + expect(result.activeWarehouses).toBe(4); + expect(result.inactiveWarehouses).toBe(1); + expect(result.totalCapacity).toBe(50000); + expect(result.totalOccupancy).toBe(17500); + expect(result.averageUtilization).toBe(35); // (17500 / 50000) * 100 + }); + }); +}); diff --git a/src/modules/partners/__tests__/partners.service.spec.ts b/src/modules/partners/__tests__/partners.service.spec.ts new file mode 100644 index 0000000..8c41d76 --- /dev/null +++ b/src/modules/partners/__tests__/partners.service.spec.ts @@ -0,0 +1,393 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Repository } from 'typeorm'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { PartnersService } from '../partners.service'; +import { Partner, PartnerStatus, PartnerType } from '../entities'; +import { CreatePartnerDto, UpdatePartnerDto } from '../dto'; + +describe('PartnersService', () => { + let service: PartnersService; + let partnerRepository: Repository; + + const mockPartner = { + id: 'uuid-1', + tenantId: 'tenant-1', + code: 'PART-001', + name: 'Test Partner', + type: PartnerType.SUPPLIER, + status: PartnerStatus.ACTIVE, + taxId: 'TAX-001', + email: 'partner@test.com', + phone: '+1234567890', + website: 'https://partner.com', + address: { + street: '123 Partner St', + city: 'Partner City', + state: 'PC', + zipCode: '12345', + country: 'US', + }, + contact: { + name: 'John Contact', + email: 'john@partner.com', + phone: '+1234567890', + position: 'Sales Manager', + }, + paymentTerms: { + days: 30, + method: 'TRANSFER', + currency: 'USD', + }, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PartnersService, + { + provide: getRepositoryToken(Partner), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(PartnersService); + partnerRepository = module.get>(getRepositoryToken(Partner)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a new partner successfully', async () => { + const dto: CreatePartnerDto = { + code: 'PART-001', + name: 'Test Partner', + type: PartnerType.SUPPLIER, + taxId: 'TAX-001', + email: 'partner@test.com', + phone: '+1234567890', + website: 'https://partner.com', + address: { + street: '123 Partner St', + city: 'Partner City', + state: 'PC', + zipCode: '12345', + country: 'US', + }, + contact: { + name: 'John Contact', + email: 'john@partner.com', + phone: '+1234567890', + position: 'Sales Manager', + }, + paymentTerms: { + days: 30, + method: 'TRANSFER', + currency: 'USD', + }, + }; + + jest.spyOn(partnerRepository, 'findOne').mockResolvedValue(null); + jest.spyOn(partnerRepository, 'create').mockReturnValue(mockPartner as any); + jest.spyOn(partnerRepository, 'save').mockResolvedValue(mockPartner); + + const result = await service.create(dto); + + expect(partnerRepository.findOne).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', code: dto.code }, + }); + expect(partnerRepository.create).toHaveBeenCalled(); + expect(partnerRepository.save).toHaveBeenCalled(); + expect(result).toEqual(mockPartner); + }); + + it('should throw error if partner code already exists', async () => { + const dto: CreatePartnerDto = { + code: 'PART-001', + name: 'Test Partner', + type: PartnerType.SUPPLIER, + }; + + jest.spyOn(partnerRepository, 'findOne').mockResolvedValue(mockPartner as any); + + await expect(service.create(dto)).rejects.toThrow('Partner code already exists'); + }); + }); + + describe('findById', () => { + it('should find partner by id', async () => { + jest.spyOn(partnerRepository, 'findOne').mockResolvedValue(mockPartner as any); + + const result = await service.findById('uuid-1'); + + expect(partnerRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'uuid-1' }, + }); + expect(result).toEqual(mockPartner); + }); + + it('should return null if partner not found', async () => { + jest.spyOn(partnerRepository, 'findOne').mockResolvedValue(null); + + const result = await service.findById('invalid-id'); + + expect(result).toBeNull(); + }); + }); + + describe('findByTenant', () => { + it('should find partners by tenant', async () => { + const mockPartners = [mockPartner, { ...mockPartner, id: 'uuid-2' }]; + 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(), + getMany: jest.fn().mockResolvedValue(mockPartners), + }; + + jest.spyOn(partnerRepository, 'createQueryBuilder').mockReturnValue(mockQueryBuilder as any); + + const result = await service.findByTenant('tenant-1', { + page: 1, + limit: 10, + }); + + expect(partnerRepository.createQueryBuilder).toHaveBeenCalledWith('partner'); + expect(mockQueryBuilder.where).toHaveBeenCalledWith('partner.tenantId = :tenantId', { tenantId: 'tenant-1' }); + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0); + expect(mockQueryBuilder.take).toHaveBeenCalledWith(10); + expect(result).toEqual(mockPartners); + }); + + it('should filter by type', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockPartner]), + }; + + jest.spyOn(partnerRepository, 'createQueryBuilder').mockReturnValue(mockQueryBuilder as any); + + await service.findByTenant('tenant-1', { type: PartnerType.SUPPLIER }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('partner.type = :type', { type: PartnerType.SUPPLIER }); + }); + + it('should filter by status', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockPartner]), + }; + + jest.spyOn(partnerRepository, 'createQueryBuilder').mockReturnValue(mockQueryBuilder as any); + + await service.findByTenant('tenant-1', { status: PartnerStatus.ACTIVE }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('partner.status = :status', { status: PartnerStatus.ACTIVE }); + }); + + it('should search by name or code', async () => { + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockPartner]), + }; + + jest.spyOn(partnerRepository, 'createQueryBuilder').mockReturnValue(mockQueryBuilder as any); + + await service.findByTenant('tenant-1', { search: 'Test' }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + '(partner.code ILIKE :search OR partner.name ILIKE :search OR partner.email ILIKE :search)', + { search: '%Test%' } + ); + }); + }); + + describe('update', () => { + it('should update partner successfully', async () => { + const dto: UpdatePartnerDto = { + name: 'Updated Partner', + status: PartnerStatus.INACTIVE, + }; + + jest.spyOn(partnerRepository, 'findOne').mockResolvedValue(mockPartner as any); + jest.spyOn(partnerRepository, 'save').mockResolvedValue({ + ...mockPartner, + name: 'Updated Partner', + status: PartnerStatus.INACTIVE, + } as any); + + const result = await service.update('uuid-1', dto); + + expect(partnerRepository.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); + expect(partnerRepository.save).toHaveBeenCalled(); + expect(result.name).toBe('Updated Partner'); + expect(result.status).toBe(PartnerStatus.INACTIVE); + }); + + it('should throw error if partner not found', async () => { + const dto: UpdatePartnerDto = { name: 'Updated' }; + + jest.spyOn(partnerRepository, 'findOne').mockResolvedValue(null); + + await expect(service.update('invalid-id', dto)).rejects.toThrow('Partner not found'); + }); + }); + + describe('delete', () => { + it('should delete partner successfully', async () => { + jest.spyOn(partnerRepository, 'findOne').mockResolvedValue(mockPartner as any); + jest.spyOn(partnerRepository, 'remove').mockResolvedValue(undefined); + + await service.delete('uuid-1'); + + expect(partnerRepository.remove).toHaveBeenCalledWith(mockPartner); + }); + + it('should throw error if partner not found', async () => { + jest.spyOn(partnerRepository, 'findOne').mockResolvedValue(null); + + await expect(service.delete('invalid-id')).rejects.toThrow('Partner not found'); + }); + }); + + describe('updateStatus', () => { + it('should update partner status', async () => { + jest.spyOn(partnerRepository, 'findOne').mockResolvedValue(mockPartner as any); + jest.spyOn(partnerRepository, 'save').mockResolvedValue({ + ...mockPartner, + status: PartnerStatus.INACTIVE, + } as any); + + const result = await service.updateStatus('uuid-1', PartnerStatus.INACTIVE); + + expect(result.status).toBe(PartnerStatus.INACTIVE); + }); + }); + + describe('findByType', () => { + it('should find partners by type', async () => { + jest.spyOn(partnerRepository, 'find').mockResolvedValue([mockPartner] as any); + + const result = await service.findByType('tenant-1', PartnerType.SUPPLIER); + + expect(partnerRepository.find).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', type: PartnerType.SUPPLIER }, + order: { name: 'ASC' }, + }); + expect(result).toEqual([mockPartner]); + }); + }); + + describe('getSuppliers', () => { + it('should get active suppliers', async () => { + jest.spyOn(partnerRepository, 'find').mockResolvedValue([mockPartner] as any); + + const result = await service.getSuppliers('tenant-1'); + + expect(partnerRepository.find).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', type: PartnerType.SUPPLIER, status: PartnerStatus.ACTIVE }, + order: { name: 'ASC' }, + }); + expect(result).toEqual([mockPartner]); + }); + }); + + describe('getCustomers', () => { + it('should get active customers', async () => { + const customerPartner = { ...mockPartner, type: PartnerType.CUSTOMER }; + jest.spyOn(partnerRepository, 'find').mockResolvedValue([customerPartner] as any); + + const result = await service.getCustomers('tenant-1'); + + expect(partnerRepository.find).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', type: PartnerType.CUSTOMER, status: PartnerStatus.ACTIVE }, + order: { name: 'ASC' }, + }); + expect(result).toEqual([customerPartner]); + }); + }); + + describe('getPartnerStats', () => { + it('should get partner statistics', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ total: 100, active: 80, inactive: 20 }), + }; + + jest.spyOn(partnerRepository, 'createQueryBuilder').mockReturnValue(mockQueryBuilder as any); + + const result = await service.getPartnerStats('tenant-1'); + + expect(result.totalPartners).toBe(100); + expect(result.activePartners).toBe(80); + expect(result.inactivePartners).toBe(20); + }); + }); + + describe('searchPartners', () => { + it('should search partners by query', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockPartner]), + }; + + jest.spyOn(partnerRepository, 'createQueryBuilder').mockReturnValue(mockQueryBuilder as any); + + const result = await service.searchPartners('tenant-1', 'Test', 10); + + expect(partnerRepository.createQueryBuilder).toHaveBeenCalledWith('partner'); + expect(mockQueryBuilder.where).toHaveBeenCalledWith('partner.tenantId = :tenantId', { tenantId: 'tenant-1' }); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + '(partner.name ILIKE :query OR partner.code ILIKE :query OR partner.email ILIKE :query)', + { query: '%Test%' } + ); + expect(mockQueryBuilder.limit).toHaveBeenCalledWith(10); + expect(result).toEqual([mockPartner]); + }); + }); + + describe('bulkUpdate', () => { + it('should update multiple partners', async () => { + const updates = [ + { id: 'uuid-1', status: PartnerStatus.INACTIVE }, + { id: 'uuid-2', status: PartnerStatus.INACTIVE }, + ]; + + jest.spyOn(partnerRepository, 'findOne').mockResolvedValue(mockPartner as any); + jest.spyOn(partnerRepository, 'save').mockResolvedValue(mockPartner as any); + + const result = await service.bulkUpdate(updates); + + expect(result).toHaveLength(2); + expect(partnerRepository.save).toHaveBeenCalledTimes(2); + }); + }); +});