Add optional service pricing matrix and fix admin catalog save.
Users pick per-service CPU/memory/storage at deploy; admins manage unit rates and deploy defaults. PATCH sends only fields accepted by the pricing-catalog DTO. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,6 +3,8 @@ 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,
|
||||
@@ -30,6 +32,20 @@ describe('PricingCatalogService', () => {
|
||||
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({
|
||||
@@ -37,6 +53,8 @@ describe('PricingCatalogService', () => {
|
||||
PricingCatalogService,
|
||||
{ provide: getRepositoryToken(PricingRate), useValue: rateRepo },
|
||||
{ provide: getRepositoryToken(AddonRate), useValue: addonRepo },
|
||||
{ provide: getRepositoryToken(OptionalServiceProfile), useValue: optionalProfileRepo },
|
||||
{ provide: getRepositoryToken(OptionalServiceRate), useValue: optionalRateRepo },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -51,6 +69,8 @@ describe('PricingCatalogService', () => {
|
||||
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);
|
||||
@@ -68,31 +88,41 @@ describe('PricingCatalogService', () => {
|
||||
},
|
||||
] as PricingRate[];
|
||||
|
||||
const result = service.computeTotalsWithRates(baseDto(), rates, []);
|
||||
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 addon only when enabled', () => {
|
||||
const addons = [
|
||||
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 = [
|
||||
{
|
||||
resourceType: PricingResourceType.REDIS_ADDON,
|
||||
service: OptionalService.REDIS,
|
||||
resourceType: PricingResourceType.BASE_FEE,
|
||||
hourlyPrice: 10,
|
||||
monthlyPrice: 100,
|
||||
yearlyPrice: 1000,
|
||||
isActive: true,
|
||||
},
|
||||
] as AddonRate[];
|
||||
] as OptionalServiceRate[];
|
||||
|
||||
const without = service.computeTotalsWithRates(baseDto(), [], addons);
|
||||
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 },
|
||||
[],
|
||||
addons,
|
||||
optional,
|
||||
);
|
||||
expect(withRedis.monthly).toBe(100);
|
||||
expect(withRedis.yearly).toBe(1000);
|
||||
@@ -111,42 +141,41 @@ describe('PricingCatalogService', () => {
|
||||
},
|
||||
] as PricingRate[];
|
||||
|
||||
const result = service.computeTotalsWithRates(baseDto(), rates, []);
|
||||
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/RAM/storage with same runtime unit rates', () => {
|
||||
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 = [
|
||||
{
|
||||
runtime: AppRuntime.NODEJS,
|
||||
service: OptionalService.REDIS,
|
||||
resourceType: PricingResourceType.CPU_PER_CORE,
|
||||
hourlyPrice: 1000,
|
||||
monthlyPrice: 0,
|
||||
yearlyPrice: 0,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
runtime: AppRuntime.NODEJS,
|
||||
resourceType: PricingResourceType.MEMORY_PER_GB,
|
||||
hourlyPrice: 0,
|
||||
monthlyPrice: 0,
|
||||
yearlyPrice: 0,
|
||||
isActive: true,
|
||||
},
|
||||
] as PricingRate[];
|
||||
] as OptionalServiceRate[];
|
||||
|
||||
const redisCpu = parseFloat(OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit) / 1000;
|
||||
const optional = { profiles: [profile], rates, customDomain: null };
|
||||
|
||||
const without = service.computeTotalsWithRates(baseDto(), rates, []);
|
||||
const without = service.computeTotalsWithRates(baseDto(), [], optional);
|
||||
const withRedis = service.computeTotalsWithRates(
|
||||
{ ...baseDto(), enableRedis: true },
|
||||
rates,
|
||||
[],
|
||||
optional,
|
||||
);
|
||||
|
||||
expect(withRedis.hourly - without.hourly).toBe(Math.round(redisCpu * 1000));
|
||||
expect(withRedis.hourly - without.hourly).toBe(200);
|
||||
});
|
||||
|
||||
it('amountForCycleFromLine picks the correct column', () => {
|
||||
|
||||
Reference in New Issue
Block a user