import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { PricingCatalogService } from './pricing-catalog.service'; import { PricingRate } from './entities/pricing-rate.entity'; import { AddonRate } from './entities/addon-rate.entity'; import { OptionalServiceProfile } from './entities/optional-service-profile.entity'; import { OptionalServiceRate } from './entities/optional-service-rate.entity'; import { AppRuntime, BillingCycle, DatabaseType, OptionalService, PricingResourceType, ProductType, } from '../common/enums'; import { OPTIONAL_SERVICE_DEPLOY_SPECS } from './pricing-catalog.constants'; import { CalculateCostDto } from './dto/billing.dto'; describe('PricingCatalogService', () => { let service: PricingCatalogService; const rateRepo = { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null), save: jest.fn().mockImplementation((x) => Promise.resolve(x)), create: jest.fn().mockImplementation((x) => x), }; const addonRepo = { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null), save: jest.fn().mockImplementation((x) => Promise.resolve(x)), create: jest.fn().mockImplementation((x) => x), }; const optionalProfileRepo = { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null), save: jest.fn().mockImplementation((x) => Promise.resolve(x)), create: jest.fn().mockImplementation((x) => x), }; const optionalRateRepo = { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null), save: jest.fn().mockImplementation((x) => Promise.resolve(x)), create: jest.fn().mockImplementation((x) => x), }; beforeEach(async () => { jest.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ providers: [ PricingCatalogService, { provide: getRepositoryToken(PricingRate), useValue: rateRepo }, { provide: getRepositoryToken(AddonRate), useValue: addonRepo }, { provide: getRepositoryToken(OptionalServiceProfile), useValue: optionalProfileRepo }, { provide: getRepositoryToken(OptionalServiceRate), useValue: optionalRateRepo }, ], }).compile(); service = module.get(PricingCatalogService); }); const baseDto = (): CalculateCostDto => ({ runtime: 'nodejs', databaseType: DatabaseType.NONE, cpuLimit: '500m', memoryLimit: '512Mi', replicas: 1, }); const emptyOptional = () => ({ profiles: [], rates: [], customDomain: null }); it('parses CPU millicores to cores', () => { expect(service.parseCpuToCores('500m')).toBe(0.5); expect(service.parseCpuToCores('2')).toBe(2); }); it('parses memory Mi to decimal GB for proportional billing (500Mi = 0.5 GB)', () => { expect(service.parseMemoryToGb('500Mi')).toBe(0.5); expect(service.parseMemoryToGb('1000Mi')).toBe(1); expect(service.parseMemoryToGb('1Gi')).toBe(1); }); it('bills memory proportional to decimal GB (500Mi at half the per-GB monthly rate)', () => { const rates = [ { runtime: AppRuntime.NODEJS, resourceType: PricingResourceType.MEMORY_PER_GB, hourlyPrice: 0, monthlyPrice: 10_000, yearlyPrice: 0, isActive: true, }, ] as PricingRate[]; const dto = { ...baseDto(), memoryLimit: '500Mi' }; const result = service.computeTotalsWithRates(dto, rates, emptyOptional()); expect(result.monthly).toBe(5000); }); it('computes CPU line with cycle-native prices (no conversion)', () => { const rates = [ { runtime: AppRuntime.NODEJS, resourceType: PricingResourceType.CPU_PER_CORE, hourlyPrice: 100, monthlyPrice: 5000, yearlyPrice: 50000, isActive: true, }, ] as PricingRate[]; const result = service.computeTotalsWithRates(baseDto(), rates, emptyOptional()); expect(result.hourly).toBe(50); expect(result.monthly).toBe(2500); expect(result.yearly).toBe(25000); expect(result.breakdown[0].label).toContain('CPU'); }); it('includes redis optional service only when enabled', () => { const profile = { service: OptionalService.REDIS, cpuLimit: OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit, memoryLimit: '256Mi', storageGi: 0, } as OptionalServiceProfile; const rates = [ { service: OptionalService.REDIS, resourceType: PricingResourceType.BASE_FEE, hourlyPrice: 10, monthlyPrice: 100, yearlyPrice: 1000, isActive: true, }, ] as OptionalServiceRate[]; const optional = { profiles: [profile], rates, customDomain: null }; const without = service.computeTotalsWithRates(baseDto(), [], optional); expect(without.monthly).toBe(0); const withRedis = service.computeTotalsWithRates( { ...baseDto(), enableRedis: true }, [], optional, ); expect(withRedis.monthly).toBe(100); expect(withRedis.yearly).toBe(1000); expect(withRedis.hourly).toBe(10); }); it('yearly deploy uses yearly column not monthly * 12', () => { const rates = [ { runtime: AppRuntime.NODEJS, resourceType: PricingResourceType.BASE_FEE, hourlyPrice: 1, monthlyPrice: 100, yearlyPrice: 999, isActive: true, }, ] as PricingRate[]; const result = service.computeTotalsWithRates(baseDto(), rates, emptyOptional()); expect(result.yearly).toBe(999); expect(result.monthly).toBe(100); expect(result.yearly).not.toBe(result.monthly * 12); }); it('bills optional service CPU from profile and service rate matrix', () => { const profile = { service: OptionalService.REDIS, cpuLimit: OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit, memoryLimit: '256Mi', storageGi: 0, } as OptionalServiceProfile; const rates = [ { service: OptionalService.REDIS, resourceType: PricingResourceType.CPU_PER_CORE, hourlyPrice: 1000, monthlyPrice: 0, yearlyPrice: 0, isActive: true, }, ] as OptionalServiceRate[]; const optional = { profiles: [profile], rates, customDomain: null }; const without = service.computeTotalsWithRates(baseDto(), [], optional); const withRedis = service.computeTotalsWithRates( { ...baseDto(), enableRedis: true }, [], optional, ); expect(withRedis.hourly - without.hourly).toBe(200); }); it('amountForCycleFromLine picks the correct column', () => { const line = { label: 'Test', hourly: 1, monthly: 2, yearly: 3 }; expect(service.amountForCycleFromLine(line, BillingCycle.HOURLY)).toBe(1); expect(service.amountForCycleFromLine(line, BillingCycle.MONTHLY)).toBe(2); expect(service.amountForCycleFromLine(line, BillingCycle.YEARLY)).toBe(3); }); it('managed_database bills database addon and resources without app base fee', () => { const rates = [ { runtime: AppRuntime.NODEJS, resourceType: PricingResourceType.BASE_FEE, hourlyPrice: 999, monthlyPrice: 999, yearlyPrice: 999, isActive: true, }, { runtime: AppRuntime.NODEJS, resourceType: PricingResourceType.DATABASE_ADDON, hourlyPrice: 0, monthlyPrice: 500, yearlyPrice: 0, isActive: true, }, { runtime: AppRuntime.NODEJS, resourceType: PricingResourceType.STORAGE_PER_GB, hourlyPrice: 0, monthlyPrice: 100, yearlyPrice: 0, isActive: true, }, ] as PricingRate[]; const result = service.computeTotalsWithRates( { ...baseDto(), productType: ProductType.MANAGED_DATABASE, databaseType: DatabaseType.POSTGRESQL, replicas: 0, dbStorageSize: '2Gi', cpuLimit: '500m', memoryLimit: '512Mi', }, rates, emptyOptional(), ); expect(result.monthly).toBe(700); expect(result.breakdown.some((l) => l.label.includes('Base fee'))).toBe(false); }); it('managed_redis bills only optional redis lines', () => { const profile = { service: OptionalService.REDIS, cpuLimit: OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit, memoryLimit: '256Mi', storageGi: 0, } as OptionalServiceProfile; const rates = [ { service: OptionalService.REDIS, resourceType: PricingResourceType.BASE_FEE, hourlyPrice: 0, monthlyPrice: 250, yearlyPrice: 0, isActive: true, }, ] as OptionalServiceRate[]; const appRates = [ { runtime: AppRuntime.NODEJS, resourceType: PricingResourceType.BASE_FEE, hourlyPrice: 999, monthlyPrice: 999, yearlyPrice: 999, isActive: true, }, ] as PricingRate[]; const result = service.computeTotalsWithRates( { ...baseDto(), productType: ProductType.MANAGED_REDIS, replicas: 0, enableRedis: true, }, appRates, { profiles: [profile], rates, customDomain: null }, ); expect(result.monthly).toBe(250); }); });