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:
parent
b5b8f2da9d
commit
e01a27d7b7
33 changed files with 414 additions and 117 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
94
tests/price-gate.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue