// Guardrails for lessons from real use (docs/pitfalls.md, skills/store-pitfalls): the store's product list lagging // right after a create, and dry runs that print a count instead of a plan. Recorded App Store Connect / Google Play // answers; every id is a placeholder. import { afterEach, describe, expect, test } from 'bun:test'; import { Glob } from 'bun'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { countOnlyDryExits, dryPlan, isPlanLine } from '../cli/lib/dry'; import { LAG_HINT, missingFromStore } from '../cli/lib/store-lag'; import { run as iapReview } from '../cli/apple/iap-review'; import { run as iap } from '../cli/apple/iap'; import { run as playProducts } from '../cli/play/products'; import type { Product } from '../cli/lib/config'; import { ascEnv, BASE, capture, mockFetch, notFound, playEnv, project, restore } from './helpers/store-mock'; const env = { ...process.env }; afterEach(() => { restore(); if (env.STOREBOUND_LIST_SETTLE_MS === undefined) delete process.env.STOREBOUND_LIST_SETTLE_MS; }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const page = (data: unknown[]): any => ({ data, links: { self: 'https://api.appstoreconnect.apple.com/v1/x' }, meta: { paging: { total: data.length, limit: 200 } } }); const product = (id: string): Product => ({ id, type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No Ads', description: 'Removes ads' } } }); // a PNG header of an App Store screenshot size (the size check reads IHDR only) const png = (w = 1290, h = 2796) => { const b = Buffer.alloc(33); b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); b.writeUInt32BE(13, 8); b.write('IHDR', 12); b.writeUInt32BE(w, 16); b.writeUInt32BE(h, 20); b[24] = 8; b[25] = 2; return b; }; const PRO = 'com.example.notes.pro', GEMS = 'com.example.notes.gems'; const CONFIG = { ...BASE, products: [product(PRO), { ...product(GEMS), type: 'consumable' as const }] }; describe('store list lag after create', () => { test('missingFromStore keeps config order and ignores duplicates', () => { expect(missingFromStore(['a', 'b', 'c', 'b'], ['c'])).toEqual(['a', 'b']); expect(missingFromStore(['a'], new Set(['a']))).toEqual([]); }); test('apple iap-review --dry: a product missing from /inAppPurchasesV2 is named with the lag hint and exits 1', async () => { ascEnv(); const p = project(CONFIG, { [`store/iap-review/${PRO}.png`]: png(), [`store/iap-review/${GEMS}.png`]: png() }); // as answered a few seconds after `apple iap` created both: only the older product is listed const calls = mockFetch([ { match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?limit=200/, answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, state: 'READY_TO_SUBMIT' } }]) }, { match: /^\/v2\/inAppPurchases\/i-pro\/appStoreReviewScreenshot/, answer: notFound() }, ]); const r = await capture(() => iapReview(p.ctx(['--dry']))); expect(r.err).toContain(`1 of 2 products not in the store's list: ${GEMS} (${LAG_HINT})`); expect(r.out).toMatch(new RegExp(`${GEMS.replace(/\./g, '\\.')}\\s+skip\\s+not in the store's list yet`)); expect(r.out).toMatch(new RegExp(`${PRO.replace(/\./g, '\\.')}\\s+upload\\s+store/iap-review/${PRO.replace(/\./g, '\\.')}\\.png \\(none in App Store Connect\\)`)); expect(r.exit).toBe(1); expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0); }); test('apple iap-review --dry: every product listed exits 0', async () => { ascEnv(); const p = project({ ...CONFIG, products: [product(PRO)] }, { [`store/iap-review/${PRO}.png`]: png() }); mockFetch([ { match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?limit=200/, answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, state: 'READY_TO_SUBMIT' } }]) }, { match: /^\/v2\/inAppPurchases\/i-pro\/appStoreReviewScreenshot/, answer: notFound() }, ]); const r = await capture(() => iapReview(p.ctx(['--dry']))); expect(r.err).not.toContain(LAG_HINT); expect(r.exit).toBeNull(); }); test('apple iap --dry prints a plan row per product (create with type, locales and prices; update with what is missing)', async () => { ascEnv(); const p = project({ ...CONFIG, products: [product(PRO), { ...product(GEMS), type: 'consumable', overrides: [{ region: 'TR', currency: 'TRY', price: 49.99 }] }] }); const calls = mockFetch([ { match: /^\/v1\/territories/, answer: page([{ type: 'territories', id: 'USA' }, { type: 'territories', id: 'TUR' }]) }, { match: new RegExp(`^/v1/apps/6400000101/inAppPurchasesV2\\?filter\\[productId\\]=${PRO}`), answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, state: 'MISSING_METADATA' } }]) }, { match: new RegExp(`^/v1/apps/6400000101/inAppPurchasesV2\\?filter\\[productId\\]=${GEMS}`), answer: page([]) }, { match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseLocalizations/, answer: page([]) }, { match: /^\/v2\/inAppPurchases\/i-pro\/iapPriceSchedule/, answer: notFound() }, { match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseAvailability/, answer: { data: { type: 'inAppPurchaseAvailabilities', id: 'av' } } }, ]); const r = await capture(() => iap(p.ctx(['--dry']))); expect(r.out).toContain('dry run: apple iap: 2 change(s); nothing written'); expect(r.out).toMatch(/com\.example\.notes\.pro\s+update\s+localizations en-US; prices USA 2\.99 \(state MISSING_METADATA\)/); expect(r.out).toMatch(/com\.example\.notes\.gems\s+create\s+CONSUMABLE; localizations en-US; prices USA 2\.99, TUR 49\.99; 2 territories/); expect(r.out).toContain(`not on App Store Connect: ${GEMS}`); expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0); }); test('apple iap: a product created this run but missing from the list read after it is named', async () => { ascEnv(); process.env.STOREBOUND_LIST_SETTLE_MS = '0'; const p = project({ ...CONFIG, products: [product(GEMS)] }); const calls = mockFetch([ { match: /^\/v1\/territories/, answer: page([{ type: 'territories', id: 'USA' }]) }, { match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?filter/, answer: page([]) }, { method: 'POST', match: /^\/v2\/inAppPurchases$/, answer: { data: { type: 'inAppPurchases', id: 'i-gems', attributes: { productId: GEMS } } } }, { match: /^\/v2\/inAppPurchases\/i-gems\/inAppPurchaseLocalizations/, answer: page([]) }, { method: 'POST', match: /^\/v1\/inAppPurchaseLocalizations$/, answer: { data: { type: 'inAppPurchaseLocalizations', id: 'l1' } } }, { match: /^\/v2\/inAppPurchases\/i-gems\/iapPriceSchedule/, answer: notFound() }, { match: /^\/v2\/inAppPurchases\/i-gems\/pricePoints\?filter\[territory\]=USA/, answer: page([{ type: 'inAppPurchasePricePoints', id: 'pp-usa-299', attributes: { customerPrice: '2.99' } }]) }, { method: 'POST', match: /^\/v1\/inAppPurchasePriceSchedules$/, answer: { data: { type: 'inAppPurchasePriceSchedules', id: 'ps' } } }, { match: /^\/v2\/inAppPurchases\/i-gems\/inAppPurchaseAvailability/, answer: notFound() }, { method: 'POST', match: /^\/v1\/inAppPurchaseAvailabilities$/, answer: { data: { type: 'inAppPurchaseAvailabilities', id: 'av' } } }, // the full list read right after the create does not have it yet { match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?limit=200/, answer: page([]) }, ]); const r = await capture(() => iap(p.ctx([]))); expect(calls.filter(c => c.method === 'POST').map(c => c.path)).toEqual(['/v2/inAppPurchases', '/v1/inAppPurchaseLocalizations', '/v1/inAppPurchasePriceSchedules', '/v1/inAppPurchaseAvailabilities']); expect(r.err).toContain(`1 of 1 new products not in the store's list: ${GEMS} (${LAG_HINT})`); expect(r.out).toContain('wait a minute before apple iap-review'); expect(r.exit).toBeNull(); }); test('play products: a product created this run but missing from the list read after it is named; --dry shows the plan table', async () => { playEnv(); const p = project({ ...CONFIG, products: [product(GEMS)] }); const API = '/androidpublisher/v3/applications/com.example.notes'; const conv = { convertedRegionPrices: { US: { regionCode: 'US', price: { currencyCode: 'USD', units: '2', nanos: 990000000 } } }, convertedOtherRegionsPrice: { usdPrice: { currencyCode: 'USD', units: '2', nanos: 990000000 }, eurPrice: { currencyCode: 'EUR', units: '2', nanos: 790000000 } }, regionVersion: { version: '2026/01' } }; const routes = () => mockFetch([ { match: new RegExp(`^${API}/oneTimeProducts\\?`), answer: { oneTimeProducts: [] } }, { method: 'POST', match: new RegExp(`^${API}/pricing:convertRegionPrices$`), answer: conv }, { method: 'PATCH', match: new RegExp(`^${API}/onetimeproducts/`), answer: {} }, { method: 'POST', match: /purchaseOptions:batchUpdateStates$/, answer: {} }, ]); routes(); let r = await capture(() => playProducts(p.ctx(['--dry']))); expect(r.out).toMatch(/dry run: play products: 1 change\(s\); nothing written\n.*\n.*\ncom\.example\.notes\.gems\s+create\s+1 regions, 1 listings, USD 2\.99/); expect(r.out).toContain(`not on Google Play: ${GEMS}`); const calls = routes(); r = await capture(() => playProducts(p.ctx([]))); expect(calls.filter(c => c.method === 'PATCH')).toHaveLength(1); expect(r.err).toContain(`1 of 1 new products not in the store's list: ${GEMS} (${LAG_HINT})`); }); }); describe('dry runs show a plan, not a count', () => { test('dryPlan: title with counts, kept rows left out of the table', () => { expect(dryPlan('apple iap', [{ item: 'a', action: 'keep' }])).toBe('dry run: apple iap: nothing to change, 1 unchanged; nothing written'); expect(dryPlan('play products', [{ item: 'com.example.a', action: 'create', detail: '174 regions' }, { item: 'b', action: 'keep' }]).split('\n')).toEqual([ 'dry run: play products: 1 change(s), 1 unchanged; nothing written', 'item action detail', '------------- ------ -----------', 'com.example.a create 174 regions', ]); }); test('the scanner flags a --dry exit after a count-only line and accepts a plan', () => { const countOnly = ['export async function run(ctx: Ctx): Promise {', ' const listing = read();', " log('apple', `listing: ${Object.keys(listing).length} locales (${Object.keys(listing).join(', ')})`);", " if (a.has('dry')) return;", ' await write();', '}'].join('\n'); expect(countOnlyDryExits(countOnly)).toEqual([4]); const counted = ['export async function run(): Promise {', " if (dry) { done(TAG, `dry run: ${reqs.length} change(s)`); return; }", '}'].join('\n'); expect(countOnlyDryExits(counted)).toEqual([2]); const planned = ['export async function run(): Promise {', " console.log(dryPlan('apple listing', rows));", " if (dry) return;", '}'].join('\n'); expect(countOnlyDryExits(planned)).toEqual([]); expect(isPlanLine(" log('apple', `${tag}: would upload ${file}`);")).toBe(true); expect(isPlanLine(" log(TAG, 'would POST /v1/analyticsReportRequests {accessType=ONGOING} (dry)');")).toBe(true); expect(isPlanLine(" done(TAG, 'dry run: nothing changed');")).toBe(false); }); // TODO: remove each entry once its branch lands (the test fails on a stale entry). const ALLOW: Record = { // the dry branch returns the .gitignore line it would add (the plan itself); the callers print it 'cli/lib/git-ignore.ts': 'returns the planned .gitignore line', }; test('no write command in cli/** leaves its --dry branch after printing only a count', () => { const root = join(import.meta.dir, '..'); const flagged: string[] = []; for (const f of [...new Glob('cli/**/*.ts').scanSync(root)].sort()) { const lines = countOnlyDryExits(readFileSync(join(root, f), 'utf8')); if (lines.length) flagged.push(`${f}:${lines.join(',')}`); } const files = flagged.map(x => x.split(':')[0]!); expect(flagged.filter(x => !ALLOW[x.split(':')[0]!])).toEqual([]); expect(Object.keys(ALLOW).filter(f => !files.includes(f))).toEqual([]); }); });