247 lines
14 KiB
TypeScript
247 lines
14 KiB
TypeScript
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, parseFactorCsv, planRows, type PppDataset, pppCsv, propose, snapApple,
|
|
snapEnding, 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' },
|
|
AF: { ppp: 13, year: 2024, currency: 'AFN' },
|
|
},
|
|
};
|
|
|
|
describe('ppp math', () => {
|
|
test('usd = base x factor / fx, clamped to floor..ceiling', () => {
|
|
expect(propose(10, 0.72, 0.9, 0.3, 1.2)).toMatchObject({ clamped: null });
|
|
expect(propose(10, 0.72, 0.9, 0.3, 1.2).usd).toBeCloseTo(8, 9);
|
|
const lo = propose(10, 20, 87, 0.3, 1.2);
|
|
expect(lo.clamped).toBe('floor');
|
|
expect(lo.usd).toBe(3);
|
|
expect(lo.usdRaw).toBeCloseTo(2.299, 3);
|
|
expect(propose(10, 1.2, 0.8, 0.3, 1.2)).toMatchObject({ usd: 12, clamped: 'ceiling' });
|
|
expect(() => propose(10, 0, 1, 0.3, 1.2)).toThrow();
|
|
});
|
|
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('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('offline Play: local currency, US row is the base, clamp flags, skipped regions', () => {
|
|
const { rows, skipped } = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, floor: 0.3, ceiling: 1.2 });
|
|
const by = new Map(rows.map(r => [r.region, r] as const));
|
|
expect(by.get('US')).toMatchObject({ currency: 'USD', price: 4.99, snap: 'base' });
|
|
expect(by.get('DE')).toMatchObject({ currency: 'EUR', price: 3.99, clamped: null, snap: 'ending' });
|
|
expect(by.get('IN')).toMatchObject({ currency: 'INR', clamped: 'floor', price: 129 });
|
|
expect(by.get('CH')).toMatchObject({ clamped: 'ceiling', price: 4.99 });
|
|
expect(by.get('JP')).toMatchObject({ currency: 'JPY', price: 480 });
|
|
expect(by.get('BA')!.currency).toBe('BAM');
|
|
expect(skipped.find(s => s.region === 'AF')!.why).toBe('no exchange rate');
|
|
expect(skipped.find(s => s.region === 'FR')!.why).toMatch(/no PPP factor/);
|
|
expect(rows.every(r => r.territory === undefined)).toBe(true);
|
|
});
|
|
test('offline Apple: the currency hint (Euro-Zone group EUR, USD groups USD), alpha-3 territory', () => {
|
|
const { rows } = planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, floor: 0.3, ceiling: 1.2 });
|
|
const ba = rows.find(r => r.region === 'BA')!;
|
|
expect(ba).toMatchObject({ currency: 'EUR', territory: 'BIH' });
|
|
expect(ba.snappedFrom).toBeCloseTo((4.99 * 0.7 / 1.75) * 0.9, 2);
|
|
expect(appleCurrencyHint('TR')).toBe('TRY');
|
|
expect(appleCurrencyHint('AF')).toBe('USD');
|
|
expect(appleCurrencyHint('RS')).toBe('EUR');
|
|
});
|
|
test('online Apple: snaps to the mocked price points and takes the store currency', () => {
|
|
const regions = new Map([
|
|
['US', { currency: 'USD', points: [3.99, 4.99, 5.99] }],
|
|
['TR', { currency: 'TRY', points: [49.99, 59.99, 64.99, 69.99, 79.99] }],
|
|
['AF', { currency: 'USD', points: [0.99, 1.99] }],
|
|
['JP', { currency: 'JPY', points: [] as number[] }],
|
|
]);
|
|
const { rows, skipped } = planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, floor: 0.3, ceiling: 1.2, regions });
|
|
expect(rows.map(r => `${r.region} ${r.currency} ${r.price} ${r.snap}`)).toEqual(['TR TRY 79.99 apple-point', 'US USD 4.99 apple-point']);
|
|
expect(rows[0]!.snappedFrom).toBeCloseTo(4.99 * 0.4 * 40, 1);
|
|
expect(skipped.map(s => `${s.region}: ${s.why}`)).toEqual(['AF: no exchange rate', 'JP: no Apple price points']);
|
|
});
|
|
test('CSV round trip through parsePriceCsv (the prices import parser)', () => {
|
|
const rows = [...planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, floor: 0.3, ceiling: 1.2 }).rows,
|
|
...planRows({ product: P, base: 4.99, store: 'play', dataset: ds, floor: 0.3, ceiling: 1.2 }).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 === 'DE')!.territory).toBe('DEU');
|
|
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<typeof obs>[]] = [{}, [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<typeof obs>[]] = [{}, [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;
|
|
const fetchOrig = globalThis.fetch;
|
|
console.log = (...x: unknown[]) => { lines.push(x.join(' ')); };
|
|
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; 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', 'online', 'rows', 'skipped', 'diff', 'out', 'written']);
|
|
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(100);
|
|
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(/floor/);
|
|
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']);
|
|
});
|
|
});
|