--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

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`);
});
});