366 lines
36 KiB
TypeScript
366 lines
36 KiB
TypeScript
// TestFlight wait / expire, IAP review submissions, offers (offer codes, win-back, promoted, pre-orders), custom
|
|
// product pages and in-app events: pure planners plus the commands against recorded App Store Connect answers
|
|
// (shapes as documented in the App Store Connect API 4.5.1 OpenAPI spec; every id and name is a placeholder).
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { planExpire, expireCandidates, waitOutcome, waitDelay } from '../cli/lib/build-ops';
|
|
import { bindIds, planIapSubmission, type SubmitState } from '../cli/lib/review-submit';
|
|
import {
|
|
endPreOrderReq, iapOfferCodeBody, planPreOrder, planPromoted, promotedOrderReq, territoryRows, validateIapOfferCodes, validatePromoted, validateWinBack, winBackBody,
|
|
} from '../cli/lib/offers';
|
|
import { checkEventMedia, cppCreateBody, eventCreateBody, planCppLocales, planEventLocales, validateCpp, validateEvents } from '../cli/lib/cpp-events';
|
|
import { validateConfig } from '../cli/lib/config';
|
|
import { run as testflight } from '../cli/apple/testflight';
|
|
import { run as iapSubmit } from '../cli/apple/iap-submit';
|
|
import { run as preorder } from '../cli/apple/preorder';
|
|
import { run as promoted } from '../cli/apple/promoted';
|
|
import { run as offerCodes } from '../cli/apple/offer-codes';
|
|
import { run as winBack } from '../cli/apple/win-back';
|
|
import { run as cpp } from '../cli/apple/cpp';
|
|
import { run as events } from '../cli/apple/events';
|
|
import { ascEnv, BASE, capture, jsonOut, mockFetch, project, restore } from './helpers/store-mock';
|
|
|
|
afterEach(restore);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const page = (data: unknown[], included: unknown[] = []): any => ({ data, included, links: { self: 'https://api.appstoreconnect.apple.com/v1/x' }, meta: { paging: { total: data.length, limit: 200 } } });
|
|
|
|
// builds of one app as GET /v1/builds?include=preReleaseVersion,appStoreVersion answers them
|
|
const BUILDS = page([
|
|
{ type: 'builds', id: 'b-50', attributes: { version: '50', uploadedDate: '2026-10-10T10:00:00-07:00', processingState: 'VALID', expired: false }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'p-12' } }, appStoreVersion: { data: null } } },
|
|
{ type: 'builds', id: 'b-49', attributes: { version: '49', uploadedDate: '2026-09-30T10:00:00-07:00', processingState: 'VALID', expired: false }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'p-12' } }, appStoreVersion: { data: { type: 'appStoreVersions', id: 'v-12' } } } },
|
|
{ type: 'builds', id: 'b-48', attributes: { version: '48', uploadedDate: '2026-08-01T10:00:00-07:00', processingState: 'VALID', expired: false }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'p-11' } }, appStoreVersion: { data: null } } },
|
|
{ type: 'builds', id: 'b-47', attributes: { version: '47', uploadedDate: '2026-07-01T10:00:00-07:00', processingState: 'VALID', expired: false }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'p-11' } }, appStoreVersion: { data: null } } },
|
|
{ type: 'builds', id: 'b-46', attributes: { version: '46', uploadedDate: '2026-06-01T10:00:00-07:00', processingState: 'PROCESSING', expired: false }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'p-11' } }, appStoreVersion: { data: null } } },
|
|
], [{ type: 'preReleaseVersions', id: 'p-12', attributes: { version: '1.2.0', platform: 'IOS' } }, { type: 'preReleaseVersions', id: 'p-11', attributes: { version: '1.1.0', platform: 'IOS' } }]);
|
|
|
|
describe('testflight wait', () => {
|
|
test('outcomes and poll delays', () => {
|
|
expect(waitOutcome({ found: false })).toBe('missing');
|
|
expect(waitOutcome({ found: true, processingState: 'PROCESSING' })).toBe('processing');
|
|
expect(waitOutcome({ found: true, processingState: 'VALID' })).toBe('valid');
|
|
expect(waitOutcome({ found: true, processingState: 'INVALID' })).toBe('failed');
|
|
expect([waitDelay(null), waitDelay(20_000), waitDelay(100_000)]).toEqual([20_000, 30_000, 120_000]);
|
|
});
|
|
test('VALID build: exit 0 with the beta state in --json; FAILED: exit 1; not listed + --dry: exit 7 after one look', async () => {
|
|
ascEnv();
|
|
const p = project();
|
|
const one = (state: string) => page([{ type: 'builds', id: 'b-50', attributes: { version: '50', processingState: state }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'p-12' } }, buildBetaDetail: { data: { type: 'buildBetaDetails', id: 'd-50' } } } }],
|
|
[{ type: 'preReleaseVersions', id: 'p-12', attributes: { version: '1.2.0' } }, { type: 'buildBetaDetails', id: 'd-50', attributes: { internalBuildState: 'MISSING_EXPORT_COMPLIANCE', externalBuildState: 'MISSING_EXPORT_COMPLIANCE' } }]);
|
|
let calls = mockFetch([{ match: /^\/v1\/builds\?.*filter\[version\]=50/, answer: one('VALID') }]);
|
|
let r = await capture(() => testflight(p.ctx(['wait', '--build', '50', '--json'])));
|
|
expect(r.exit).toBe(0);
|
|
expect(jsonOut(r.out)).toMatchObject({ build: '50', version: '1.2.0', outcome: 'valid', internal: 'MISSING_EXPORT_COMPLIANCE' });
|
|
expect(r.out).toContain('storebound apple encryption --build 50');
|
|
expect(calls[0]!.path).toContain('include=preReleaseVersion%2CbuildBetaDetail');
|
|
mockFetch([{ match: /^\/v1\/builds\?/, answer: one('FAILED') }]);
|
|
r = await capture(() => testflight(p.ctx(['wait', '--build', '50'])));
|
|
expect(r.exit).toBe(1);
|
|
calls = mockFetch([{ match: /^\/v1\/builds\?/, answer: page([]) }]);
|
|
r = await capture(() => testflight(p.ctx(['wait', '--build', '51', '--dry'])));
|
|
expect(r.exit).toBe(7);
|
|
expect(calls).toHaveLength(1);
|
|
expect(r.out).toContain('not listed yet');
|
|
});
|
|
});
|
|
|
|
describe('testflight expire', () => {
|
|
test('plan: newest kept, attached and processing builds protected, --keep / --older-than combine', () => {
|
|
const c = expireCandidates([BUILDS]);
|
|
expect(c.map(b => [b.build, b.version, b.attached])).toEqual([['50', '1.2.0', false], ['49', '1.2.0', true], ['48', '1.1.0', false], ['47', '1.1.0', false], ['46', '1.1.0', false]]);
|
|
const now = Date.parse('2026-10-11T00:00:00Z');
|
|
const k = planExpire(c, { keep: 2, now });
|
|
expect(k.expire.map(b => b.build)).toEqual(['48', '47']);
|
|
expect(k.protected.map(p => [p.b.build, p.why])).toEqual([['49', 'attached to an App Store version'], ['46', 'still PROCESSING']]);
|
|
expect(k.reqs[0]).toEqual({ method: 'PATCH', path: '/v1/builds/b-48', note: 'expire build 48 (1.1.0), uploaded 2026-08-01', body: { data: { type: 'builds', id: 'b-48', attributes: { expired: true } } } });
|
|
expect(planExpire(c, { olderThanDays: 80, now }).expire.map(b => b.build)).toEqual(['47']);
|
|
expect(planExpire(c, { keep: 3, olderThanDays: 30, now }).expire.map(b => b.build)).toEqual(['47']);
|
|
expect(() => planExpire(c, {})).toThrow('--keep');
|
|
expect(() => planExpire(c, { keep: 0 })).toThrow('>= 1');
|
|
});
|
|
test('dry run sends nothing; without --yes in a non-terminal nothing is expired; --yes sends the PATCHes', async () => {
|
|
ascEnv();
|
|
const p = project();
|
|
let calls = mockFetch([{ match: /^\/v1\/builds\?.*filter\[expired\]=false/, answer: BUILDS }, { method: 'PATCH', match: /^\/v1\/builds\/b-4[78]$/, answer: { data: {} } }]);
|
|
let r = await capture(() => testflight(p.ctx(['expire', '--keep', '2', '--dry', '--json'])));
|
|
expect(jsonOut(r.out)).toMatchObject({ dryRun: true, expire: [{ build: '48' }, { build: '47' }], keep: 1 });
|
|
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
|
|
r = await capture(() => testflight(p.ctx(['expire', '--keep', '2'])));
|
|
expect(r.out).toContain('re-run with --yes');
|
|
calls = mockFetch([{ match: /^\/v1\/builds\?/, answer: BUILDS }, { method: 'PATCH', match: /^\/v1\/builds\/b-4[78]$/, answer: { data: {} } }]);
|
|
r = await capture(() => testflight(p.ctx(['expire', '--keep', '2', '--yes'])));
|
|
expect(calls.filter(c => c.method === 'PATCH').map(c => [c.path, c.body])).toEqual([
|
|
['/v1/builds/b-48', { data: { type: 'builds', id: 'b-48', attributes: { expired: true } } }], ['/v1/builds/b-47', { data: { type: 'builds', id: 'b-47', attributes: { expired: true } } }]]);
|
|
});
|
|
});
|
|
|
|
// ---- IAP review submissions ----
|
|
|
|
const STATE: SubmitState = {
|
|
appVersions: [{ id: 'v-2', versionString: '1.2.0', state: 'PREPARE_FOR_SUBMISSION' }, { id: 'v-1', versionString: '1.1.0', state: 'READY_FOR_DISTRIBUTION' }],
|
|
iaps: [
|
|
{ id: 'i-coins', productId: 'com.example.notes.coins_100', type: 'CONSUMABLE', state: 'APPROVED', versions: [{ id: 'iv-1', version: 1, state: 'APPROVED' }] },
|
|
{ id: 'i-coins2', productId: 'com.example.notes.coins_500', type: 'CONSUMABLE', state: 'READY_TO_SUBMIT', versions: [{ id: 'iv-2', version: 1, state: 'PREPARE_FOR_SUBMISSION' }] },
|
|
{ id: 'i-pro', productId: 'com.example.notes.pro', type: 'NON_CONSUMABLE', state: 'READY_TO_SUBMIT', versions: [{ id: 'iv-3', version: 1, state: 'PREPARE_FOR_SUBMISSION' }] },
|
|
{ id: 'i-meta', productId: 'com.example.notes.theme', type: 'NON_CONSUMABLE', state: 'MISSING_METADATA', versions: [] },
|
|
],
|
|
subs: [{ id: 's-m', productId: 'com.example.notes.plus_monthly', groupId: 'g-1', state: 'READY_TO_SUBMIT', versions: [{ id: 'sv-1', version: 1, state: 'PREPARE_FOR_SUBMISSION' }] }],
|
|
groups: [{ id: 'g-1', name: 'Plus', versions: [{ id: 'gv-1', version: 1, state: 'PREPARE_FOR_SUBMISSION' }] }],
|
|
draft: null,
|
|
};
|
|
|
|
describe('IAP review submission plan', () => {
|
|
test('a later consumable goes alone; first non-consumable / first subscription need the app version', () => {
|
|
const later = planIapSubmission(STATE, ['com.example.notes.coins_500']);
|
|
expect(later.blockers).toEqual([]);
|
|
expect(later.needsAppVersion).toBe(false);
|
|
expect(later.items).toEqual([{ kind: 'inAppPurchaseVersion', versionId: 'iv-2', label: 'com.example.notes.coins_500 (consumable, version 1)' }]);
|
|
expect(later.prepare.map(r => `${r.method} ${r.path}`)).toEqual(['POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems']);
|
|
expect(bindIds(later.prepare[1]!, { submission: 'rs-9' }).body).toEqual({ data: { type: 'reviewSubmissionItems', relationships: { reviewSubmission: { data: { type: 'reviewSubmissions', id: 'rs-9' } }, inAppPurchaseVersion: { data: { type: 'inAppPurchaseVersions', id: 'iv-2' } } } } });
|
|
expect(bindIds(later.submit, { submission: 'rs-9' }).body).toEqual({ data: { type: 'reviewSubmissions', id: 'rs-9', attributes: { submitted: true } } });
|
|
|
|
const first = planIapSubmission(STATE, ['com.example.notes.pro', 'com.example.notes.plus_monthly', 'com.example.notes.theme', 'com.example.notes.coins_100', 'com.example.nope']);
|
|
expect(first.firstOfType.sort()).toEqual(['auto-renewable subscription', 'non-consumable']);
|
|
expect(first.blockers[0]).toContain('--with-version');
|
|
expect(first.blockers[0]).toContain('1.2.0');
|
|
expect(first.items.map(i => i.kind)).toEqual(['inAppPurchaseVersion', 'subscriptionVersion', 'subscriptionGroupVersion']);
|
|
expect(Object.fromEntries(first.skipped.map(s => [s.id, s.why.split(' (')[0]!.split(':')[0]]))).toEqual({
|
|
'com.example.notes.theme': 'missing metadata', 'com.example.notes.coins_100': 'latest version is APPROVED', 'com.example.nope': 'not on App Store Connect' });
|
|
|
|
const withV = planIapSubmission(STATE, ['com.example.notes.pro'], { withVersion: true });
|
|
expect(withV.blockers).toEqual([]);
|
|
expect(withV.items.map(i => i.kind)).toEqual(['appStoreVersion', 'inAppPurchaseVersion']);
|
|
expect(withV.appVersion?.versionString).toBe('1.2.0');
|
|
});
|
|
test('no approved app version yet: even a later type needs the version; a draft is reused and items in it are skipped', () => {
|
|
const fresh = planIapSubmission({ ...STATE, appVersions: [{ id: 'v-1', versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION' }] }, ['com.example.notes.coins_500']);
|
|
expect(fresh.needsAppVersion).toBe(true);
|
|
expect(fresh.blockers[0]).toContain('no approved version');
|
|
const draft = planIapSubmission({ ...STATE, draft: { id: 'rs-1', itemVersionIds: ['iv-2'] } }, ['com.example.notes.coins_500', 'com.example.notes.pro'], { withVersion: true });
|
|
expect(draft.prepare.map(r => r.path)).not.toContain('/v1/reviewSubmissions');
|
|
expect(draft.skipped.find(s => s.id === 'com.example.notes.coins_500')?.why).toBe('already in the draft submission');
|
|
expect(draft.submit.path).toBe('/v1/reviewSubmissions/rs-1');
|
|
expect(planIapSubmission({ ...STATE, draft: { id: 'rs-1', itemVersionIds: Array.from({ length: 200 }, (_, i) => `x${i}`) } }, ['com.example.notes.coins_500']).blockers[0]).toContain('200');
|
|
});
|
|
test('command: --dry --json plans; a real run asks, creates the draft, adds the items, submits', async () => {
|
|
ascEnv();
|
|
const p = project({ ...BASE, products: [{ id: 'com.example.notes.coins_500', type: 'consumable', usd: 4.99, listings: { en: { title: '500 coins', description: 'A bag of coins' } } }] });
|
|
const routes = () => mockFetch([
|
|
{ match: /^\/v1\/apps\/6400000101\/appStoreVersions\?/, answer: page([{ type: 'appStoreVersions', id: 'v-1', attributes: { versionString: '1.1.0', appVersionState: 'READY_FOR_DISTRIBUTION', platform: 'IOS' } }]) },
|
|
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?/, answer: page([
|
|
{ type: 'inAppPurchases', id: 'i-coins', attributes: { productId: 'com.example.notes.coins_100', inAppPurchaseType: 'CONSUMABLE', state: 'APPROVED' } },
|
|
{ type: 'inAppPurchases', id: 'i-coins2', attributes: { productId: 'com.example.notes.coins_500', inAppPurchaseType: 'CONSUMABLE', state: 'READY_TO_SUBMIT' } }]) },
|
|
{ match: /^\/v2\/inAppPurchases\/i-coins2\/versions/, answer: page([{ type: 'inAppPurchaseVersions', id: 'iv-2', attributes: { version: 1, state: 'PREPARE_FOR_SUBMISSION' } }]) },
|
|
{ match: /^\/v1\/apps\/6400000101\/subscriptionGroups/, answer: page([]) },
|
|
{ match: /^\/v1\/apps\/6400000101\/reviewSubmissions\?filter\[platform\]=IOS&filter\[state\]=READY_FOR_REVIEW,UNRESOLVED_ISSUES,WAITING_FOR_REVIEW,IN_REVIEW/, answer: page([]) },
|
|
{ match: /^\/v1\/reviewSubmissions\?filter\[app\]=6400000101&filter\[platform\]=IOS&filter\[state\]=READY_FOR_REVIEW/, answer: page([]) },
|
|
{ method: 'POST', match: /^\/v1\/reviewSubmissions$/, answer: { data: { type: 'reviewSubmissions', id: 'rs-new', attributes: { state: 'READY_FOR_REVIEW' } } } },
|
|
{ method: 'POST', match: /^\/v1\/reviewSubmissionItems$/, answer: { data: { type: 'reviewSubmissionItems', id: 'it-1' } } },
|
|
{ method: 'PATCH', match: /^\/v1\/reviewSubmissions\/rs-new$/, answer: { data: { type: 'reviewSubmissions', id: 'rs-new', attributes: { state: 'WAITING_FOR_REVIEW' } } } },
|
|
// the journal of recent creates (.storebound/created.json) names rs-new: read by id, it was submitted, so a new draft is made
|
|
{ match: /^\/v1\/reviewSubmissions\/rs-new$/, answer: { data: { type: 'reviewSubmissions', id: 'rs-new', attributes: { state: 'WAITING_FOR_REVIEW' } } } },
|
|
]);
|
|
let calls = routes();
|
|
let r = await capture(() => iapSubmit(p.ctx(['--dry', '--json'])));
|
|
const plan = jsonOut(r.out);
|
|
expect(plan).toMatchObject({ dryRun: true, blockers: [], needsAppVersion: false, hasApprovedVersion: true });
|
|
expect((plan.requests as string[]).at(-1)).toContain('PATCH /v1/reviewSubmissions/${submission} {submitted=true}');
|
|
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
|
|
calls = routes();
|
|
r = await capture(() => iapSubmit(p.ctx(['--yes'])));
|
|
expect(calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`)).toEqual(['POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems', 'PATCH /v1/reviewSubmissions/rs-new']);
|
|
expect(calls.find(c => c.path === '/v1/reviewSubmissionItems')!.body).toEqual({ data: { type: 'reviewSubmissionItems', relationships: { reviewSubmission: { data: { type: 'reviewSubmissions', id: 'rs-new' } }, inAppPurchaseVersion: { data: { type: 'inAppPurchaseVersions', id: 'iv-2' } } } } });
|
|
calls = routes();
|
|
r = await capture(() => iapSubmit(p.ctx(['--yes', '--no-submit'])));
|
|
expect(calls.filter(c => c.method === 'PATCH')).toHaveLength(0);
|
|
expect(r.out).toContain('Submit for Review');
|
|
});
|
|
});
|
|
|
|
// ---- offers ----
|
|
|
|
describe('offer codes, win-back, promoted, pre-orders: config and bodies', () => {
|
|
test('config checks (date rules only right before creating)', () => {
|
|
expect(validateIapOfferCodes('p', [{ name: 'Spring', eligibility: ['NON_SPENDER'], usd: 0.99, oneTimeCodes: [{ count: 500, expires: '2027-01-01' }] }], 'non_consumable')).toEqual([]);
|
|
const e = validateIapOfferCodes('p', [{ name: 'X', eligibility: ['NEW' as never], customCodes: [{ code: 'NO WAY', count: 30000 }], oneTimeCodes: [{ count: 10, expires: '2030-01-01' }] }], 'non_consumable', '2026-10-11').join('\n');
|
|
for (const m of ['eligibility', 'free: true or usd', 'letters and digits', '1-25000', '500-25000', 'within 6 months']) expect(e).toContain(m);
|
|
const wb = { id: 'come-back', type: 'pay-up-front' as const, duration: 'P3M', usd: 9.99, paidMonths: 6, lapsedMonths: { min: 2, max: 24 }, start: '2026-11-01', end: '2026-11-30' };
|
|
expect(validateWinBack('s', [wb])).toEqual([]);
|
|
expect(validateWinBack('s', [{ ...wb, start: '2026-10-11' }], '2026-10-11').join()).toContain('at the earliest tomorrow');
|
|
expect(validateWinBack('s', [{ ...wb, paidMonths: 30, lapsedMonths: { min: 0, max: 30 }, waitMonths: 1, end: '2026-11-02' }]).join('\n')).toMatch(/paidMonths[\s\S]*lapsedMonths[\s\S]*waitMonths[\s\S]*at least 3 days/);
|
|
expect(validatePromoted({ products: Array.from({ length: 21 }, (_, i) => `p${i}`) }, new Set(['p0'])).join()).toContain('at most 20');
|
|
// a config with them validates, an old campaign never breaks the config
|
|
const cfg = { ...BASE, products: [{ id: 'com.example.notes.pro', type: 'non_consumable' as const, usd: 4.99, listings: { en: { title: 'Pro', description: 'All features' } }, offerCodes: [{ name: 'Old', eligibility: ['CHURNED_SPENDER' as const], free: true, oneTimeCodes: [{ count: 500, expires: '2020-01-01' }] }] }],
|
|
apple: { ...BASE.apple!, promoted: { products: ['com.example.notes.pro'] } } };
|
|
expect(validateConfig(cfg)).toEqual([]);
|
|
});
|
|
test('IAP offer code body: paid per territory, free without a price point', () => {
|
|
const b = iapOfferCodeBody('i-pro', { name: 'Spring', eligibility: ['NON_SPENDER', 'CHURNED_SPENDER'], usd: 1.99 }, [{ territory: 'USA', pointId: 'pp-usa' }, { territory: 'DEU', pointId: 'pp-deu' }]);
|
|
expect(b.data).toEqual({ type: 'inAppPurchaseOfferCodes', attributes: { name: 'Spring', customerEligibilities: ['NON_SPENDER', 'CHURNED_SPENDER'] },
|
|
relationships: { inAppPurchase: { data: { type: 'inAppPurchases', id: 'i-pro' } }, prices: { data: [{ type: 'inAppPurchaseOfferPrices', id: '${iapcode-0}' }, { type: 'inAppPurchaseOfferPrices', id: '${iapcode-1}' }] } } });
|
|
expect(b.included[1]).toEqual({ type: 'inAppPurchaseOfferPrices', id: '${iapcode-1}', relationships: { territory: { data: { type: 'territories', id: 'DEU' } }, pricePoint: { data: { type: 'inAppPurchasePricePoints', id: 'pp-deu' } } } });
|
|
expect(iapOfferCodeBody('i', { name: 'Free', eligibility: ['NON_SPENDER'], free: true }, [{ territory: 'USA' }]).included[0]!.relationships).toEqual({ territory: { data: { type: 'territories', id: 'USA' } } });
|
|
expect(() => iapOfferCodeBody('i', { name: 'P', eligibility: ['NON_SPENDER'], usd: 1 }, [{ territory: 'USA' }])).toThrow('needs a price point');
|
|
});
|
|
test('win-back body like Apple\'s example request', () => {
|
|
const b = winBackBody('s-1', 'P1M', { id: '6Monthfor3_a', name: '6 Months for 3 A', type: 'pay-up-front', duration: 'P6M', usd: 3, paidMonths: 6, lapsedMonths: { min: 2, max: 24 }, waitMonths: 2,
|
|
start: '2026-11-01', end: '2026-11-30', priority: 'high', promote: true }, [{ territory: 'USA', pointId: 'sp-usa' }]);
|
|
expect(b.data.attributes).toEqual({ referenceName: '6 Months for 3 A', offerId: '6Monthfor3_a', duration: 'SIX_MONTHS', offerMode: 'PAY_UP_FRONT', periodCount: 1,
|
|
customerEligibilityPaidSubscriptionDurationInMonths: 6, customerEligibilityTimeSinceLastSubscribedInMonths: { minimum: 2, maximum: 24 }, customerEligibilityWaitBetweenOffersInMonths: 2,
|
|
startDate: '2026-11-01', endDate: '2026-11-30', priority: 'HIGH', promotionIntent: 'USE_AUTO_GENERATED_ASSETS' });
|
|
expect(b.included[0]).toEqual({ type: 'winBackOfferPrices', id: '${winback-0}', relationships: { subscriptionPricePoint: { data: { type: 'subscriptionPricePoints', id: 'sp-usa' } } } });
|
|
expect(winBackBody('s', 'P1M', { id: 'x', type: 'pay-as-you-go', usd: 1, periods: 3, paidMonths: 1, lapsedMonths: { min: 1, max: 3 }, start: '2026-11-01' }, [{ territory: 'USA', pointId: 'p' }]).data.attributes).toMatchObject({ duration: 'ONE_MONTH', offerMode: 'PAY_AS_YOU_GO', periodCount: 3, priority: 'NORMAL', promotionIntent: 'NOT_PROMOTED' });
|
|
});
|
|
test('promoted: create disabled, enable only with --enable, order configured first', () => {
|
|
const prods = new Map([['a', { apiId: 'i-a', kind: 'iap' as const }], ['b', { apiId: 's-b', kind: 'sub' as const }]]);
|
|
const now = [{ id: 'pp-x', productId: 'x', enabled: true, visibleForAllUsers: true }, { id: 'pp-b', productId: 'b', enabled: false, visibleForAllUsers: false }];
|
|
const p = planPromoted('app', { products: ['a', 'b', 'zzz'] }, now, prods);
|
|
expect(p.create[0]!.body).toEqual({ data: { type: 'promotedPurchases', attributes: { visibleForAllUsers: true, enabled: false }, relationships: { app: { data: { type: 'apps', id: 'app' } }, inAppPurchaseV2: { data: { type: 'inAppPurchases', id: 'i-a' } } } } });
|
|
expect(p.update.map(r => r.note)).toEqual(['b: visibleForAllUsers=true']);
|
|
expect(p.unknown).toEqual(['zzz']);
|
|
expect(p.extra).toEqual(['x']);
|
|
expect(planPromoted('app', { products: ['b'] }, now, prods, { enable: true }).update[0]!.body).toEqual({ data: { type: 'promotedPurchases', id: 'pp-b', attributes: { visibleForAllUsers: true, enabled: true } } });
|
|
expect(promotedOrderReq('app', { products: ['b'] }, now)).toEqual({ method: 'PATCH', path: '/v1/apps/app/relationships/promotedPurchases', note: 'promoted purchase order', body: { data: [{ type: 'promotedPurchases', id: 'pp-b' }, { type: 'promotedPurchases', id: 'pp-x' }] } });
|
|
expect(promotedOrderReq('app', { products: ['x'] }, now)).toBeNull();
|
|
});
|
|
test('pre-order: 2-365 days, released territories skipped, end releases now', () => {
|
|
const rows = territoryRows([page([
|
|
{ type: 'territoryAvailabilities', id: 'ta-usa', attributes: { available: true, releaseDate: null, preOrderEnabled: false, contentStatuses: ['AVAILABLE_FOR_SALE_UNRELEASED_APP'] }, relationships: { territory: { data: { type: 'territories', id: 'USA' } } } },
|
|
{ type: 'territoryAvailabilities', id: 'ta-deu', attributes: { available: true, releaseDate: '2026-12-01', preOrderEnabled: true, contentStatuses: ['AVAILABLE_FOR_PREORDER_ON_DATE'] }, relationships: { territory: { data: { type: 'territories', id: 'DEU' } } } },
|
|
{ type: 'territoryAvailabilities', id: 'ta-tur', attributes: { available: true, contentStatuses: ['AVAILABLE'] }, relationships: { territory: { data: { type: 'territories', id: 'TUR' } } } },
|
|
])]);
|
|
const p = planPreOrder(rows, '2026-12-01', null, '2026-10-11');
|
|
expect(p.problems).toEqual([]);
|
|
expect(p.reqs.map(r => r.path)).toEqual(['/v1/territoryAvailabilities/ta-usa']);
|
|
expect(p.reqs[0]!.body).toEqual({ data: { type: 'territoryAvailabilities', id: 'ta-usa', attributes: { available: true, preOrderEnabled: true, releaseDate: '2026-12-01' } } });
|
|
expect(p.warnings).toEqual(['TUR: already released there, no pre-order possible']);
|
|
expect(planPreOrder(rows, '2026-10-12', null, '2026-10-11').problems[0]).toContain('at least 2 days');
|
|
expect(planPreOrder(rows, '2027-06-01', null, '2026-10-11').warnings[0]).toContain('180 days');
|
|
expect(planPreOrder(rows, '2026-12-01', ['FRA'], '2026-10-11').problems[0]).toContain('FRA');
|
|
expect(endPreOrderReq(rows, null)!.body).toEqual({ data: { type: 'endAppAvailabilityPreOrders', relationships: { territoryAvailabilities: { data: [{ type: 'territoryAvailabilities', id: 'ta-deu' }] } } } });
|
|
});
|
|
});
|
|
|
|
describe('offer commands against recorded answers', () => {
|
|
test('preorder status --json and enable --dry', async () => {
|
|
ascEnv();
|
|
const p = project();
|
|
mockFetch([
|
|
{ match: /^\/v1\/apps\/6400000101\/appAvailabilityV2$/, answer: { data: { type: 'appAvailabilities', id: 'av-1', attributes: { availableInNewTerritories: true } } } },
|
|
{ match: /^\/v2\/appAvailabilities\/av-1\/territoryAvailabilities\?include=territory/, answer: page([
|
|
{ type: 'territoryAvailabilities', id: 'ta-usa', attributes: { available: true, releaseDate: null, preOrderEnabled: false, preOrderPublishDate: null, contentStatuses: ['AVAILABLE_FOR_SALE_UNRELEASED_APP'] }, relationships: { territory: { data: { type: 'territories', id: 'USA' } } } }]) },
|
|
]);
|
|
let r = await capture(() => preorder(p.ctx(['status', '--json'])));
|
|
expect(jsonOut(r.out)).toMatchObject({ availableInNewTerritories: true, territories: [{ territory: 'USA', preOrderEnabled: false }] });
|
|
const future = new Date(Date.now() + 30 * 86_400_000).toISOString().slice(0, 10);
|
|
r = await capture(() => preorder(p.ctx(['enable', '--release-date', future, '--dry', '--json'])));
|
|
expect((jsonOut(r.out).requests as string[])[0]).toContain('PATCH /v1/territoryAvailabilities/ta-usa {available=true, preOrderEnabled=true');
|
|
});
|
|
test('promoted: status without config; plan with config is a dry run', async () => {
|
|
ascEnv();
|
|
const routes = [
|
|
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2/, answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: 'com.example.notes.pro', inAppPurchaseType: 'NON_CONSUMABLE', state: 'APPROVED' } }]) },
|
|
{ match: /^\/v1\/apps\/6400000101\/subscriptionGroups/, answer: page([]) },
|
|
{ match: /^\/v1\/apps\/6400000101\/promotedPurchases\?include=/, answer: page([]) },
|
|
];
|
|
mockFetch(routes);
|
|
let r = await capture(() => promoted(project().ctx(['--json'])));
|
|
expect(jsonOut(r.out)).toEqual({ promoted: [] });
|
|
const calls = mockFetch(routes);
|
|
r = await capture(() => promoted(project({ ...BASE, apple: { ...BASE.apple!, promoted: { products: ['com.example.notes.pro'] } } }).ctx(['--dry', '--json'])));
|
|
expect((jsonOut(r.out).requests as string[])[0]).toContain('POST /v1/promotedPurchases {visibleForAllUsers=true, enabled=false}');
|
|
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
|
|
});
|
|
test('offer-codes --dry: campaign with equalized prices; codes need --codes', async () => {
|
|
ascEnv();
|
|
const p = project({ ...BASE, products: [{ id: 'com.example.notes.pro', type: 'non_consumable', usd: 4.99, listings: { en: { title: 'Pro', description: 'All features' } },
|
|
offerCodes: [{ name: 'Spring', eligibility: ['NON_SPENDER'], usd: 0.99, customCodes: [{ code: 'SPRING26', count: 100 }] }] }] });
|
|
const calls = mockFetch([
|
|
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?filter\[productId\]=com.example.notes.pro/, answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: 'com.example.notes.pro', state: 'APPROVED' } }]) },
|
|
{ match: /^\/v2\/inAppPurchases\/i-pro\/offerCodes/, answer: page([]) },
|
|
{ match: /^\/v2\/inAppPurchases\/i-pro\/pricePoints\?filter\[territory\]=USA/, answer: page([
|
|
{ type: 'inAppPurchasePricePoints', id: 'pp-usa-099', attributes: { customerPrice: '0.99', proceeds: '0.84' }, relationships: { territory: { data: { type: 'territories', id: 'USA' } } } },
|
|
{ type: 'inAppPurchasePricePoints', id: 'pp-usa-499', attributes: { customerPrice: '4.99', proceeds: '4.24' }, relationships: { territory: { data: { type: 'territories', id: 'USA' } } } }]) },
|
|
{ match: /^\/v1\/inAppPurchasePricePoints\/pp-usa-099\/equalizations/, answer: page([
|
|
{ type: 'inAppPurchasePricePoints', id: 'pp-deu-099', attributes: { customerPrice: '0.99' }, relationships: { territory: { data: { type: 'territories', id: 'DEU' } } } }]) },
|
|
]);
|
|
const r = await capture(() => offerCodes(p.ctx(['--dry', '--json'])));
|
|
expect(r.out).toContain('would POST /v1/inAppPurchaseOfferCodes {name="Spring"');
|
|
expect(r.out).toContain('2 territories');
|
|
expect(jsonOut(r.out).codes).toEqual(['POST /v1/inAppPurchaseOfferCodeCustomCodes {customCode="SPRING26", numberOfCodes=100} # custom code SPRING26 (100 redemptions)']);
|
|
expect(r.out).toContain('run with --codes');
|
|
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
|
|
});
|
|
test('win-back --dry: an approved subscription gets the plan, others are skipped with the reason', async () => {
|
|
ascEnv();
|
|
const start = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10);
|
|
const sub = (id: string) => ({ id, period: 'P1M' as const, level: 1, usd: 4.99, listings: { en: { title: 'Plus', description: 'Everything' } },
|
|
winBack: [{ id: `wb-${id.slice(-1)}`, type: 'free' as const, duration: 'P1M', paidMonths: 3, lapsedMonths: { min: 1, max: 12 }, start }] });
|
|
const p = project({ ...BASE, subscriptions: [{ name: 'Plus', listings: { en: { name: 'Plus' } }, subscriptions: [sub('com.example.notes.plus_a'), sub('com.example.notes.plus_b')] }] });
|
|
const calls = mockFetch([
|
|
{ match: /^\/v1\/apps\/6400000101\/subscriptionGroups/, answer: page([{ type: 'subscriptionGroups', id: 'g-1', attributes: { referenceName: 'Plus' } }]) },
|
|
{ match: /^\/v1\/subscriptionGroups\/g-1\/subscriptions/, answer: page([{ type: 'subscriptions', id: 's-a', attributes: { productId: 'com.example.notes.plus_a', state: 'APPROVED' } }, { type: 'subscriptions', id: 's-b', attributes: { productId: 'com.example.notes.plus_b', state: 'READY_TO_SUBMIT' } }]) },
|
|
{ match: /^\/v1\/subscriptions\/s-[ab]\/winBackOffers/, answer: page([]) },
|
|
{ match: /^\/v1\/subscriptions\/s-a\/prices\?include=territory/, answer: page([{ type: 'subscriptionPrices', id: 'sp1', relationships: { territory: { data: { type: 'territories', id: 'USA' } } } }]) },
|
|
]);
|
|
const r = await capture(() => winBack(p.ctx(['--dry', '--json'])));
|
|
const j = jsonOut(r.out);
|
|
expect((j.create as string[])[0]).toContain('POST /v1/winBackOffers {referenceName="wb-a", offerId="wb-a", duration="ONE_MONTH", offerMode="FREE_TRIAL"');
|
|
expect(j.skipped).toEqual([{ id: 'com.example.notes.plus_b', why: 'subscription is READY_TO_SUBMIT: win-back offers need an approved subscription' }]);
|
|
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ---- custom product pages and in-app events ----
|
|
|
|
describe('custom product pages and in-app events', () => {
|
|
const EV = { referenceName: 'Winter Challenge', badge: 'CHALLENGE' as const, purpose: 'KEEP_ACTIVE_USERS_INFORMED' as const,
|
|
schedule: { publishStart: '2026-12-01T00:00:00Z', eventStart: '2026-12-10T00:00:00Z', eventEnd: '2026-12-20T00:00:00Z' },
|
|
listings: { en: { name: 'Winter Challenge', shortDescription: 'Finish ten lists before New Year', longDescription: 'Ten lists, one badge. Share your progress with friends.' } },
|
|
media: { card: 'store/events/winter/{lang}-card.png' } };
|
|
test('config checks', () => {
|
|
expect(validateCpp([{ name: 'Students', variant: 'b', promotionalText: { en: 'For class notes.' }, deepLink: 'https://example.com/students' }], new Set(['en']), ['b'])).toEqual([]);
|
|
expect(validateCpp([{ name: 'A', variant: 'z', deepLink: 'not a link', promotionalText: { xx: 'x'.repeat(171) } }], new Set(['en']), []).join('\n')).toMatch(/variant[\s\S]*deepLink[\s\S]*not in languages[\s\S]*171 > 170/);
|
|
expect(validateEvents([EV], new Set(['en']))).toEqual([]);
|
|
const bad = validateEvents([{ ...EV, schedule: { publishStart: '2026-11-01T00:00:00Z', eventStart: '2026-12-10T00:00:00Z', eventEnd: '2027-02-10T00:00:00Z' }, listings: { en: { name: 'x'.repeat(31), shortDescription: '', longDescription: 'ok' } } }], new Set(['en'])).join('\n');
|
|
for (const m of ['at most 31 days', 'at most 14 days', 'name: 31 > 30', 'shortDescription: missing']) expect(bad).toContain(m);
|
|
expect(checkEventMedia('EVENT_CARD', 1920, 1080, 'png')).toEqual([]);
|
|
expect(checkEventMedia('EVENT_DETAILS_PAGE', 1920, 1080, 'png').join()).toContain('9:16');
|
|
});
|
|
test('request bodies', () => {
|
|
const b = cppCreateBody('6400000101', { name: 'Students', deepLink: 'https://example.com/s' }, [{ locale: 'en-US', promotionalText: 'For class notes.' }, { locale: 'de-DE' }]);
|
|
expect(b.data.relationships.appCustomProductPageVersions).toEqual({ data: [{ type: 'appCustomProductPageVersions', id: '${cpp-version}' }] });
|
|
expect(b.included[0]).toEqual({ type: 'appCustomProductPageVersions', id: '${cpp-version}', attributes: { deepLink: 'https://example.com/s' }, relationships: { appCustomProductPageLocalizations: { data: [{ type: 'appCustomProductPageLocalizations', id: '${cpp-loc-0}' }, { type: 'appCustomProductPageLocalizations', id: '${cpp-loc-1}' }] } } });
|
|
expect(b.included[2]).toEqual({ type: 'appCustomProductPageLocalizations', id: '${cpp-loc-1}', attributes: { locale: 'de-DE' } });
|
|
expect(planCppLocales('cv', [{ id: 'l1', locale: 'en-US', promotionalText: 'old' }], [{ locale: 'en-US', promotionalText: 'new' }, { locale: 'tr' }]).map(r => `${r.method} ${r.path}`)).toEqual(['PATCH /v1/appCustomProductPageLocalizations/l1', 'POST /v1/appCustomProductPageLocalizations']);
|
|
const e = eventCreateBody('6400000101', EV, 'en-US', ['USA', 'DEU']);
|
|
expect(e.data.attributes).toEqual({ referenceName: 'Winter Challenge', badge: 'CHALLENGE', primaryLocale: 'en-US', priority: 'NORMAL', purpose: 'KEEP_ACTIVE_USERS_INFORMED',
|
|
territorySchedules: [{ territories: ['USA', 'DEU'], publishStart: '2026-12-01T00:00:00Z', eventStart: '2026-12-10T00:00:00Z', eventEnd: '2026-12-20T00:00:00Z' }] });
|
|
expect(planEventLocales('ev', [{ id: 'l', locale: 'en-US', name: 'Winter Challenge', shortDescription: 'old', longDescription: 'Ten lists, one badge. Share your progress with friends.' }], [{ locale: 'en-US', ...EV.listings.en }])[0]!.note).toBe('en-US shortDescription');
|
|
});
|
|
test('cpp create --dry --json and events create --dry against recorded answers', async () => {
|
|
ascEnv();
|
|
const p = project({ ...BASE, apple: { ...BASE.apple!, customProductPages: [{ name: 'Students', promotionalText: { en: 'For class notes.' } }], events: [EV] } });
|
|
const calls = mockFetch([
|
|
{ match: /^\/v1\/apps\/6400000101\/appCustomProductPages/, answer: page([]) },
|
|
{ match: /^\/v1\/apps\/6400000101\/appEvents/, answer: page([]) },
|
|
{ match: /^\/v1\/territories/, answer: page([{ type: 'territories', id: 'USA' }, { type: 'territories', id: 'DEU' }]) },
|
|
]);
|
|
let r = await capture(() => cpp(p.ctx(['create', '--dry', '--json'])));
|
|
expect(jsonOut(r.out).requests).toEqual(['POST /v1/appCustomProductPages {name="Students"} # page "Students" with 3 locale(s)']);
|
|
r = await capture(() => events(p.ctx(['create', '--dry', '--json'])));
|
|
expect((jsonOut(r.out).requests as string[])[0]).toContain('POST /v1/appEvents {referenceName="Winter Challenge", badge="CHALLENGE", primaryLocale="en-US"');
|
|
expect(r.out).toContain('then: 1 localization(s)');
|
|
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
|
|
// submit of a created page: the dry run shows draft, item and submit, and sends nothing
|
|
const sent = mockFetch([
|
|
{ match: /^\/v1\/apps\/6400000101\/appCustomProductPages/, answer: page([{ type: 'appCustomProductPages', id: 'cpp-1', attributes: { name: 'Students', visible: false, url: 'https://apps.apple.com/us/app/id6400000101?ppid=cpp-1' } }]) },
|
|
{ match: /^\/v1\/appCustomProductPages\/cpp-1\/appCustomProductPageVersions/, answer: page([{ type: 'appCustomProductPageVersions', id: 'cv-1', attributes: { version: '1', state: 'PREPARE_FOR_SUBMISSION', deepLink: null } }]) },
|
|
{ match: /^\/v1\/reviewSubmissions\?filter\[app\]=6400000101/, answer: page([]) },
|
|
]);
|
|
r = await capture(() => cpp(p.ctx(['submit', '--name', 'Students', '--dry'])));
|
|
expect(r.out).toContain('would POST /v1/reviewSubmissionItems');
|
|
expect(r.out).toContain('would PATCH /v1/reviewSubmissions/${submission}');
|
|
r = await capture(() => cpp(p.ctx(['submit', '--name', 'Students'])));
|
|
expect(r.out).toContain('re-run with --yes');
|
|
expect(sent.filter(c => c.method !== 'GET')).toHaveLength(0);
|
|
});
|
|
});
|