// Real use 2026-10-23: (1) `play products --yes` died at a 649.99 USD product: Play refused the converted KR price // ("Price for KR must be between ₩70 and ₩600,000, found ₩950,000") after earlier products were written; (2) `apple iap // --allow-price-changes --yes` died at an approved IAP: 409 ENTITY_ERROR.ATTRIBUTE.INVALID.UNMODIFIABLE "Cannot edit // InAppPurchaseLocalization when it is in ACTIVE state". Both against the stateful fakes with those answers. import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { run as appleIap } from '../cli/apple/iap'; import type { ShipConfig } from '../cli/lib/config'; import { applyLimits, parseLimitError, parseLocalAmount } from '../cli/lib/play-limits'; import { isLockedLocState, isUnmodifiableError } from '../cli/lib/loc-lock'; import { run as playProducts } from '../cli/play/products'; import { upsertTiming } from '../cli/lib/upsert'; import { ascEnv, BASE, capture, playEnv, project, restore } from './helpers/store-mock'; import { installStores, type Stores, uninstallStores } from './helpers/fake-stores'; let s: Stores; beforeAll(() => { ascEnv(); playEnv(); }); afterAll(restore); beforeEach(() => { upsertTiming.waitMs = 0; s = installStores(); }); afterEach(uninstallStores); const L = { en: { title: 'Pack', description: 'A pack.' } }; const BIG = 'com.example.notes.credits_big'; const SMALL = 'com.example.notes.credits_small'; const cfg = (...ids: [string, number][]): ShipConfig => ({ ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], products: ids.map(([id, usd]) => ({ id, type: 'consumable' as const, usd, listings: L })) }); const region = (id: string, code: string) => ((s.play.products.get(id)?.purchaseOptions as { regionalPricingAndAvailabilityConfigs: { regionCode: string; price: { units: string } }[] }[] | undefined)?.[0]?.regionalPricingAndAvailabilityConfigs ?? []).find(r => r.regionCode === code); describe('play price limits (pure)', () => { test('Play\'s 400 text is parsed, amounts with symbols and thousands separators too', () => { expect(parseLimitError('PATCH /x -> 400 Price for KR must be between ₩70 and ₩600,000, found ₩950,000')).toEqual({ region: 'KR', min: 70, max: 600000, found: 950000 }); expect(parseLimitError('Price for DE must be between €0.5 and €500.00, found €649.99.')).toEqual({ region: 'DE', min: 0.5, max: 500, found: 649.99 }); expect(parseLimitError('some other 400')).toBeNull(); expect(parseLocalAmount('₩1,234,567')).toBe(1234567); }); test('applyLimits clamps to max / min, or leaves the region out', () => { const m = (currencyCode: string, units: string) => ({ currencyCode, units }); const rows = [{ regionCode: 'KR', price: m('KRW', '950000') }, { regionCode: 'US', price: m('USD', '5') }]; const lim = { KR: { currency: 'KRW', min: 70, max: 600000 } }; const c = applyLimits(rows, lim); expect(c.regions[0]!.price.units).toBe('600000'); expect(c.clamps).toEqual([{ region: 'KR', currency: 'KRW', from: 950000, to: 600000, bound: 'max' }]); expect(applyLimits(rows, lim, true).regions.map(r => r.regionCode)).toEqual(['US']); expect(applyLimits([{ regionCode: 'KR', price: m('KRW', '10') }], lim).clamps[0]!.bound).toBe('min'); }); test('Apple\'s 409 and the locked states', () => { expect(isUnmodifiableError(new Error('PATCH /v1/x -> 409 ENTITY_ERROR.ATTRIBUTE.INVALID.UNMODIFIABLE Cannot edit InAppPurchaseLocalization when it is in ACTIVE state'))).toBe(true); expect(isLockedLocState('APPROVED') && isLockedLocState('ACTIVE') && !isLockedLocState('PREPARE_FOR_SUBMISSION')).toBe(true); }); }); describe('play products: regions outside Play\'s min / max (KR 649.99 USD)', () => { beforeEach(() => { s.play.convertKr = true; s.play.priceLimits.KR = { min: 70, max: 600000, symbol: '₩' }; }); test('dry run names the region, old -> new; the write clamps it (one PATCH, no 400) and the next run is unchanged', async () => { const p = project(cfg([SMALL, 0.99], [BIG, 649.99])); const d = await capture(() => playProducts(p.ctx(['--dry']))); expect(d.out).toContain(`price limits on ${BIG}`); expect(d.out).toMatch(/KR KRW 942486 -> 600000 \(above Play's max/); expect(d.out).not.toContain(`price limits on ${SMALL}`); const r = await capture(() => playProducts(p.ctx(['--yes']))); expect(r.exit).toBeNull(); expect(region(BIG, 'KR')?.price.units).toBe('600000'); expect(region(SMALL, 'KR')?.price.units).toBe('1436'); const again = await capture(() => playProducts(p.ctx(['--dry']))); expect(again.out).toContain('nothing to change, 2 unchanged'); }); test('--skip-out-of-range-regions leaves the region out', async () => { const p = project(cfg([BIG, 649.99])); const r = await capture(() => playProducts(p.ctx(['--yes', '--skip-out-of-range-regions']))); expect(r.exit).toBeNull(); expect(region(BIG, 'KR')).toBeUndefined(); expect(region(BIG, 'US')?.price.units).toBe('649'); }); test('a limit not in the table is learned from Play\'s 400, saved, and the PATCH sent again clamped', async () => { s.play.priceLimits.DE = { min: 0.5, max: 500, symbol: '€' }; const p = project(cfg([BIG, 649.99])); const r = await capture(() => playProducts(p.ctx(['--yes']))); expect(r.exit).toBeNull(); expect(r.err).toContain('Play limit for DE is 0.5-500'); expect(region(BIG, 'DE')?.price.units).toBe('500'); const file = join(p.root, '.storebound', 'play-price-limits.json'); expect(existsSync(file)).toBe(true); expect(JSON.parse(readFileSync(file, 'utf8')).DE.max).toBe(500); // the next dry run knows it without a 400 const d = await capture(() => playProducts(p.ctx(['--dry']))); expect(d.out).not.toContain('price limits on'); }); test('one product failing does not stop the others: results per product, exit 1 at the end', async () => { s.play.rejectProducts[BIG] = 'Some other refusal'; const p = project(cfg([BIG, 5], [SMALL, 0.99])); const r = await capture(() => playProducts(p.ctx(['--yes']))); expect(s.play.products.has(SMALL)).toBe(true); expect(s.play.products.has(BIG)).toBe(false); expect(r.exit).toBe(1); expect(r.err).toContain('1 of 2 product(s) failed'); expect(r.out + r.err).toContain(`result ${SMALL}: ok`); expect(r.out + r.err).toContain(`result ${BIG}: FAILED`); }); }); describe('apple iap: live (approved) localizations cannot be edited', () => { const two = (d1: string, d2: string): ShipConfig => ({ ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], products: [{ id: BIG, type: 'consumable', usd: 1.99, listings: { en: { title: 'Big', description: d1 } } }, { id: SMALL, type: 'consumable', usd: 0.99, listings: { en: { title: 'Small', description: d2 } } }] }); test('dry run marks it, the write skips it (no PATCH sent) and goes on; a failing IAP does not abort the rest', async () => { await capture(() => appleIap(project(two('one', 'one')).ctx([]))); const locs = s.asc.all('inAppPurchaseLocalizations'); expect(locs).toHaveLength(2); for (const l of locs) l.attributes.state = 'APPROVED'; const p = project(two('two', 'two')); const d = await capture(() => appleIap(p.ctx(['--dry']))); expect(d.out).toContain('not editable now'); expect(d.out).toContain('next review of the product'); const r = await capture(() => appleIap(p.ctx(['--allow-price-changes', '--yes']))); expect(r.exit).toBeNull(); expect(s.asc.patchRefused).toEqual([]); expect(r.err).toContain('2 localization edit(s) skipped'); // an unknown locked state: the 409 itself is skipped with a warning for (const l of locs) l.attributes.state = 'SOMETHING_NEW'; s.asc.unmodifiable.add(locs[0]!.id); const r2 = await capture(() => appleIap(project(two('three', 'three')).ctx(['--yes']))); expect(r2.exit).toBeNull(); expect(s.asc.patchRefused).toEqual([locs[0]!.id]); expect(r2.err).toContain('skipped'); expect(locs[1]!.attributes.description).toBe('three'); // a product that throws (description over Apple's limit) is reported at the end, the next one still runs const r3 = await capture(() => appleIap(project(two('x'.repeat(5000), 'four')).ctx(['--yes']))); expect(r3.exit).toBe(1); expect(r3.err).toContain('1 of 2 product(s) failed'); expect(locs[1]!.attributes.description).toBe('four'); }); });