play products: clamp regional prices to Play's min/max, per-product results; apple iap: skip live localizations, per-product results
This commit is contained in:
parent
882255a32f
commit
b0641c1fc8
12 changed files with 456 additions and 69 deletions
|
|
@ -143,6 +143,10 @@ export class FakeAsc {
|
|||
/** The attached builds carry com.apple.developer.game-center: submitting answers Apple's 409 (seen live) until the
|
||||
* app has a gameCenterDetail and the version a gameCenterAppVersion. */
|
||||
gameCenterBuild = false;
|
||||
/** localization ids whose PATCH answers 409 UNMODIFIABLE besides those in state APPROVED / ACTIVE */
|
||||
unmodifiable = new Set<string>();
|
||||
/** ids whose PATCH was refused with that 409 */
|
||||
patchRefused: string[] = [];
|
||||
|
||||
constructor(readonly appId = '6400000101') {
|
||||
this.put({ type: 'apps', id: appId, attributes: { bundleId: 'com.example.notes', name: 'Example Notes' }, rels: {} });
|
||||
|
|
@ -327,6 +331,12 @@ export class FakeAsc {
|
|||
// the age rating declaration is addressed by its app info's id
|
||||
if (self && type && self.type !== type) self = this.children(self.id, type)[0];
|
||||
if (!self) return json({ errors: [{ status: '404' }] }, 404);
|
||||
// seen live (2026-10-23): a live (approved) localization cannot be edited
|
||||
if (/^(inAppPurchase|subscription|subscriptionGroup)Localizations$/.test(self.type) && (['APPROVED', 'ACTIVE'].includes(String(self.attributes.state)) || this.unmodifiable.has(self.id))) {
|
||||
this.patchRefused.push(self.id);
|
||||
return json({ errors: [{ id: '00000000-0000-0000-0000-000000000000', status: '409', code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.UNMODIFIABLE', title: 'The provided entity includes an attribute with an invalid value',
|
||||
detail: `Cannot edit ${self.type.replace(/s$/, '').replace(/^./, c => c.toUpperCase())} when it is in ACTIVE state` }] }, 409);
|
||||
}
|
||||
const attrs = (body as { data: { attributes?: Record<string, unknown> } }).data.attributes ?? {};
|
||||
for (const [k, v] of Object.entries((body as { data: { relationships?: Record<string, { data: { id: string } | null }> } }).data.relationships ?? {})) {
|
||||
if (v.data) self.rels[k] = v.data.id; else delete self.rels[k];
|
||||
|
|
@ -405,6 +415,12 @@ export class FakePlay {
|
|||
/** How many validate / commit calls without changesNotSentForReview=true Play refuses with its 400 "Changes cannot
|
||||
* be sent for review automatically" (after a rejection, pending changes, managed publishing: pitfall 72). */
|
||||
refuseAutoReview = 0;
|
||||
/** convertRegionPrices also answers KR (KRW, 1450 per USD) */
|
||||
convertKr = false;
|
||||
/** per region the min / max Play accepts in a product's regional price (live: KR 70-600000 KRW); a PATCH outside answers 400 */
|
||||
priceLimits: Record<string, { min: number; max: number; symbol: string }> = {};
|
||||
/** product id -> 400 message for its PATCH */
|
||||
rejectProducts: Record<string, string> = {};
|
||||
/** validate calls that carried changesNotSentForReview (live Play answers them 400 "Unknown name") */
|
||||
validateWithNotSent = 0;
|
||||
|
||||
|
|
@ -454,7 +470,8 @@ export class FakePlay {
|
|||
if (method === 'POST' && p === '/pricing:convertRegionPrices') {
|
||||
const usd = body as { price: { units?: string; nanos?: number } };
|
||||
const price = { currencyCode: 'USD', units: usd.price.units ?? '0', nanos: usd.price.nanos ?? 0 };
|
||||
return json({ convertedRegionPrices: { US: { regionCode: 'US', price }, DE: { regionCode: 'DE', price: { ...price, currencyCode: 'EUR' } } },
|
||||
const kr = this.convertKr ? { KR: { regionCode: 'KR', price: { currencyCode: 'KRW', units: String(Math.round((Number(price.units) + price.nanos / 1e9) * 1450)), nanos: 0 } } } : {};
|
||||
return json({ convertedRegionPrices: { US: { regionCode: 'US', price }, DE: { regionCode: 'DE', price: { ...price, currencyCode: 'EUR' } }, ...kr },
|
||||
convertedOtherRegionsPrice: { usdPrice: price, eurPrice: { ...price, currencyCode: 'EUR' } }, regionVersion: { version: '2022/02' } });
|
||||
}
|
||||
if (method === 'GET' && p === '/oneTimeProducts') return json({ oneTimeProducts: [...this.products.values()] });
|
||||
|
|
@ -463,7 +480,14 @@ export class FakePlay {
|
|||
if (method === 'PATCH') {
|
||||
const cur = this.products.get(id);
|
||||
if (!cur && u.searchParams.get('allowMissing') !== 'true') return err(404, 'NOT_FOUND', 'no product');
|
||||
if (this.rejectProducts[id]) return err(400, 'INVALID_ARGUMENT', this.rejectProducts[id]!);
|
||||
const b = clone(body as Record<string, unknown>);
|
||||
for (const o of (b.purchaseOptions as { regionalPricingAndAvailabilityConfigs?: { regionCode: string; price: { units?: string; nanos?: number } }[] }[] | undefined) ?? [])
|
||||
for (const r of o.regionalPricingAndAvailabilityConfigs ?? []) {
|
||||
const l = this.priceLimits[r.regionCode];
|
||||
const v = Number(r.price.units ?? 0) + (r.price.nanos ?? 0) / 1e9;
|
||||
if (l && (v < l.min || v > l.max)) return err(400, 'INVALID_ARGUMENT', `Price for ${r.regionCode} must be between ${l.symbol}${l.min.toLocaleString('en-US')} and ${l.symbol}${l.max.toLocaleString('en-US')}, found ${l.symbol}${v.toLocaleString('en-US')}`);
|
||||
}
|
||||
const mask = (u.searchParams.get('updateMask') ?? '').split(',');
|
||||
const next = cur ? { ...cur, ...(mask.includes('listings') ? { listings: b.listings } : {}), ...(mask.includes('purchaseOptions') ? { purchaseOptions: b.purchaseOptions } : {}) } : b;
|
||||
for (const o of (next.purchaseOptions as { state?: string; purchaseOptionId: string }[] | undefined) ?? []) {
|
||||
|
|
|
|||
137
tests/price-limits-locked-loc.test.ts
Normal file
137
tests/price-limits-locked-loc.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue