// 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, 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'; 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): Promise { 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'); }); }); // 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`); }); });