--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:
parent
bab3f63227
commit
357319a775
30 changed files with 865 additions and 209 deletions
|
|
@ -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`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue