Files
cloud-host/backend/src/billing/pricing-catalog.service.spec.ts
T
keyhan 695e05f948 Add managed databases and services with billing-aligned upgrades.
Introduce product types for managed PostgreSQL, Redis, and RabbitMQ with a dedicated dashboard, Helm-only deploy pipeline, external access, snapshots with progress, and prorated resource or storage upgrades matching application billing rules. PVCs use an expandable StorageClass with automatic migration when legacy disks cannot resize in place.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 19:00:09 +03:30

300 lines
9.1 KiB
TypeScript

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);
});
});