import { describe, expect, test } from 'bun:test'; import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve as pathResolve } from 'node:path'; import bundledJson from '../cli/data/ppp-worldbank.json'; import { parseArgs } from '../cli/lib/args'; import { ALPHA3 } from '../cli/lib/money'; import { appleCurrencyHint, bigMacDataset, CURRENCY, datasetJson, datasetProblems, fxOf, HIGH_INCOME, offlinePar, parseFactorCsv, planRows, PPP_DEFAULTS, type PppDataset, type PppOptions, pppCsv, shareOf, snapApple, snapEnding, snapUp, TOP_MARKETS, topMarketDrops, topMarketsFromReports, VAT, worldBankDataset, } from '../cli/lib/ppp'; import { parsePriceCsv } from '../cli/lib/prices'; import { applePoints, run } from '../cli/prices-ppp'; const P = 'com.example.game.remove_ads'; const ds: PppDataset = { name: 'worldbank', source: 'test', url: 'https://example.com', license: 'CC BY 4.0', retrieved: '2026-10-11', eur: { fx: 0.9 }, countries: { US: { ppp: 1, fx: 1, year: 2025, currency: 'USD' }, DE: { ppp: 0.72, fx: 0.9, year: 2025, currency: 'EUR' }, TR: { ppp: 16, fx: 40, year: 2025, currency: 'TRY' }, IN: { ppp: 20, fx: 87, year: 2025, currency: 'INR' }, CH: { ppp: 1.2, fx: 0.8, year: 2025, currency: 'CHF' }, JP: { ppp: 97, fx: 150, year: 2025, currency: 'JPY' }, BA: { ppp: 0.7, fx: 1.75, year: 2025, currency: 'BAM' }, MX: { ppp: 10, fx: 18, year: 2025, currency: 'MXN' }, CN: { ppp: 3.4, fx: 7.2, year: 2025, currency: 'CNY' }, AF: { ppp: 13, year: 2024, currency: 'AFN' }, }, }; const OPEN: PppOptions = { ...PPP_DEFAULTS, maxRatio: 1.2, maxLevel: 5, onlyDecreases: false, includeHighIncome: true }; describe('ppp math', () => { test('share of par: level = factor / fx, clamped to min..max ratio; defaults keep high-income, CN and levels above 0.5 at par', () => { expect(shareOf('US', 1, PPP_DEFAULTS)).toEqual({ share: 1, reason: 'base' }); expect(shareOf('DE', 0.8, PPP_DEFAULTS)).toEqual({ share: 1, reason: 'high-income' }); expect(shareOf('CN', 0.47, PPP_DEFAULTS)).toEqual({ share: 1, reason: 'china' }); expect(shareOf('MX', 0.55, PPP_DEFAULTS)).toEqual({ share: 1, reason: 'above-level' }); expect(shareOf('TR', 0.41, PPP_DEFAULTS)).toEqual({ share: 0.41, reason: 'ppp' }); expect(shareOf('IN', 0.23, PPP_DEFAULTS)).toEqual({ share: 0.3, reason: 'min-ratio' }); expect(shareOf('DE', 0.8, OPEN)).toEqual({ share: 0.8, reason: 'ppp' }); expect(shareOf('CH', 1.5, OPEN)).toEqual({ share: 1.2, reason: 'max-ratio' }); expect(shareOf('AF', null, PPP_DEFAULTS)).toEqual({ share: 1, reason: 'no-ppp-data' }); expect(HIGH_INCOME.has('DE') && HIGH_INCOME.has('GB') && HIGH_INCOME.has('JP') && !HIGH_INCOME.has('IN') && !HIGH_INCOME.has('CN')).toBe(true); }); test('offline par: US price x exchange rate x (1 + VAT): store prices include VAT, the US price does not', () => { expect(offlinePar(4.99, 'DE', 'EUR', ds)).toBeCloseTo(4.99 * 0.9 * 1.19, 6); expect(offlinePar(4.99, 'US', 'USD', ds)).toBe(4.99); expect(offlinePar(4.99, 'XX', 'XYZ', ds)).toBeNull(); expect(VAT.US).toBeUndefined(); }); test('fxOf: USD 1, EUR the euro rate, else a country with that currency', () => { expect(fxOf(ds, 'USD')).toBe(1); expect(fxOf(ds, 'EUR')).toBe(0.9); expect(fxOf(ds, 'TRY')).toBe(40); expect(fxOf(ds, 'XYZ')).toBeNull(); }); }); describe('snapping', () => { test('two-decimal currencies: x.x9 under 1, x.99 under 100, ...9 under 1000', () => { expect(snapEnding(2.4, 'USD')).toBe(1.99); expect(snapEnding(2.6, 'USD')).toBe(2.99); expect(snapEnding(0.3, 'GBP')).toBe(0.29); expect(snapEnding(0.02, 'GBP')).toBe(0.09); expect(snapEnding(1.2, 'EUR')).toBe(0.99); expect(snapEnding(49.6, 'TRY')).toBe(49.99); expect(snapEnding(129.4, 'TRY')).toBe(129); expect(snapEnding(135, 'INR')).toBe(139); }); test('zero-decimal and large-unit currencies: round numbers', () => { expect(snapEnding(478, 'JPY')).toBe(480); expect(snapEnding(80.4, 'JPY')).toBe(80); expect(snapEnding(3456, 'KRW')).toBe(3500); expect(snapEnding(45678, 'IDR')).toBe(46000); expect(snapEnding(56789, 'VND')).toBe(57000); expect(snapEnding(2345, 'CLP')).toBe(2300); expect(snapEnding(923, 'HUF')).toBe(920); expect(snapEnding(1234.5, 'PKR')).toBe(1200); }); test('par rounds up to the next clean price (stores round their own conversions up)', () => { expect(snapUp(5.26, 'EUR')).toBe(5.99); expect(snapUp(4.99, 'EUR')).toBe(4.99); expect(snapUp(821.5, 'JPY')).toBe(830); expect(snapUp(111.35, 'MXN')).toBe(119); expect(snapUp(0.31, 'GBP')).toBe(0.39); }); test('Apple: nearest real price point', () => { expect(snapApple([49.99, 59.99, 64.99, 69.99], 62)).toBe(59.99); expect(snapApple([], 62)).toBeNull(); }); }); describe('planRows', () => { test('defaults: Germany stays near EUR 5.99 for USD 4.99, India and Turkey drop, high-income and price levels above 0.5 keep par', () => { const { rows, kept, skipped } = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: PPP_DEFAULTS }); const by = new Map([...rows, ...kept].map(r => [r.region, r] as const)); expect(rows.map(r => r.region)).toEqual(['BA', 'IN', 'TR', 'US']); for (const r of ['CH', 'DE', 'FR', 'JP', 'MX']) expect(kept.map(k => k.region)).toContain(r); expect(by.get('MX')).toMatchObject({ reason: 'above-level', share: 1 }); expect(by.get('US')).toMatchObject({ currency: 'USD', price: 4.99, snap: 'base' }); expect(by.get('DE')).toMatchObject({ currency: 'EUR', price: 5.99, reason: 'high-income', share: 1 }); expect(by.get('JP')).toMatchObject({ currency: 'JPY', reason: 'high-income' }); expect(by.get('JP')!.price).toBeGreaterThanOrEqual(4.99 * 150 * 1.1); expect(by.get('CH')).toMatchObject({ reason: 'high-income', share: 1 }); expect(by.get('IN')).toMatchObject({ currency: 'INR', reason: 'min-ratio', share: 0.3 }); expect(by.get('IN')!.price).toBeLessThan(4.99 * 87 * 1.18 * 0.35); expect(by.get('TR')).toMatchObject({ currency: 'TRY', reason: 'ppp', share: 0.4 }); expect(by.get('TR')!.price).toBe(snapEnding(4.99 * 40 * 1.2 * 0.4, 'TRY')); expect(by.get('BA')).toMatchObject({ currency: 'BAM', reason: 'ppp', share: 0.4 }); expect(skipped.find(s => s.region === 'AF')!.why).toBe('no exchange rate'); expect(skipped.find(s => s.region === 'PE')!.why).toMatch(/no PPP factor/); expect(skipped.find(s => s.region === 'CN')!.why).toMatch(/not available in mainland China/); expect(rows.every(r => r.price <= r.par * 1.2 || r.share === 1)).toBe(true); expect(rows.every(r => r.territory === undefined)).toBe(true); }); test('the old method (no VAT, no par, everything cut) would have given Germany EUR 3.59: the open options still compare net with net', () => { const { rows } = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: OPEN }); const de = rows.find(r => r.region === 'DE')!; expect(de.share).toBeCloseTo(0.8, 6); expect(de.price).toBe(snapEnding(4.99 * 0.9 * 1.19 * 0.8, 'EUR')); expect(de.price).toBeGreaterThan(3.59); }); test('only-decreases: never above the current store price; max-ratio caps a region at N x par', () => { const current = new Map([['TR', { currency: 'TRY', price: 59.99 }], ['DE', { currency: 'EUR', price: 4.99 }]]); const { rows, kept } = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: PPP_DEFAULTS, current }); expect(rows.some(r => r.region === 'TR' || r.region === 'DE')).toBe(false); expect(kept.find(r => r.region === 'TR')).toMatchObject({ price: 59.99, reason: 'only-decreases' }); expect(kept.find(r => r.region === 'DE')).toMatchObject({ price: 4.99, reason: 'only-decreases' }); const up = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: { ...PPP_DEFAULTS, onlyDecreases: false, includeHighIncome: true, maxLevel: 1 }, current }).rows; expect(up.find(r => r.region === 'TR')!.price).toBeGreaterThan(59.99); const capped = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: { ...OPEN, maxRatio: 0.5 } }).rows; expect(capped.find(r => r.region === 'DE')).toMatchObject({ share: 0.5, reason: 'max-ratio' }); }); test('offline Apple: the currency hint (Euro-Zone group EUR, USD groups USD), alpha-3 territory, CN at par', () => { const { rows, kept } = planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, options: PPP_DEFAULTS }); expect(rows.find(r => r.region === 'BA')).toMatchObject({ currency: 'EUR', territory: 'BIH' }); expect(kept.find(r => r.region === 'CN')).toMatchObject({ reason: 'china', share: 1 }); expect(appleCurrencyHint('TR')).toBe('TRY'); expect(appleCurrencyHint('AF')).toBe('USD'); expect(appleCurrencyHint('RS')).toBe('EUR'); }); test('online: the store\'s own conversion is par (Apple equalizations, Play convertRegionPrices); Apple snaps to its price points', () => { const regions = new Map([ ['US', { currency: 'USD', points: [3.99, 4.99, 5.99] }], ['DE', { currency: 'EUR', points: [4.99, 5.99, 6.99], par: 5.99 }], ['TR', { currency: 'TRY', points: [49.99, 59.99, 64.99, 69.99, 79.99, 99.99], par: 249.99 }], ['JP', { currency: 'JPY', points: [] as number[] }], ]); const { rows, kept, skipped } = planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, options: PPP_DEFAULTS, regions }); expect([...rows, ...kept].map(r => `${r.region} ${r.currency} ${r.price} ${r.snap}`)).toEqual(['TR TRY 99.99 apple-point', 'US USD 4.99 apple-point', 'DE EUR 5.99 par']); expect(rows.find(r => r.region === 'TR')!.snappedFrom).toBeCloseTo(249.99 * 0.4, 1); expect(kept.find(r => r.region === 'DE')!.parFrom).toBe('store'); expect(skipped.map(s => `${s.region}: ${s.why}`)).toEqual(['JP: no Apple price points']); }); test('top-market check: a drop of more than 20 % in a top market is named', () => { const { rows } = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: OPEN }); const drops = topMarketDrops(rows, TOP_MARKETS); expect(drops.map(d => d.region)).toContain('JP'); expect(drops.every(d => d.drop > 0.2)).toBe(true); expect(topMarketDrops(planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: PPP_DEFAULTS }).rows, TOP_MARKETS)).toEqual([]); expect(topMarketsFromReports([[{ 'Country Code': 'TR', Units: '10', 'Developer Proceeds': '80', 'Currency of Proceeds': 'TRY' }, { 'Country Code': 'DE', Units: '3', 'Developer Proceeds': '4', 'Currency of Proceeds': 'EUR' }], [{ 'Buyer Country': 'IN', 'Charged Amount': '2000', 'Currency of Sale': 'INR' }]], ds)) .toEqual(['IN', 'TR', 'DE']); }); test('CSV round trip through parsePriceCsv (the prices import parser)', () => { const rows = [...planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, options: PPP_DEFAULTS }).rows, ...planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: PPP_DEFAULTS }).rows]; const back = parsePriceCsv(pppCsv(rows), { products: [P], today: '2026-10-11' }); const k = (r: { store: string; region: string; currency: string; price: number }) => `${r.store} ${r.region} ${r.currency} ${r.price}`; expect(back.map(k).sort()).toEqual(rows.map(k).sort()); expect(back.find(r => r.store === 'apple' && r.region === 'BA')!.territory).toBe('BIH'); expect(pppCsv(rows).split('\n')[0]).toBe('product,store,region,currency,price,start_date,kind'); }); }); describe('datasets', () => { const bundled = bundledJson as unknown as PppDataset; test('bundled World Bank file: shape, citation, licence, euro rate', () => { expect(datasetProblems(bundled)).toEqual([]); expect(bundled.name).toBe('worldbank'); expect(bundled.license).toBe('CC BY 4.0'); expect(bundled.attribution).toMatch(/World Bank/); expect(bundled.source).toMatch(/PA\.NUS\.PPP/); expect(bundled.source).toMatch(/PA\.NUS\.FCRF/); expect(bundled.retrieved).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(bundled.urls!.some(u => u.startsWith('https://api.worldbank.org/'))).toBe(true); expect(bundled.year).toBeGreaterThanOrEqual(2023); expect(Object.keys(bundled.countries).length).toBeGreaterThan(120); for (const [r, e] of Object.entries(bundled.countries)) { expect(ALPHA3[r]).toBeDefined(); expect(e.currency).toBe(CURRENCY[r]!); if (e.currency === 'EUR') expect(e.fx).toBe(bundled.eur!.fx); } expect(bundled.countries.US).toMatchObject({ ppp: 1, fx: 1 }); expect(JSON.parse(datasetJson(bundled))).toEqual(bundled); }); test('CURRENCY covers every App Store territory', () => { expect(Object.keys(ALPHA3).filter(k => !CURRENCY[k])).toEqual([]); }); test('worldBankDataset: latest year, aggregates out, euro rate, unit mismatch dropped', () => { const obs = (id: string, date: string, value: number | null) => ({ country: { id }, countryiso3code: '', date, value }); const ppp: [unknown, ReturnType[]] = [{}, [obs('ZH', '2025', 5), obs('DE', '2024', 0.7), obs('DE', '2025', 0.71), obs('TR', '2025', null), obs('TR', '2024', 15), obs('LR', '2025', 0.4), obs('US', '2025', 1)]]; const fx: [unknown, ReturnType[]] = [{}, [obs('XC', '2025', 0.88), obs('TR', '2025', 39), obs('LR', '2025', 190), obs('US', '2025', 1)]]; const countries: [unknown, { id: string; iso2Code: string; region?: { value?: string } }[]] = [{}, [ { id: 'AFE', iso2Code: 'ZH', region: { value: 'Aggregates' } }, { id: 'DEU', iso2Code: 'DE', region: { value: 'Europe & Central Asia' } }, { id: 'TUR', iso2Code: 'TR' }, { id: 'LBR', iso2Code: 'LR' }, { id: 'USA', iso2Code: 'US' }]]; const d = worldBankDataset(ppp, fx, countries, '2026-10-11'); expect(Object.keys(d.countries)).toEqual(['DE', 'TR', 'US']); expect(d.countries.DE).toEqual({ ppp: 0.71, fx: 0.88, year: 2025, currency: 'EUR' }); expect(d.countries.TR).toEqual({ ppp: 15, fx: 39, year: 2024, fxYear: 2025, currency: 'TRY' }); expect(d.eur).toEqual({ fx: 0.88, year: 2025 }); expect(d.notes!.join(' ')).toMatch(/LR/); expect(datasetProblems(d)).toEqual([]); }); test('bigMacDataset: implied PPP = local / US price, latest date, euro area row for EUR members', () => { const csv = `date,iso_a3,currency_code,name,local_price,dollar_ex,dollar_price 2025-07-01,USA,USD,United States,5.5,1,5.5 2026-07-01,USA,USD,United States,6,1,6 2026-07-01,TUR,TRY,Turkey,240,40,6 2026-07-01,EUZ,EUR,Euro area,5.4,0.9,6 `; const d = bigMacDataset(csv, '2026-10-11'); expect(d.countries.TR).toEqual({ ppp: 40, fx: 40, year: 2026, currency: 'TRY' }); expect(d.countries.DE).toEqual({ ppp: 0.9, fx: 0.9, year: 2026, currency: 'EUR' }); expect(d.countries.US!.ppp).toBe(1); expect(d.eur!.fx).toBe(0.9); expect(d.license).toMatch(/CC BY 4\.0/); }); test('factor CSV: fx falls back to the bundled rate when the currency matches; errors at once', () => { const fb: PppDataset = { ...ds }; const d = parseFactorCsv('region,factor,currency,fx\nTR,15,TRY,\nDE,0.7,EUR,0.92\nUS,1,USD,\nAF,13,AFN,\n', fb, 'f.csv'); expect(d.countries.TR).toEqual({ ppp: 15, fx: 40, currency: 'TRY' }); expect(d.countries.DE!.fx).toBe(0.92); expect(d.countries.US!.fx).toBe(1); expect(d.countries.AF!.fx).toBeUndefined(); expect(() => parseFactorCsv('region,factor,currency\nTUR,0,tr\n')).toThrow(/region "TUR"/); expect(() => parseFactorCsv('region,factor,currency\nTR,0,tr\n')).toThrow(/currency.*factor/); }); }); describe('command', () => { test('applePoints: batched GET with include=territory -> region, currency, points', async () => { const urls: string[] = []; const fake = { asc: async (_m: string, url: string) => { urls.push(url); return { data: [ { id: 'pp1', attributes: { customerPrice: '59.99' }, relationships: { territory: { data: { type: 'territories', id: 'TUR' } } } }, { id: 'pp2', attributes: { customerPrice: '64.99' }, relationships: { territory: { data: { type: 'territories', id: 'TUR' } } } }, { id: 'pp3', attributes: { customerPrice: '4.99' }, relationships: { territory: { data: { type: 'territories', id: 'USA' } } } }], included: [{ type: 'territories', id: 'TUR', attributes: { currency: 'TRY' } }, { type: 'territories', id: 'USA', attributes: { currency: 'USD' } }] }; } }; const m = await applePoints(fake as never, 'iap-1', ['TUR', 'USA']); expect(urls).toEqual(['/v2/inAppPurchases/iap-1/pricePoints?filter[territory]=TUR,USA&include=territory&fields[territories]=currency&limit=8000']); expect(m.get('TR')).toEqual({ currency: 'TRY', points: [59.99, 64.99] }); expect(m.get('US')).toEqual({ currency: 'USD', points: [4.99] }); }); async function runIn(argv: string[]): Promise<{ out: string; dir: string }> { const dir = mkdtempSync(join(tmpdir(), 'ppp-')); const config = { app: { name: 'Example' }, apple: { bundleId: 'com.example.game' }, play: { packageName: 'com.example.game' }, products: [{ id: P, type: 'non_consumable', usd: 2.99, listings: {} }] }; const lines: string[] = []; const orig = console.log, write = process.stdout.write; const fetchOrig = globalThis.fetch; console.log = (...x: unknown[]) => { lines.push(x.join(' ')); }; process.stdout.write = ((t: string) => { lines.push(String(t)); return true; }) as typeof process.stdout.write; globalThis.fetch = (() => { throw new Error('no network in tests'); }) as never; try { await run({ loaded: { config, root: dir, path: join(dir, 'storebound.config.ts'), resolve: (p: string) => pathResolve(dir, p) }, args: parseArgs(argv) } as never); } finally { console.log = orig; process.stdout.write = write; globalThis.fetch = fetchOrig; } return { out: lines.join('\n'), dir }; } test('offline --dry --json: the documented shape, nothing written', async () => { const { out, dir } = await runIn(['--offline', '--dry', '--json', '--base', '4.99']); const j = JSON.parse(out.slice(out.indexOf('{'))); expect(Object.keys(j)).toEqual(['source', 'base', 'floor', 'ceiling', 'options', 'online', 'rows', 'kept', 'skipped', 'diff', 'topMarkets', 'out', 'written']); expect(j.options).toEqual(PPP_DEFAULTS); expect(j.kept.find((r: { store: string; region: string }) => r.store === 'play' && r.region === 'DE')).toMatchObject({ currency: 'EUR', reason: 'high-income', price: 5.99 }); expect(j.source).toMatchObject({ name: 'worldbank', license: 'CC BY 4.0' }); expect(j.base).toEqual({ [P]: 4.99 }); expect(j.written).toBe(false); expect(j.rows.find((r: { store: string; region: string }) => r.store === 'play' && r.region === 'US')).toMatchObject({ price: 4.99, factor: 1, usdEquivalent: 4.99 }); expect(j.diff.every((d: { change: string }) => d.change === 'new')).toBe(true); expect(() => readFileSync(join(dir, 'store/prices-ppp.csv'))).toThrow(); }); test('offline write: the sheet parses back with the import parser', async () => { const { out, dir } = await runIn(['--offline', '--store', 'play', '--floor', '0.4']); const csv = readFileSync(join(dir, 'store/prices-ppp.csv'), 'utf8'); const rows = parsePriceCsv(csv, { products: [P] }); expect(rows.length).toBeGreaterThan(50); expect(rows.every(r => r.store === 'play')).toBe(true); expect(out).toMatch(/storebound prices import --csv store\/prices-ppp\.csv --dry/); }); test('source file and bad bounds', async () => { const dir = mkdtempSync(join(tmpdir(), 'ppp-f-')); writeFileSync(join(dir, 'f.csv'), 'region,factor,currency,fx\nTR,16,TRY,40\n'); await expect(runIn(['--offline', '--floor', '2', '--ceiling', '1'])).rejects.toThrow(/--min-ratio/); await expect(runIn(['--offline', '--min-ratio', '0.6', '--max-ratio', '0.5'])).rejects.toThrow(/--min-ratio/); await expect(runIn(['--offline', '--source', 'bigmac'])).rejects.toThrow(/--update-bigmac/); const { out } = await runIn(['--offline', '--dry', '--json', '--source', 'file', '--file', join(dir, 'f.csv'), '--store', 'play']); const j = JSON.parse(out.slice(out.indexOf('{'))); expect(j.rows.map((r: { region: string }) => r.region)).toEqual(['TR', 'US']); }); });