// Pitfall 70 (seen live): the app's first in-app purchase (a starter pack) was not in the 1.0.0 review submission; // its items held only the appStoreVersion, the IAP stayed READY_TO_SUBMIT and nothing warned for two days in // WAITING_FOR_REVIEW. Against the stateful App Store Connect double: apple status warns (text and --json), validate's // rules raise a blocker before submitting, apple submit refuses to leave a never-approved first product out, and // iap-review never calls READY_TO_SUBMIT "complete". import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; import type { ShipConfig } from '../cli/lib/config'; import { firstProductGaps, planIapSubmission, productStateText, type SubmitState } from '../cli/lib/review-submit'; import { appleFirstProductWarnings } from '../cli/lib/portfolio'; import { type AppleSnapshot, appleRules } from '../cli/lib/validate-rules'; import { run as appleStatus } from '../cli/apple/status'; import { upsertTiming } from '../cli/lib/upsert'; import { ascEnv, BASE, capture, jsonOut, project, restore } from './helpers/store-mock'; import { installStores, type Stores, uninstallStores } from './helpers/fake-stores'; const APP = '6400000101'; const PACK = 'com.example.game.starter_pack'; const CONFIG: ShipConfig = { ...BASE, products: [{ id: PACK, type: 'non_consumable', usd: 1.99, listings: { en: { title: 'Starter pack', description: 'A head start.' } } }] }; let s: Stores; beforeAll(ascEnv); afterAll(restore); beforeEach(() => { upsertTiming.waitMs = 0; s = installStores(); }); afterEach(uninstallStores); /** 1.0.0 WAITING_FOR_REVIEW in rs-1; the starter pack READY_TO_SUBMIT (never approved). */ function waiting(o: { packInItems?: boolean; packState?: string } = {}): void { s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'WAITING_FOR_REVIEW', platform: 'IOS' }, rels: { app: APP } }); s.asc.put({ type: 'inAppPurchases', id: 'iap-pack', attributes: { productId: PACK, inAppPurchaseType: 'NON_CONSUMABLE', state: o.packState ?? 'READY_TO_SUBMIT' }, rels: { app: APP } }); s.asc.put({ type: 'inAppPurchaseVersions', id: 'iapv-pack', attributes: { version: 1, state: 'PREPARE_FOR_SUBMISSION' }, rels: { inAppPurchase: 'iap-pack' } }); s.asc.put({ type: 'reviewSubmissions', id: 'rs-1', attributes: { platform: 'IOS', state: 'WAITING_FOR_REVIEW' }, rels: { app: APP } }); s.asc.put({ type: 'reviewSubmissionItems', id: 'it-ver', attributes: { state: 'READY_FOR_REVIEW' }, rels: { reviewSubmission: 'rs-1', appStoreVersion: 'ver-1' } }); if (o.packInItems) s.asc.put({ type: 'reviewSubmissionItems', id: 'it-pack', attributes: { state: 'READY_FOR_REVIEW' }, rels: { reviewSubmission: 'rs-1', inAppPurchaseVersion: 'iapv-pack' } }); } describe('apple status (pitfall 70)', () => { test('only the version in the submission, the first IAP READY_TO_SUBMIT: WARN in text and --json', async () => { waiting(); const p = project({ ...CONFIG, apple: { ...BASE.apple!, appId: APP } }); const r = await capture(() => appleStatus(p.ctx([]))); expect(r.err).toContain(`WARN ${PACK} is not in this review`); expect(r.err).toContain('it will not be sellable after approval'); expect(r.err).toContain('add it with the next version, or withdraw the submission'); const j = await capture(() => appleStatus(p.ctx(['--json'], ['json']))); const gaps = jsonOut(j.out).firstProductGaps as { productId: string; level: string }[]; expect(gaps.map(g => [g.productId, g.level])).toEqual([[PACK, 'warn']]); // release status / the desktop read the same JSON expect(appleFirstProductWarnings(jsonOut(j.out))[0]).toStartWith(`App Store: ${PACK} is not in this review`); }); test('the IAP in the submission items: no warning', async () => { waiting({ packInItems: true }); const p = project({ ...CONFIG, apple: { ...BASE.apple!, appId: APP } }); const r = await capture(() => appleStatus(p.ctx([]))); expect(r.err).not.toContain('is not in this review'); }); test('an approved IAP: no warning', async () => { waiting({ packState: 'APPROVED' }); const p = project({ ...CONFIG, apple: { ...BASE.apple!, appId: APP } }); const r = await capture(() => appleStatus(p.ctx([]))); expect(r.err).not.toContain('is not in this review'); }); }); describe('firstProductGaps (pure)', () => { const iap = (state: string, type = 'NON_CONSUMABLE', productId = PACK) => ({ productId, type, state, versionIds: [`v-${productId}`] }); test('an open draft without the never-approved first IAP: error; with it: nothing', () => { const sub = { id: 'rs-1', state: 'READY_FOR_REVIEW', itemVersionIds: ['ver-1'] }; expect(firstProductGaps({ iaps: [iap('READY_TO_SUBMIT')], subs: [], submission: sub, versionState: 'PREPARE_FOR_SUBMISSION' }).map(g => g.level)).toEqual(['error']); expect(firstProductGaps({ iaps: [iap('READY_TO_SUBMIT')], subs: [], submission: { ...sub, itemVersionIds: ['ver-1', `v-${PACK}`] } })).toEqual([]); }); test('another product of the type approved, or the product approved: nothing', () => { expect(firstProductGaps({ iaps: [iap('READY_TO_SUBMIT'), iap('APPROVED', 'NON_CONSUMABLE', 'com.example.game.old')], subs: [], versionState: 'WAITING_FOR_REVIEW' })).toEqual([]); expect(firstProductGaps({ iaps: [iap('APPROVED')], subs: [], versionState: 'WAITING_FOR_REVIEW' })).toEqual([]); }); test('missing metadata before submitting: error; a first subscription of a group counts too; only config ids', () => { expect(firstProductGaps({ iaps: [iap('MISSING_METADATA')], subs: [] })[0]!.level).toBe('error'); const g = firstProductGaps({ iaps: [], subs: [{ productId: 'com.example.game.vip', groupId: 'g1', state: 'READY_TO_SUBMIT' }], versionState: 'IN_REVIEW' }); expect(g.map(x => [x.kind, x.level])).toEqual([['subscription', 'warn']]); expect(firstProductGaps({ iaps: [iap('READY_TO_SUBMIT')], subs: [], versionState: 'IN_REVIEW', only: ['com.example.other'] })).toEqual([]); }); test('validate rules: error is a blocker, warn a warning', () => { const snap: AppleSnapshot = { version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION' }, build: null, expectedLocales: [], locales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, firstProductGaps: [{ level: 'error', msg: 'm1', fix: 'f' }, { level: 'warn', msg: 'm2', fix: 'f' }], }; const f = appleRules(snap).filter(x => x.code === 'iap-first'); expect(f.map(x => [x.level, x.msg])).toEqual([['blocker', 'm1'], ['warn', 'm2']]); }); }); test('apple submit plan: a never-approved first IAP left out (missing metadata) blocks the version submission', () => { const st: SubmitState = { appVersions: [{ id: 'ver-1', versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION' }], subs: [], groups: [], draft: null, iaps: [{ id: 'iap-pack', productId: PACK, type: 'NON_CONSUMABLE', state: 'MISSING_METADATA', versions: [{ id: 'iapv-pack', version: 1, state: 'PREPARE_FOR_SUBMISSION' }] }] }; const plan = planIapSubmission(st, [PACK], { withVersion: true, appVersionId: 'ver-1' }); expect(plan.blockers.join(' ')).toContain(`never-approved first in-app purchases would be left out of this review`); const ok = planIapSubmission({ ...st, iaps: [{ ...st.iaps[0]!, state: 'READY_TO_SUBMIT' }] }, [PACK], { withVersion: true, appVersionId: 'ver-1' }); expect(ok.blockers).toEqual([]); expect(ok.items.map(i => i.kind)).toEqual(['appStoreVersion', 'inAppPurchaseVersion']); }); test('iap-review wording: READY_TO_SUBMIT is never "complete"', () => { expect(productStateText('READY_TO_SUBMIT', false)).toBe('READY_TO_SUBMIT: ready, not submitted yet (goes in with the next app version: storebound apple submit --build adds it)'); expect(productStateText('READY_TO_SUBMIT', true)).not.toContain('complete'); expect(productStateText('APPROVED', true)).toBe('APPROVED'); });