--json prints only JSON on stdout (withJsonOutput around every command: logs, tables and prompts to stderr; printJson everywhere; test runs every --json command against the fake stores and parses stdout); prices ppp compares like with like: par = the store's own US-equivalent price (Play convertRegionPrices, Apple equalizations; offline base x fx x (1 + VAT) rounded up) scaled by the PPP price level; defaults only lower, only price levels <= 0.5, World Bank high-income at par, CN at par on Apple and skipped on Play, never above par (--only-decreases, --max-ratio, --min-ratio, --max-level, --include-high-income; --floor / --ceiling kept), top-10 revenue market drops > 20 % warned (sales reports, else a static list), unchanged regions out of the sheet; price changes of existing products only with --allow-price-changes, TTY or not (a terminal yes covered them), then their own question listing each old -> new, held section in dry runs (play products, prices import, play / apple subscriptions); pitfalls 61-63; tests

This commit is contained in:
CRTkafa 2026-10-14 17:36:00 +08:00
parent bab3f63227
commit 357319a775
30 changed files with 865 additions and 209 deletions

100
tests/json-stdout.test.ts Normal file
View file

@ -0,0 +1,100 @@
// `--json` runs write only the JSON to stdout; every log, step, table and warning line goes to stderr (lib/log.ts
// withJsonOutput, wrapped around every command by index.ts). STAR DASH, 2026-10-14: `prices ppp --json` printed log
// lines on stdout before the JSON, so `JSON.parse(stdout)` failed. This runs every command whose module reads --json
// against the fake stores, in a temp project and HOME, and requires stdout to be empty (the command failed or wrote
// nothing) or exactly one JSON document.
import { afterAll, beforeAll, describe, expect, spyOn, test } from 'bun:test';
import * as rt from '../cli/lib/rt';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import type { ShipConfig } from '../cli/lib/config';
import { parseArgs } from '../cli/lib/args';
import { withJsonOutput } from '../cli/lib/log';
import { COMMANDS, PLAIN } from '../cli/commands';
import { ascEnv, BASE, capture, playEnv, project, restore } from './helpers/store-mock';
import { installStores, uninstallStores } from './helpers/fake-stores';
const CLI = resolve(import.meta.dir, '../cli');
/** Never run here: long-lived servers, and the ones that install or open things on the machine. */
const SKIP = new Set(['mcp', 'serve', 'install', 'desktop', 'init', 'flow', 'ws']);
/** Commands that need a subcommand (or arguments) to reach their --json output. */
const ARGV: Record<string, string[]> = {
'prices': ['ppp', '--offline'], 'api': ['list'], 'accounts': ['list'], 'release': ['status'], 'apps': ['folders'],
'ads': ['scan'], 'reviews': ['list'], 'health': ['status'],
};
/** Ones that must print JSON here (the report's command and a few plain readers). */
const MUST = ['prices', 'inspect', 'doctor', 'api'];
/** command key -> module source (the `import('./x')` of cli/commands.ts), for the commands that read --json. */
function jsonCommands(): string[] {
const src = readFileSync(join(CLI, 'commands.ts'), 'utf8');
const out: string[] = [];
for (const m of src.matchAll(/^ {2}'([a-z][a-z0-9 -]*)': \[.*?import\('(\.\/[^']+)'\)/gm)) {
const key = m[1]!;
if (SKIP.has(key)) continue;
let file = join(CLI, m[2]!);
try { file = readFileSync(file + '.ts', 'utf8'); } catch { try { file = readFileSync(join(file, 'index.ts'), 'utf8'); } catch { continue; } }
if (/has\('json'\)|get\('json'\)|'json'\)/.test(file)) out.push(key);
}
return out;
}
const CFG: ShipConfig = { ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }],
products: [{ id: 'com.example.notes.pro', type: 'non_consumable', usd: 4.99, listings: { en: { title: 'Pro', description: 'Every feature.' } } }] };
let home = '', cwd = '';
const saved = { home: process.env.HOME, cwd: process.cwd() };
// retries and store-lag waits must not sleep here: the doubles answer at once
let sleep: { mockRestore: () => void } | undefined;
beforeAll(() => { sleep = spyOn(rt, 'sleep').mockImplementation(async () => {}); ascEnv(); playEnv(); home = mkdtempSync(join(tmpdir(), 'sb-json-home-')); process.env.HOME = home; });
afterAll(() => { sleep?.mockRestore(); restore(); process.env.HOME = saved.home; process.chdir(saved.cwd); });
describe('--json: stdout is only the JSON', () => {
const keys = jsonCommands();
test('finds the commands that read --json', () => {
expect(keys.length).toBeGreaterThan(20);
for (const k of MUST) expect(keys).toContain(k);
});
const printed: string[] = [];
for (const key of keys) {
test(`storebound ${key} --json`, async () => {
const p = project(CFG);
cwd = p.root; process.chdir(cwd);
installStores();
const argv = [...(ARGV[key] ?? []), '--json'];
const args = parseArgs(argv);
const r = await capture(() => withJsonOutput(async () => {
if (PLAIN[key]) await (await PLAIN[key]![0]()).run(args);
else await (await COMMANDS[key]![0]()).run({ loaded: p.loaded, args });
})).catch(e => ({ out: '', err: String(e), exit: 1 }));
uninstallStores();
process.chdir(saved.cwd);
const out = r.out.trim();
if (!out) return;
let ok = true;
try { JSON.parse(out); } catch { ok = false; }
if (!ok) throw new Error(`storebound ${key} ${argv.join(' ')}: stdout is not one JSON document:\n${out.slice(0, 600)}`);
printed.push(key);
}, 20_000);
}
test('the report\'s commands printed JSON', () => {
for (const k of MUST) expect(printed).toContain(k);
});
});
describe('--json through the real CLI (index.ts wraps every command)', () => {
test('prices ppp --offline --json --dry: stdout parses, the log lines are on stderr', () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-json-cli-'));
writeFileSync(join(dir, 'storebound.config.ts'), `export default ${JSON.stringify(CFG)};\n`);
const r = Bun.spawnSync([process.execPath, join(CLI, 'index.ts'), 'prices', 'ppp', '--offline', '--dry', '--json'],
{ cwd: dir, env: { PATH: process.env.PATH ?? '', HOME: dir, STOREBOUND_NO_NETWORK: '1' }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
const out = r.stdout.toString(), err = r.stderr.toString();
expect(r.exitCode).toBe(0);
const j = JSON.parse(out) as { rows: unknown[] };
expect(j.rows.length).toBeGreaterThan(50);
expect(err).toContain('[ppp]');
});
});

View file

@ -436,7 +436,9 @@ describe('play products: price and region changes of existing products are the o
process.env.STOREBOUND_SERVE = '1';
expect((await runWith(over, ['--yes'])).writes.filter(w => w.includes('no_ads'))).toEqual([]);
process.env.STOREBOUND_OWNER_CONFIRMED = '1';
expect((await runWith(over)).writes.filter(w => w.includes('no_ads'))).toEqual(['PATCH /onetimeproducts/com.example.game.no_ads?allowMissing=true&updateMask=listings,purchaseOptions']);
// serve adds --allow-price-changes to the owner action's real run (serve.ts OWNER_REAL_ONLY); without it nothing changes
expect((await runWith(over)).writes.filter(w => w.includes('no_ads'))).toEqual([]);
expect((await runWith(over, ['--allow-price-changes'])).writes.filter(w => w.includes('no_ads'))).toEqual(['PATCH /onetimeproducts/com.example.game.no_ads?allowMissing=true&updateMask=listings,purchaseOptions']);
});
test('nothing to change: no write for the existing product', async () => {

View file

@ -6,8 +6,8 @@ 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,
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';
@ -23,20 +23,32 @@ const ds: PppDataset = {
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('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('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);
@ -67,6 +79,13 @@ describe('snapping', () => {
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();
@ -74,47 +93,84 @@ describe('snapping', () => {
});
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));
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: 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(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 === 'FR')!.why).toMatch(/no PPP factor/);
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('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);
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 Apple: snaps to the mocked price points and takes the store currency', () => {
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] }],
['TR', { currency: 'TRY', points: [49.99, 59.99, 64.99, 69.99, 79.99] }],
['AF', { currency: 'USD', points: [0.99, 1.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, 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']);
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, 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 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 === 'DE')!.territory).toBe('DEU');
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');
});
});
@ -206,20 +262,23 @@ describe('command', () => {
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 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; globalThis.fetch = fetchOrig; }
} 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', 'online', 'rows', 'skipped', 'diff', 'out', 'written']);
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);
@ -231,14 +290,15 @@ describe('command', () => {
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.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(/floor/);
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('{')));

