storebound/tests/price-gate.test.ts

174 lines
11 KiB
TypeScript

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