storebound/tests/first-products.test.ts

108 lines
7.9 KiB
TypeScript

// 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 <n> adds it)');
expect(productStateText('READY_TO_SUBMIT', true)).not.toContain('complete');
expect(productStateText('APPROVED', true)).toBe('APPROVED');
});