diff --git a/src/__tests__/billing-index.test.ts b/src/__tests__/billing-index.test.ts index feeac247..478a57f3 100644 --- a/src/__tests__/billing-index.test.ts +++ b/src/__tests__/billing-index.test.ts @@ -8,11 +8,13 @@ * Confirms the hot /api/billing filter on `developer_id` creates and uses * `idx_billing_requests_lookup_hot`, and that the rollback migration drops it. */ +import fc from 'fast-check'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { newDb } from 'pg-mem'; +import { billingInternals } from '../services/billing.js'; const migrationsDir = path.join(process.cwd(), 'migrations'); @@ -31,6 +33,13 @@ INSERT INTO billing_requests (id, request_id, developer_id, api_id, endpoint_id, VALUES ('req_1', 'req_id_1', 'dev_a', 'api_1', 'ep_1', 'key_1', '5.00'); `; +// --- USDC unit conversion properties (issue: property-test round trips) --- +// Use the real exported helpers from src/services/billing.ts so these +// round-trip invariants exercise production code instead of a local copy. +const { parseUsdcToContractUnits, formatContractUnitsToUsdc } = billingInternals; +const USDC_DECIMALS = 7; +const USDC_SCALE = 10n ** BigInt(USDC_DECIMALS); + const HOT_PATH_QUERY = ` SELECT id, request_id, developer_id, api_id, endpoint_id, api_key_id, amount_usdc, created_at FROM billing_requests @@ -48,6 +57,101 @@ function isSqliteAvailable(): boolean { } } +describe('USDC unit conversion — fast-check properties', () => { + it('round-trips format(parse(x)) for 10k generated amounts', () => { + const amountArb = fc + .tuple( + fc.bigInt({ min: 0n, max: 10n ** 18n }), + fc.integer({ min: 0, max: USDC_DECIMALS }), + fc.array(fc.integer({ min: 0, max: 9 }), { + minLength: USDC_DECIMALS, + maxLength: USDC_DECIMALS, + }), + ) + .map(([whole, fracLen, digits]) => { + const frac = digits.slice(0, fracLen).join(''); + const raw = frac.length > 0 ? `${whole}.${frac}` : `${whole}`; + return raw; + }) + .filter((raw) => { + try { + parseUsdcToContractUnits(raw); + return true; + } catch { + return false; + } + }); + + fc.assert( + fc.property(amountArb, (raw) => { + const units = parseUsdcToContractUnits(raw); + const formatted = formatContractUnitsToUsdc(units); + const reparsed = parseUsdcToContractUnits(formatted); + expect(reparsed).toBe(units); + }), + { numRuns: 10_000 }, + ); + }); + + it('rejects inputs with more than 7 fractional digits', () => { + fc.assert( + fc.property( + fc.bigInt({ min: 0n, max: 10n ** 12n }), + fc.array(fc.integer({ min: 0, max: 9 }), { minLength: 8, maxLength: 12 }), + (whole, digits) => { + const raw = `${whole}.${digits.join('')}`; + expect(() => parseUsdcToContractUnits(raw)).toThrow(); + }, + ), + { numRuns: 1_000 }, + ); + }); + + it('rejects negative amounts', () => { + fc.assert( + fc.property(fc.bigInt({ min: 1n, max: 10n ** 12n }), (whole) => { + expect(() => parseUsdcToContractUnits(`-${whole}`)).toThrow(); + }), + { numRuns: 1_000 }, + ); + }); + + it("throws 'must be greater than zero' for zero", () => { + expect(() => parseUsdcToContractUnits('0')).toThrow('must be greater than zero'); + expect(() => parseUsdcToContractUnits('0.0')).toThrow('must be greater than zero'); + expect(() => parseUsdcToContractUnits('0.0000000')).toThrow('must be greater than zero'); + }); + + it('never emits trailing zeros in formatted output', () => { + fc.assert( + fc.property( + fc.bigInt({ min: 1n, max: 10n ** 24n }), + (units) => { + const formatted = formatContractUnitsToUsdc(units); + expect(formatted).not.toMatch(/\.\d*0$/); + expect(formatted).not.toMatch(/\.$/); + }, + ), + { numRuns: 10_000 }, + ); + }); + + it('preserves precision for very large whole numbers', () => { + fc.assert( + fc.property( + fc.bigInt({ min: 10n ** 18n, max: 10n ** 30n }), + (whole) => { + const raw = `${whole}`; + const units = parseUsdcToContractUnits(raw); + expect(units).toBe(whole * USDC_SCALE); + expect(formatContractUnitsToUsdc(units)).toBe(raw); + }, + ), + { numRuns: 1_000 }, + ); + }); +}); + describe('migrations/billing_index.sql — EXPLAIN-verified hot path [b#057]', () => { let db: ReturnType; @@ -61,6 +165,7 @@ describe('migrations/billing_index.sql — EXPLAIN-verified hot path [b#057]', ( expect(() => db.public.none(upSql)).not.toThrow(); }); + it('uses idx_billing_requests_lookup_hot for the hot developer_id filter', () => { const upSql = readFileSync(path.join(migrationsDir, 'billing_index.sql'), 'utf8'); db.public.none(upSql);