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

@ -397,7 +397,7 @@ describe.skipIf(!browser)('watchdog: an unknown page after a click never means a
expect(said).toMatch(/still waiting for the page after "Next", \d+ s/);
expect(said).toMatch(/the page after "Next" is not a page storebound knows .*Buttons in the dialog: .*"Done"/);
expect(said).toContain(`Debug saved to ${dir}`);
expect(said).toContain('do this part yourself');
expect(said).toContain('this part is left to the calling agent with its browser tool (`storebound console plan apple-app-privacy`');
expect(said).toContain('it stops here');
const [sub] = readdirSync(dir);
const files = readdirSync(join(dir, sub!)).sort();

View file

@ -66,4 +66,36 @@ describe("first rule: do it, don't ask", () => {
'press Create app yourself', 'Granting them is the owner\'s (a permission grant)'];
for (const o of ok) expect(HANDOFF.some(re => re.test(o))).toBe(false);
});
// The CLI's own output: when storebound's scripted part cannot go on, what is left goes to the calling agent (its
// browser tool with `console plan` / `console verify`, or an edit of the config), not "do it yourself" to the owner.
// Sign-in, the final Submit / Create / Publish and agreements stay the owner's.
const CLI_HANDOFF: RegExp[] = [
/\b(open|fill|answer|check|do|merge|add|set)\b[^.\n]{0,40}\b(yourself|by hand)\b/i,
/\bdo (this|that) part\b/i,
/\bto check yourself\b/i,
];
const cliFiles = (dir: string): string[] => readdirSync(join(root, dir), { withFileTypes: true }).flatMap(e =>
e.isDirectory() ? (e.name === 'data' ? [] : cliFiles(`${dir}/${e.name}`)) : e.name.endsWith('.ts') ? [`${dir}/${e.name}`] : []);
test('CLI output never hands agent work to the owner', () => {
const hits: string[] = [];
for (const f of cliFiles('cli')) {
read(f).split('\n').forEach((line, i) => {
const t = line.trim();
if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return; // comments are for the reader of the code
for (const re of CLI_HANDOFF) if (re.test(line)) hits.push(`${f}:${i + 1}: ${t.slice(0, 140)}`);
});
}
expect(hits).toEqual([]);
});
test('the CLI patterns catch the old console fill lines and keep the owner-only ones', () => {
for (const b of ['Open it yourself; storebound keeps watching', 'fill them yourself', 'do this part yourself; storebound keeps watching',
'3 answer(s) to check yourself.', 'not mapped (do these by hand)'])
expect([b, CLI_HANDOFF.some(re => re.test(b))]).toEqual([b, true]);
for (const o of ['review every field and press Submit yourself', 'sign in to App Store Connect yourself (Apple ID, password, two-factor code)',
'Read and tick them yourself, then press Create app.', 'storebound console plan <form> [--json] [--lookup] (then drive the page yourself'])
expect([o, CLI_HANDOFF.some(re => re.test(o))]).toEqual([o, false]);
});
});

View file

