174 lines
11 KiB
TypeScript
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`);
|
|
});
|
|
});
|