137 lines
8.4 KiB
TypeScript
137 lines
8.4 KiB
TypeScript
// 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');
|
|
});
|
|
});
|