price changes of existing products need --allow-price-changes (play products, play/apple subscriptions, prices import): --yes and flow run --apply --yes confirm creates, first prices and other updates only; without the flag the run writes the rest, names each held change and exits 3 (declined), dry runs mark them owner, flows refuse the flag; lib/price-gate.ts, tests/price-gate.test.ts. CLI wording: console fill leaves what it cannot open or fill to the calling agent's browser tool (console plan / verify) instead of 'yourself'; other 'by hand' output points at the config; do-it-dont-ask test scans cli/ output

This commit is contained in:
CRTkafa 2026-10-12 09:38:56 +08:00
parent b5b8f2da9d
commit e01a27d7b7
33 changed files with 414 additions and 117 deletions

View file

@ -353,13 +353,15 @@ describe('play products --dry: what would change on an existing product', () =>
const w = { ...want, listings: [{ ...want.listings[0]!, title: 'Remove Ads' }], regions: [want.regions[0]!, region('TR', 'TRY', 99.99)] };
expect(productChanges(CUR, w)).toEqual([
{ text: 'listings changed: en-US', owner: false }, { text: 'listings removed: tr-TR', owner: false },
{ text: 'prices in 1 region(s): TR TRY 129.99 -> TRY 99.99', owner: true }, { text: 'regions dropped: JP', owner: true }]);
{ text: 'prices in 1 region(s): TR TRY 129.99 -> TRY 99.99', owner: true, prices: [{ region: 'TR', from: 'TRY 129.99', to: 'TRY 99.99' }] },
{ text: 'regions dropped: JP', owner: true, prices: [{ region: 'JP', from: 'JPY 450.00', to: 'not sold' }] }]);
// activation is not a price change: --yes confirms it
const inactive = { ...CUR, purchaseOptions: [{ ...CUR.purchaseOptions![0]!, state: 'INACTIVE' }] };
expect(productChanges(inactive, want)).toEqual([{ text: 'purchase option INACTIVE -> ACTIVE', owner: true }]);
expect(productChanges(inactive, want)).toEqual([{ text: 'purchase option INACTIVE -> ACTIVE', owner: false }]);
});
});
describe('play products: price, region and activation changes of existing products ask first', () => {
describe('play products: price and region changes of existing products are the owner\'s (--allow-price-changes, never --yes)', () => {
// the existing product as listed (2 regions), and what convertRegionPrices answers for USD 2.99
const LISTED = { productId: 'com.example.game.no_ads',
listings: [{ languageCode: 'en-US', title: 'No Ads', description: 'Removes ads.' }],
@ -389,26 +391,42 @@ describe('play products: price, region and activation changes of existing produc
const out: string[] = [];
const [l, w, e] = [console.log, console.warn, console.error];
console.log = console.warn = console.error = (...a: unknown[]) => { out.push(a.join(' ')); };
try { await productsRun({ loaded, args: parseArgs(argv) }); } finally { [console.log, console.warn, console.error] = [l, w, e]; }
const before = process.exitCode;
process.exitCode = 0;
let exit = 0;
try { await productsRun({ loaded, args: parseArgs(argv) }); } finally { [console.log, console.warn, console.error] = [l, w, e]; exit = Number(process.exitCode ?? 0); process.exitCode = before; }
const writes = calls.filter(c => c.method !== 'GET' && !c.url.endsWith(':convertRegionPrices')).map(c => `${c.method} ${c.url.replace(API, '').replace(/regionsVersion\.version=[^&]+&?/, '')}`);
return { writes, out: out.join('\n') };
return { writes, out: out.join('\n'), exit };
}
test('without a terminal: an existing product\'s price change is skipped, its listing change and new products still go', async () => {
const r = await runWith({ overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }], listings: { en: { title: 'Remove Ads', description: 'Removes ads.' } } });
expect(r.out).toContain('update listings changed: en-US; prices in 1 region(s): TR TRY 129.99 -> TRY 99.99');
expect(r.writes).toEqual([
'PATCH /onetimeproducts/com.example.game.no_ads?updateMask=listings',
'PATCH /onetimeproducts/com.example.game.gems_new?allowMissing=true&updateMask=listings,purchaseOptions',
'POST /oneTimeProducts/com.example.game.gems_new/purchaseOptions:batchUpdateStates',
]);
expect(r.out).toContain('skipped: confirm in a terminal');
test('flow-style run (--yes, no terminal): the listing change and new products go, the price stays, exit 3 naming the change', async () => {
for (const argv of [[], ['--yes']]) {
const r = await runWith({ overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }], listings: { en: { title: 'Remove Ads', description: 'Removes ads.' } } }, argv);
expect(r.out).toContain('update listings changed: en-US; prices in 1 region(s): TR TRY 129.99 -> TRY 99.99');
expect(r.writes).toEqual([
'PATCH /onetimeproducts/com.example.game.no_ads?updateMask=listings',
'PATCH /onetimeproducts/com.example.game.gems_new?allowMissing=true&updateMask=listings,purchaseOptions',
'POST /oneTimeProducts/com.example.game.gems_new/purchaseOptions:batchUpdateStates',
]);
expect(r.out).toContain('price change on com.example.game.no_ads (TR TRY 129.99 -> TRY 99.99) needs the owner: re-run with --allow-price-changes');
expect(r.out).toContain('declined: 1 price change(s) of existing products need the owner');
expect(r.exit).toBe(3);
}
});
test('a price-only change is not written at all without a confirmation; --yes confirms in a terminal-less run', async () => {
test('a price-only change: never written with --yes; --allow-price-changes writes it (exit 0)', async () => {
const over = { overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }] };
expect((await runWith(over)).writes.filter(w => w.includes('no_ads'))).toEqual([]);
expect((await runWith(over, ['--yes'])).writes.filter(w => w.includes('no_ads'))).toEqual(['PATCH /onetimeproducts/com.example.game.no_ads?allowMissing=true&updateMask=listings,purchaseOptions']);
expect((await runWith(over, ['--yes'])).writes.filter(w => w.includes('no_ads'))).toEqual([]);
const ok = await runWith(over, ['--yes', '--allow-price-changes']);
expect(ok.writes.filter(w => w.includes('no_ads'))).toEqual(['PATCH /onetimeproducts/com.example.game.no_ads?allowMissing=true&updateMask=listings,purchaseOptions']);
expect([ok.exit, ok.out.includes('needs the owner')]).toEqual([0, false]);
});
test('the dry run marks the price row "owner"', async () => {
const r = await runWith({ overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }] }, ['--dry']);
expect(r.out).toMatch(/com\.example\.game\.no_ads\s+update \(owner: price change, --allow-price-changes\)/);
expect(r.writes).toEqual([]);
});
test('under serve: refused even with --yes; a verified desktop owner confirmation lets it through', async () => {