View file

@ -2,7 +2,8 @@
// adds to write steps, confirms creates, first prices and other updates, never a price change. Without
// --allow-price-changes the command writes everything else, names each held change and exits 3 (declined), so a flow
// stops there. Runs against the stateful store doubles (tests/helpers/fake-stores.ts).
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test';
import * as rt from '../cli/lib/rt';
import type { ShipConfig } from '../cli/lib/config';
import { DECLINED_EXIT } from '../cli/lib/ctx';
import { planStep, toFlow, validateFlows } from '../cli/lib/flow';
@ -92,3 +93,82 @@ describe('flows never pass --allow-price-changes', () => {
expect(priceDiff(['A', 'B', 'C', 'D', 'E', 'F'].map(region => ({ region, from: '1', to: '2' })))).toBe('A 1 -> 2, B 1 -> 2, C 1 -> 2, D 1 -> 2, +2 more');
});
});
// A terminal "y" covers creates and other updates only (STAR DASH, 2026-10-14: one yes changed the prices of live
// products while new ones were created). Price changes of existing products need --allow-price-changes, then their
// own question listing each old -> new, asked in a terminal even with --yes.
describe('terminal runs: one yes never changes a live price', () => {
let answers: (string | null)[] = [];
let spy: { mockRestore: () => void } | undefined;
beforeEach(() => {
process.env.STOREBOUND_ASSUME_TTY = '1';
answers = [];
spy = spyOn(rt, 'readLine').mockImplementation(async () => answers.shift() ?? null);
});
afterEach(() => { delete process.env.STOREBOUND_ASSUME_TTY; spy?.mockRestore(); });
const PRO = 'com.example.notes.pro', COINS = 'com.example.notes.coins';
const cfg = (usd: number, extra = false): ShipConfig => ({ ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], products: [
{ id: PRO, type: 'non_consumable', usd, listings: { en: { title: 'Pro', description: 'Every feature.' } } },
...(extra ? [{ id: COINS, type: 'consumable' as const, usd: 0.99, listings: { en: { title: 'Coins', description: 'A few coins.' } } }] : []),
] });
type Opt = { regionalPricingAndAvailabilityConfigs: { regionCode: string; price: unknown }[] };
const usPrice = () => JSON.stringify((s.play.products.get(PRO)!.purchaseOptions as Opt[])[0]!.regionalPricingAndAvailabilityConfigs.find(r => r.regionCode === 'US')!.price);
test('play products: the new product is created on "y", the live price is held (exit 3), no price question without the flag', async () => {
await ok(() => playProducts(project(cfg(2.99)).ctx(['--yes'])));
const before = usPrice();
answers = ['y', 'y', 'y'];
const r = await capture(() => playProducts(project(cfg(4.99, true)).ctx([])));
expect(r.exit).toBe(DECLINED_EXIT);
expect(r.err).toContain(`price change on ${PRO} (`); expect(r.err).toContain("US USD 2.99 -> USD 4.99");
expect(usPrice()).toBe(before);
expect(s.play.products.has(COINS)).toBe(true);
expect(r.out).not.toContain('Change prices');
});
test('play products --allow-price-changes --yes in a terminal: its own question lists old -> new; "n" holds, "y" applies', async () => {
await ok(() => playProducts(project(cfg(2.99)).ctx(['--yes'])));
const before = usPrice();
const p = project(cfg(4.99));
answers = ['n'];
const r = await capture(() => playProducts(p.ctx(['--yes', '--allow-price-changes'])));
expect(r.out).toContain('Change prices / regions of 1 existing product(s)');
expect(r.out).toContain(`${PRO} US USD 2.99 -> USD 4.99`);
expect(r.exit).toBe(DECLINED_EXIT);
expect(usPrice()).toBe(before);
answers = ['y'];
const r2 = await capture(() => playProducts(p.ctx(['--yes', '--allow-price-changes'])));
expect([r2.exit, r2.err.includes('needs the owner')]).toEqual([null, false]);
expect(usPrice()).not.toBe(before);
});
test('prices import in a terminal: "y" writes nothing on a live product without the flag', async () => {
const p = project(cfg(2.99), { 'store/prices.csv': `product,store,region,currency,price\n${PRO},play,DE,EUR,3.49\n` });
await ok(() => playProducts(p.ctx(['--yes'])));
const k = s.calls.filter(c => c.method === 'PATCH').length;
answers = ['y', 'y'];
const r = await capture(() => prices(p.ctx(['import', '--store', 'play'])));
expect(r.exit).toBe(DECLINED_EXIT);
expect(r.err).toContain(`price change on ${PRO} play (DE EUR 2.99 -> EUR 3.49) needs the owner`);
expect(s.calls.filter(c => c.method === 'PATCH').length).toBe(k);
});
test('play subscriptions in a terminal: "y" writes the listing, the base plan price stays', async () => {
await ok(() => playSubscriptions(project(sub(4.99)).ctx(['--yes'])));
const price = () => JSON.stringify((s.play.subs.get(MONTHLY)!.basePlans as { regionalConfigs: { regionCode: string; price: unknown }[] }[])[0]!.regionalConfigs.find(r => r.regionCode === 'US')!.price);
const before = price();
answers = ['y', 'y'];
const r = await capture(() => playSubscriptions(project(sub(9.99, 'Monthly Pro')).ctx([])));
expect(r.exit).toBe(DECLINED_EXIT);
expect(price()).toBe(before);
expect((s.play.subs.get(MONTHLY)!.listings as { title: string }[])[0]!.title).toBe('Monthly Pro');
});
test('dry runs show the held price changes in their own section', async () => {
await ok(() => playProducts(project(cfg(2.99)).ctx(['--yes'])));
const r = await capture(() => playProducts(project(cfg(4.99, true)).ctx(['--dry'])));
expect(r.out).toContain('price changes on existing products (held: only with --allow-price-changes):');
expect(r.out).toContain(`${PRO} US USD 2.99 -> USD 4.99`);
});
});