@ -420,7 +420,7 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri
test('prices import (Play): changed regions written once, then the diff is empty', async () => {
const p = project({ ...CONFIG, products: [CONFIG.products![0]!] }, { 'store/prices.csv': 'product,store,region,currency,price\ncom.example.notes.pro,play,DE,EUR,3.49\n' });
await capture(() => playProducts(p.ctx([])));
const r = await twice(() => prices(p.ctx(['import', '--store', 'play', '--yes'])));
const r = await twice(() => prices(p.ctx(['import', '--store', 'play', '--yes', '--allow-price-changes'])));
expect(r.first.some(x => x.startsWith('PATCH /androidpublisher/v3/applications/com.example.notes/onetimeproducts/'))).toBe(true);
expect(r.second).toEqual([]);
});

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 () => {

94
tests/price-gate.test.ts Normal file
View file

@ -0,0 +1,94 @@
// Price changes of existing products are the owner's call (lib/price-gate.ts): `--yes`, which `flow run --apply --yes`
// 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 type { ShipConfig } from '../cli/lib/config';
import { DECLINED_EXIT } from '../cli/lib/ctx';
import { planStep, toFlow, validateFlows } from '../cli/lib/flow';
import { priceDiff } from '../cli/lib/price-gate';
import { run as appleSubscriptions } from '../cli/apple/subscriptions';
import { run as playProducts } from '../cli/play/products';
import { run as playSubscriptions } from '../cli/play/subscriptions';
import { run as prices } from '../cli/prices';
import { ascEnv, BASE, capture, playEnv, project, restore } from './helpers/store-mock';
import { installStores, type Stores, uninstallStores } from './helpers/fake-stores';
import { upsertTiming } from '../cli/lib/upsert';
let s: Stores;
beforeAll(() => { ascEnv(); playEnv(); });
afterAll(restore);
beforeEach(() => { upsertTiming.waitMs = 0; s = installStores(); });
afterEach(() => { uninstallStores(); });
const MONTHLY = 'com.example.notes.monthly';
const sub = (usd: number, title = 'Monthly'): ShipConfig => ({ ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], subscriptions: [{ name: 'Pro', listings: { en: { name: 'Pro' } }, subscriptions: [
{ id: MONTHLY, period: 'P1M', level: 1, usd, listings: { en: { title, description: 'Pro every month.', benefits: ['Sync'] } } },
] } as never] });
async function ok(run: () => Promise<void>): Promise<void> {
const r = await capture(run);
if (r.exit) throw new Error(`exit ${r.exit}:\n${r.out}\n${r.err}`);
}
describe('flow-style runs (--yes, no terminal) never change a live price', () => {
test('play subscriptions: the listing change goes, the base plan price stays, exit 3; --allow-price-changes applies it', async () => {
await ok(() => playSubscriptions(project(sub(4.99)).ctx(['--yes'])));
const usPrice = () => JSON.stringify((s.play.subs.get(MONTHLY)!.basePlans as { regionalConfigs: { regionCode: string; price: unknown }[] }[])[0]!.regionalConfigs.find(r => r.regionCode === 'US')!.price);
const before = usPrice();
const p = project(sub(9.99, 'Monthly Pro'));
const r = await capture(() => playSubscriptions(p.ctx(['--yes'])));
expect(r.exit).toBe(DECLINED_EXIT);
expect(r.err).toContain(`price change on ${MONTHLY} (`);
expect(r.err).toContain('needs the owner: re-run with --allow-price-changes');
expect(usPrice()).toBe(before);
expect((s.play.subs.get(MONTHLY)!.listings as { title: string }[])[0]!.title).toBe('Monthly Pro');
const r2 = await capture(() => playSubscriptions(p.ctx(['--yes', '--allow-price-changes'])));
expect([r2.exit, r2.err.includes('needs the owner')]).toEqual([null, false]);
expect(usPrice()).not.toBe(before);
});
test('apple subscriptions: first prices go with --yes; a changed price is held (exit 3), then applied with --allow-price-changes', async () => {
await ok(() => appleSubscriptions(project(sub(4.99)).ctx(['--yes'])));
const n = s.asc.all('subscriptionPrices').length;
expect(n).toBeGreaterThan(0);
const p = project(sub(9.99));
const r = await capture(() => appleSubscriptions(p.ctx(['--yes'])));
expect(r.exit).toBe(DECLINED_EXIT);
expect(r.err).toContain(`price change on ${MONTHLY} (USA 4.99 -> 9.99`);
expect(s.asc.all('subscriptionPrices').length).toBe(n);
await ok(() => appleSubscriptions(p.ctx(['--yes', '--allow-price-changes'])));
expect(s.asc.all('subscriptionPrices').length).toBeGreaterThan(n);
});
test('prices import: an existing Play product\'s price is held with --yes (exit 3, nothing written), written with --allow-price-changes', async () => {
const cfg: ShipConfig = { ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }],
products: [{ id: 'com.example.notes.pro', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'Pro', description: 'Every feature.' } } }] };
const p = project(cfg, { 'store/prices.csv': 'product,store,region,currency,price\ncom.example.notes.pro,play,DE,EUR,3.49\n' });
await ok(() => playProducts(p.ctx([])));
const dry = await capture(() => prices(p.ctx(['import', '--store', 'play', '--dry'])));
expect(dry.out + dry.err).toContain('owner: price change of 1 existing product / store pair(s) (--allow-price-changes): com.example.notes.pro play');
const patches = () => s.calls.filter(c => c.method === 'PATCH').length;
const k = patches();
const r = await capture(() => prices(p.ctx(['import', '--store', 'play', '--yes'])));
expect(r.exit).toBe(DECLINED_EXIT);
expect(r.err).toContain('price change on com.example.notes.pro play (DE EUR 2.99 -> EUR 3.49) needs the owner: re-run with --allow-price-changes');
expect(patches()).toBe(k);
await ok(() => prices(p.ctx(['import', '--store', 'play', '--yes', '--allow-price-changes'])));
expect(patches()).toBe(k + 1);
});
});
describe('flows never pass --allow-price-changes', () => {
test('refused in a step; flow run --apply --yes adds only --yes', () => {
const f = toFlow('p', 'p.yml', { steps: [{ id: 'a', run: 'play products --allow-price-changes' }] }).flow!;
expect(validateFlows([f]).join('\n')).toContain('--allow-price-changes is not allowed in a flow');
const step = planStep({ id: 'b', run: 'play products' }, 'steps', 'apply', {}, { apple: true, play: true, exists: () => false }, {}, true);
expect(step.argv).toEqual(['play', 'products', '--yes']);
});
test('the held line names region, current and wanted price', () => {
expect(priceDiff([{ region: 'TR', from: 'TRY 129.99', to: 'TRY 99.99' }])).toBe('TR TRY 129.99 -> TRY 99.99');
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');
});
});