449 lines
32 KiB
TypeScript
449 lines
32 KiB
TypeScript
// Resubmitting after a rejection (seen live, 2026-10-12: an Invalid Binary rejection, then `apple iap-submit
|
|
// --with-version --yes` created a second, empty draft next to the rejected submission and stopped with a wrong
|
|
// "list lag" message on 409 ITEM_PART_OF_ANOTHER_SUBMISSION). Against the stateful App Store Connect double: the
|
|
// UNRESOLVED_ISSUES submission is reused (no new draft), only missing items go in, the submit is retried while Apple
|
|
// says "not ready yet" (no real waits), the 409 names the other submission, and a re-run sends nothing.
|
|
// Seen live the same day: a REJECTED item blocks every submit with 409 "not ready yet" until it is marked resolved
|
|
// (PATCH reviewSubmissionItems resolved: true), and an empty draft cannot be canceled ("not in cancellable state").
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
import type { ShipConfig } from '../cli/lib/config';
|
|
import { chooseSubmission, formatRows, type OpenSub, planIapSubmission, resubmitRows, type SubmitState } from '../cli/lib/review-submit';
|
|
import { isConflict, upsertTiming } from '../cli/lib/upsert';
|
|
import { notReadyYet, otherSubmissionOf, submitTiming } from '../cli/apple/submission';
|
|
import { run as appleIapSubmit } from '../cli/apple/iap-submit';
|
|
import { run as appleResubmit } from '../cli/apple/resubmit';
|
|
import { run as appleStatus } from '../cli/apple/status';
|
|
import { run as appleSubmit } from '../cli/apple/submit';
|
|
import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock';
|
|
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
|
|
|
|
const APP = '6400000101';
|
|
const NOT_READY = { method: 'PATCH', match: /^\/v1\/reviewSubmissions\/rs-old$/, when: 'before' as const, status: 409, code: 'STATE_ERROR',
|
|
detail: 'Version is not ready to be submitted yet, please try again later' };
|
|
|
|
let s: Stores;
|
|
let slept: number[];
|
|
const timing = { ...submitTiming };
|
|
beforeAll(ascEnv);
|
|
afterAll(restore);
|
|
beforeEach(() => {
|
|
upsertTiming.waitMs = 0;
|
|
s = installStores();
|
|
slept = [];
|
|
let now = 0;
|
|
Object.assign(submitTiming, timing, { sleep: async (ms: number) => { slept.push(ms); now += ms; }, now: () => now });
|
|
});
|
|
afterEach(() => { uninstallStores(); Object.assign(submitTiming, timing); });
|
|
|
|
const CONFIG: ShipConfig = {
|
|
...BASE,
|
|
products: [
|
|
{ id: 'com.example.notes.gems', type: 'consumable', usd: 0.99, listings: { en: { title: 'Gems', description: 'A few gems.' } } },
|
|
{ id: 'com.example.notes.pro', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'Pro', description: 'Every feature.' } } },
|
|
],
|
|
};
|
|
|
|
/** The live shape: version 1.0.0 REJECTED (Invalid Binary) with build 7, submission rs-old UNRESOLVED_ISSUES holding the
|
|
* version and the gems IAP; pro was never added; build 8 uploaded and VALID. */
|
|
/** The app was on sale before (an update, not a first release): version 0.9.0 READY_FOR_SALE. */
|
|
function onSale(): void { s.asc.put({ type: 'appStoreVersions', id: 'ver-0', attributes: { versionString: '0.9.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' }, rels: { app: APP } }); }
|
|
|
|
function rejected(o: { pro?: boolean; ver?: string; gems?: string } = {}): void {
|
|
onSale();
|
|
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'REJECTED', platform: 'IOS' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP, appStoreVersion: 'ver-1' } });
|
|
s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } });
|
|
s.asc.put({ type: 'inAppPurchases', id: 'iap-gems', attributes: { productId: 'com.example.notes.gems', inAppPurchaseType: 'CONSUMABLE', state: 'WAITING_FOR_REVIEW' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'inAppPurchaseVersions', id: 'iapv-gems', attributes: { version: 1, state: 'READY_FOR_REVIEW' }, rels: { inAppPurchase: 'iap-gems' } });
|
|
s.asc.put({ type: 'inAppPurchases', id: 'iap-pro', attributes: { productId: 'com.example.notes.pro', inAppPurchaseType: 'NON_CONSUMABLE', state: 'READY_TO_SUBMIT' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'inAppPurchaseVersions', id: 'iapv-pro', attributes: { version: 1, state: 'PREPARE_FOR_SUBMISSION' }, rels: { inAppPurchase: 'iap-pro' } });
|
|
s.asc.put({ type: 'reviewSubmissions', id: 'rs-old', attributes: { platform: 'IOS', state: 'UNRESOLVED_ISSUES' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'reviewSubmissionItems', id: 'it-ver', attributes: { state: o.ver ?? 'REJECTED' }, rels: { reviewSubmission: 'rs-old', appStoreVersion: 'ver-1' } });
|
|
s.asc.put({ type: 'reviewSubmissionItems', id: 'it-gems', attributes: { state: o.gems ?? 'READY_FOR_REVIEW' }, rels: { reviewSubmission: 'rs-old', inAppPurchaseVersion: 'iapv-gems' } });
|
|
if (o.pro) s.asc.put({ type: 'reviewSubmissionItems', id: 'it-pro', attributes: { state: 'READY_FOR_REVIEW' }, rels: { reviewSubmission: 'rs-old', inAppPurchaseVersion: 'iapv-pro' } });
|
|
}
|
|
|
|
const shown = (from: number): string[] => writes(s.calls.slice(from)).map(c => `${c.method} ${decodeURIComponent(c.path).replace(/\?.*/, '')}`);
|
|
|
|
describe('choosing the review submission', () => {
|
|
const sub = (id: string, state: string, items: string[] = []): OpenSub => ({ id, state, items: items.map(v => ({ kind: 'appStoreVersion', versionId: v, state: 'REJECTED' })) });
|
|
test('UNRESOLVED_ISSUES holding the version beats an empty draft; in review means nothing to do; none means create', () => {
|
|
const ch = chooseSubmission([sub('rs-new', 'READY_FOR_REVIEW'), sub('rs-x', 'UNRESOLVED_ISSUES'), sub('rs-old', 'UNRESOLVED_ISSUES', ['ver-1'])], { appVersionId: 'ver-1' });
|
|
expect(ch.action).toBe('reuse');
|
|
if (ch.action === 'reuse') { expect(ch.sub.id).toBe('rs-old'); expect(ch.strayDrafts.map(d => d.id)).toEqual(['rs-new']); }
|
|
expect(chooseSubmission([sub('rs-1', 'WAITING_FOR_REVIEW'), sub('rs-2', 'READY_FOR_REVIEW')]).action).toBe('busy');
|
|
expect(chooseSubmission([sub('rs-2', 'READY_FOR_REVIEW')])).toMatchObject({ action: 'reuse', sub: { id: 'rs-2' } });
|
|
expect(chooseSubmission([]).action).toBe('create');
|
|
});
|
|
|
|
test('a 409 ITEM_PART_OF_ANOTHER_SUBMISSION is no duplicate (never the list-lag message) and names the other submission', () => {
|
|
const e = new Error('POST /v1/reviewSubmissionItems -> 409 {"errors":[{"status":"409","code":"STATE_ERROR.ITEM_PART_OF_ANOTHER_SUBMISSION","detail":"appStoreVersions with id 1234567890 was already added to another reviewSubmission with id rs-example-1"}]}');
|
|
expect(isConflict(e)).toBe(false);
|
|
expect(otherSubmissionOf(e)).toBe('rs-example-1');
|
|
expect(otherSubmissionOf(new Error('-> 409 ENTITY_ERROR already exists'))).toBeNull();
|
|
expect(notReadyYet(new Error('PATCH /v1/reviewSubmissions/x -> 409 {"detail":"Version is not ready to be submitted yet, please try again later"}'))).toBe(true);
|
|
expect(notReadyYet(new Error('PATCH /v1/reviewSubmissions/x -> 409 {"detail":"something else"}'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('apple resubmit --build <n>', () => {
|
|
test('dry run without --yes: a plan table, nothing sent', async () => {
|
|
rejected();
|
|
const p = project(CONFIG);
|
|
const a = s.calls.length;
|
|
const r = await capture(() => appleResubmit(p.ctx(['--build', '8'])));
|
|
expect(r.exit).toBeFalsy();
|
|
expect(shown(a)).toEqual([]);
|
|
expect(r.out).toContain('STEP');
|
|
expect(r.out).toMatch(/build\s+build 8\s+attached: 7\s+attach build 8 instead of 7/);
|
|
expect(r.out).toMatch(/submission\s+rs-old\s+UNRESOLVED_ISSUES\s+reuse \(resubmit, no new draft\)/);
|
|
expect(r.out).toMatch(/item\s+app version 1\.0\.0\s+REJECTED\s+in it already; mark resolved/);
|
|
expect(r.out).toMatch(/item\s+com\.example\.notes\.gems\s+READY_FOR_REVIEW\s+in it already/);
|
|
expect(r.out).toMatch(/item\s+com\.example\.notes\.pro \(non-consumable, version 1\)\s+-\s+add/);
|
|
expect(r.out).toContain('owner');
|
|
const j = await capture(() => appleResubmit(p.ctx(['--build', '8', '--json'])));
|
|
expect(JSON.parse(j.out)).toMatchObject({ dryRun: true, build: { action: 'attach', id: 'b-8' }, submission: { action: 'reuse', sub: { id: 'rs-old' } }, blockers: [],
|
|
resolve: [{ item: 'it-ver', kind: 'appStoreVersion', what: 'app version 1.0.0' }] });
|
|
expect(shown(a)).toEqual([]);
|
|
});
|
|
|
|
test('--yes: build attached, rs-old reused (no new draft), only the missing IAP added, the REJECTED version item marked resolved, submit retried after "not ready"', async () => {
|
|
rejected();
|
|
// an empty draft next to it (left by an earlier run): App Store Connect cannot cancel it, so it is left as is
|
|
s.asc.put({ type: 'reviewSubmissions', id: 'rs-empty', attributes: { platform: 'IOS', state: 'READY_FOR_REVIEW' }, rels: { app: APP } });
|
|
s.faults.push({ ...NOT_READY, times: 2 });
|
|
const p = project(CONFIG);
|
|
const a = s.calls.length;
|
|
const r = await capture(() => appleResubmit(p.ctx(['--build', '8', '--yes'])));
|
|
expect([r.exit ?? 0, r.err]).toEqual([0, '']);
|
|
expect(shown(a)).toEqual(['PATCH /v1/appStoreVersions/ver-1/relationships/build', 'POST /v1/reviewSubmissionItems', 'PATCH /v1/reviewSubmissionItems/it-ver',
|
|
'PATCH /v1/reviewSubmissions/rs-old', 'PATCH /v1/reviewSubmissions/rs-old', 'PATCH /v1/reviewSubmissions/rs-old']);
|
|
// the exact request shape that worked live
|
|
expect(s.calls.find(c => c.method === 'PATCH' && c.path === '/v1/reviewSubmissionItems/it-ver')!.body).toEqual(
|
|
{ data: { type: 'reviewSubmissionItems', id: 'it-ver', attributes: { resolved: true } } });
|
|
expect(r.out).toContain('app version 1.0.0: marked resolved (item it-ver)');
|
|
expect(r.out).toContain('rs-empty: empty draft (harmless; App Store Connect keeps it, not cancellable)');
|
|
expect(s.asc.all('reviewSubmissions').map(x => [x.id, x.attributes.state])).toEqual([['rs-old', 'WAITING_FOR_REVIEW'], ['rs-empty', 'READY_FOR_REVIEW']]);
|
|
expect(s.asc.res.get('b-8')!.rels.appStoreVersion).toBe('ver-1');
|
|
expect(slept).toEqual([15_000, 30_000]); // 15 s after the first "not ready", 30 s after the second
|
|
expect(r.out).toContain('not ready to be submitted yet, no item left REJECTED (try 1');
|
|
expect(r.out).toContain('accepted on try 3');
|
|
// a re-run sends nothing: the submission waits for review now
|
|
const b = s.calls.length;
|
|
const again = await capture(() => appleResubmit(p.ctx(['--build', '8', '--yes'])));
|
|
expect(again.exit).toBeFalsy();
|
|
expect(shown(b)).toEqual([]);
|
|
expect(again.out).toContain('nothing to resubmit');
|
|
});
|
|
|
|
test('"not ready" until the timeout: a clear stop that names the submission, no other write', async () => {
|
|
rejected({ pro: true });
|
|
s.asc.res.get('b-7')!.rels = { app: APP }; // build 8 attached already
|
|
s.asc.res.get('b-8')!.rels = { app: APP, appStoreVersion: 'ver-1' };
|
|
s.faults.push({ ...NOT_READY, times: 100 });
|
|
const p = project(CONFIG);
|
|
await expect(appleResubmit(p.ctx(['--build', '8', '--yes']))).rejects.toThrow(/rs-old: Apple still answers "not ready to be submitted yet" after \d+ s and \d+ tries[\s\S]*re-run the same command/);
|
|
expect(s.asc.all('reviewSubmissions')).toHaveLength(1);
|
|
expect(slept.reduce((x, y) => x + y, 0)).toBeLessThanOrEqual(submitTiming.timeoutMs);
|
|
expect(Math.max(...slept)).toBeLessThanOrEqual(submitTiming.heartbeatMs);
|
|
});
|
|
|
|
test('a REJECTED IAP item is left without --resolve-iaps: one submit, then a stop naming it (no blind retry); --resolve-iaps resolves it', async () => {
|
|
rejected({ gems: 'REJECTED', pro: true });
|
|
const p = project(CONFIG);
|
|
const plan = await capture(() => appleResubmit(p.ctx(['--build', '8'])));
|
|
expect(plan.out).toMatch(/item\s+com\.example\.notes\.gems\s+REJECTED\s+REJECTED, left: .*--resolve-iaps/);
|
|
const a = s.calls.length;
|
|
await expect(appleResubmit(p.ctx(['--build', '8', '--yes']))).rejects.toThrow(/still REJECTED[\s\S]*com\.example\.notes\.gems \(item it-gems\): .*--resolve-iaps/);
|
|
expect(shown(a)).toEqual(['PATCH /v1/appStoreVersions/ver-1/relationships/build', 'PATCH /v1/reviewSubmissionItems/it-ver', 'PATCH /v1/reviewSubmissions/rs-old']);
|
|
expect(slept).toEqual([]);
|
|
expect(s.asc.res.get('it-gems')!.attributes.state).toBe('REJECTED');
|
|
expect(s.asc.res.get('rs-old')!.attributes.state).toBe('UNRESOLVED_ISSUES');
|
|
// with the flag: the plan says so, and the IAP item is marked resolved before the submit
|
|
const plan2 = await capture(() => appleResubmit(p.ctx(['--build', '8', '--resolve-iaps'])));
|
|
expect(plan2.out).toMatch(/item\s+com\.example\.notes\.gems\s+REJECTED\s+in it already; mark resolved/);
|
|
const b = s.calls.length;
|
|
const r = await capture(() => appleResubmit(p.ctx(['--build', '8', '--resolve-iaps', '--yes'])));
|
|
expect([r.exit ?? 0, r.err]).toEqual([0, '']);
|
|
expect(shown(b)).toEqual(['PATCH /v1/reviewSubmissionItems/it-gems', 'PATCH /v1/reviewSubmissions/rs-old']);
|
|
expect(s.asc.res.get('rs-old')!.attributes.state).toBe('WAITING_FOR_REVIEW');
|
|
});
|
|
|
|
test('a build that is not VALID or not there is refused before anything is sent', async () => {
|
|
rejected();
|
|
s.asc.res.get('b-8')!.attributes.processingState = 'PROCESSING';
|
|
const p = project(CONFIG);
|
|
const a = s.calls.length;
|
|
const r = await capture(() => appleResubmit(p.ctx(['--build', '8', '--yes'])));
|
|
expect(r.exit).toBe(1);
|
|
expect(r.err).toContain('build 8 is PROCESSING');
|
|
expect(shown(a)).toEqual([]);
|
|
await expect(appleResubmit(p.ctx(['--yes']))).rejects.toThrow(/--build <n>/);
|
|
});
|
|
});
|
|
|
|
describe('apple submit --build <n> (a first submission, the desktop owner action)', () => {
|
|
/** Version 1.0.0 in Prepare for Submission without a build, build 8 VALID, the gems IAP ready, no open submission. */
|
|
function fresh(): void {
|
|
onSale();
|
|
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } });
|
|
s.asc.put({ type: 'inAppPurchases', id: 'iap-gems', attributes: { productId: 'com.example.notes.gems', inAppPurchaseType: 'CONSUMABLE', state: 'READY_TO_SUBMIT' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'inAppPurchaseVersions', id: 'iapv-gems', attributes: { version: 1, state: 'PREPARE_FOR_SUBMISSION' }, rels: { inAppPurchase: 'iap-gems' } });
|
|
}
|
|
const ONE: ShipConfig = { ...CONFIG, products: CONFIG.products!.slice(0, 1) };
|
|
|
|
test('dry run: plan table (build attach, version + IAP), nothing sent; under serve the table comes with the --json result', async () => {
|
|
fresh();
|
|
const p = project(ONE);
|
|
const a = s.calls.length;
|
|
const r = await capture(() => appleSubmit(p.ctx(['--build', '8'])));
|
|
expect(r.exit).toBeFalsy();
|
|
expect(shown(a)).toEqual([]);
|
|
expect(r.out).toMatch(/build\s+build 8\s+none attached\s+attach build 8/);
|
|
expect(r.out).toMatch(/submission\s+new draft\s+-\s+create/);
|
|
expect(r.out).toMatch(/app version 1\.0\.0/);
|
|
expect(r.out).toContain('com.example.notes.gems');
|
|
expect(r.out).toContain('update of an app on sale');
|
|
process.env.STOREBOUND_MACHINE = '1';
|
|
try {
|
|
const j = await capture(() => appleSubmit(p.ctx(['--build', '8', '--json'])));
|
|
expect(j.out).toContain('STEP');
|
|
const line = j.out.split('\n').find(l => l.includes('storebound-result'))!;
|
|
expect(JSON.parse(line.slice(line.indexOf('{')))).toMatchObject({ dryRun: true, version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION' }, build: { action: 'attach', id: 'b-8' }, blockers: [] });
|
|
} finally { delete process.env.STOREBOUND_MACHINE; }
|
|
expect(shown(a)).toEqual([]);
|
|
});
|
|
|
|
test('macOS with apple.macos.noIap and no Mac Game Center: version and build only, the skips named', async () => {
|
|
fresh();
|
|
s.asc.put({ type: 'appStoreVersions', id: 'ver-m', attributes: { versionString: '2.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'MAC_OS' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'builds', id: 'b-13', attributes: { version: '13', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } });
|
|
const p = project({ ...ONE, apple: { ...ONE.apple!, macos: { noIap: true } } });
|
|
const a = s.calls.length;
|
|
const r = await capture(() => appleSubmit(p.ctx(['--build', '13', '--platform', 'macos'])));
|
|
expect(r.out).toContain('1 in-app purchase(s) left out: apple.macos.noIap');
|
|
expect(r.out).not.toContain('com.example.notes.gems');
|
|
expect(r.out).toMatch(/app version 2\.0\.0/);
|
|
expect(shown(a)).toEqual([]);
|
|
expect(s.calls.slice(a).some(c => /gameCenter/.test(c.path))).toBe(false);
|
|
});
|
|
test('macosSubmitSkips: only MAC_OS, gameCenter true keeps the switch', async () => {
|
|
const { macosSubmitSkips } = await import('../cli/lib/config');
|
|
expect(macosSubmitSkips({ apple: { ...ONE.apple!, macos: { noIap: true } } }, 'IOS')).toEqual({ iaps: null, gameCenter: null });
|
|
expect(macosSubmitSkips({ apple: { ...ONE.apple!, macos: { gameCenter: true } } }, 'MAC_OS')).toEqual({ iaps: null, gameCenter: null });
|
|
expect(macosSubmitSkips({ apple: ONE.apple! }, 'MAC_OS').gameCenter).toContain('no Game Center');
|
|
});
|
|
|
|
test('--yes: build attached, one draft with the version and the IAP, submitted; a re-run sends nothing', async () => {
|
|
fresh();
|
|
const p = project(ONE);
|
|
const a = s.calls.length;
|
|
const r = await capture(() => appleSubmit(p.ctx(['--build', '8', '--yes'])));
|
|
expect([r.exit ?? 0, r.err]).toEqual([0, '']);
|
|
const sid = s.asc.all('reviewSubmissions')[0]!.id;
|
|
expect(shown(a)).toEqual(['PATCH /v1/appStoreVersions/ver-1/relationships/build', 'POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems', 'POST /v1/reviewSubmissionItems', `PATCH /v1/reviewSubmissions/${sid}`]);
|
|
expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['WAITING_FOR_REVIEW']);
|
|
const b = s.calls.length;
|
|
const again = await capture(() => appleSubmit(p.ctx(['--build', '8', '--yes'])));
|
|
expect(again.exit).toBeFalsy();
|
|
expect(shown(b)).toEqual([]);
|
|
});
|
|
|
|
test('--no-submit --yes, then --yes: the second step submits the prepared draft that holds everything already', async () => {
|
|
fresh();
|
|
const p = project(ONE);
|
|
await capture(() => appleSubmit(p.ctx(['--build', '8', '--no-submit', '--yes'])));
|
|
const sid = s.asc.all('reviewSubmissions')[0]!.id;
|
|
expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['READY_FOR_REVIEW']);
|
|
expect(s.asc.children(sid, 'reviewSubmissionItems')).toHaveLength(2);
|
|
s.faults.push({ ...NOT_READY, match: new RegExp(`^/v1/reviewSubmissions/${sid}$`), times: 1 });
|
|
const a = s.calls.length;
|
|
const r = await capture(() => appleSubmit(p.ctx(['--build', '8', '--yes'])));
|
|
expect([r.exit ?? 0, r.err]).toEqual([0, '']);
|
|
expect(r.out).toContain('in it already');
|
|
expect(r.out).not.toContain('nothing to submit');
|
|
expect(shown(a)).toEqual([`PATCH /v1/reviewSubmissions/${sid}`, `PATCH /v1/reviewSubmissions/${sid}`]);
|
|
expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['WAITING_FOR_REVIEW']);
|
|
expect(r.out).toContain(`review submission ${sid} submitted`);
|
|
});
|
|
|
|
test('a live version is not submitted; no editable version is a clear stop', async () => {
|
|
fresh();
|
|
s.asc.res.get('ver-1')!.attributes.appVersionState = 'READY_FOR_SALE';
|
|
const p = project(ONE);
|
|
await expect(appleSubmit(p.ctx(['--build', '8', '--version', '1.0.0']))).rejects.toThrow(/READY_FOR_SALE: only a version in Prepare for Submission/);
|
|
await expect(appleSubmit(p.ctx(['--build', '8']))).rejects.toThrow(/no iOS App Store version to submit/);
|
|
});
|
|
});
|
|
|
|
describe('apple iap-submit with a submission back from App Review', () => {
|
|
test('--with-version --yes reuses UNRESOLVED_ISSUES: no new draft, the missing item only, then resubmitted; a re-run writes nothing', async () => {
|
|
rejected({ ver: 'READY_FOR_REVIEW' }); // the version item was resolved already
|
|
s.faults.push({ ...NOT_READY, times: 1 });
|
|
const p = project(CONFIG);
|
|
const a = s.calls.length;
|
|
const r = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--yes'])));
|
|
expect([r.exit ?? 0, r.err]).toEqual([0, '']);
|
|
expect(shown(a)).toEqual(['POST /v1/reviewSubmissionItems', 'PATCH /v1/reviewSubmissions/rs-old', 'PATCH /v1/reviewSubmissions/rs-old']);
|
|
expect(r.out).toContain('UNRESOLVED_ISSUES');
|
|
expect(r.out).toContain('resubmitted');
|
|
expect(s.asc.all('reviewSubmissions')).toHaveLength(1);
|
|
const b = s.calls.length;
|
|
const again = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--yes'])));
|
|
expect(again.exit).toBeFalsy();
|
|
expect(shown(b)).toEqual([]);
|
|
expect(again.out).toContain('nothing to submit');
|
|
});
|
|
|
|
test('a REJECTED version item: a warning in the plan, one submit, then a stop that points to apple resubmit (no blind retry)', async () => {
|
|
rejected({ pro: true });
|
|
const p = project(CONFIG);
|
|
const dry = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--dry'])));
|
|
expect(dry.err).toContain('app version 1.0.0 is REJECTED in rs-old: Apple takes no submit while it is not resolved; attach a fixed build and mark it resolved: storebound apple resubmit --build <n>');
|
|
const a = s.calls.length;
|
|
await expect(appleIapSubmit(p.ctx(['--with-version', '--yes'])))
|
|
.rejects.toThrow(/app version 1\.0\.0 \(item it-ver\): attach a fixed build and mark it resolved: storebound apple resubmit --build <n>/);
|
|
expect(shown(a)).toEqual(['PATCH /v1/reviewSubmissions/rs-old']);
|
|
expect(slept).toEqual([]);
|
|
expect(s.asc.res.get('it-ver')!.attributes.state).toBe('REJECTED');
|
|
});
|
|
|
|
test('every item in it already: resubmitted as it is (the submit alone)', async () => {
|
|
rejected({ pro: true, ver: 'READY_FOR_REVIEW' });
|
|
const p = project(CONFIG);
|
|
const a = s.calls.length;
|
|
const r = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--yes'])));
|
|
expect([r.exit ?? 0, r.err]).toEqual([0, '']);
|
|
expect(shown(a)).toEqual(['PATCH /v1/reviewSubmissions/rs-old']);
|
|
});
|
|
|
|
test('a 409 ITEM_PART_OF_ANOTHER_SUBMISSION (list missed rs-old): clear message, own empty draft left (not cancellable), rs-old used', async () => {
|
|
rejected({ ver: 'READY_FOR_REVIEW' });
|
|
// the list read does not show rs-old (e.g. a state outside the filter): the run creates a draft, the add answers 409
|
|
s.asc.res.get('rs-old')!.attributes.platform = 'MAC_OS';
|
|
const p = project(CONFIG);
|
|
const a = s.calls.length;
|
|
const r = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--yes'])));
|
|
expect(r.exit).toBeFalsy();
|
|
expect(r.err).toContain('app version 1.0.0 is already in another review submission, rs-old');
|
|
expect(`${r.out}\n${r.err}`).not.toContain('lag');
|
|
const w = shown(a);
|
|
const created = s.asc.all('reviewSubmissions').find(x => x.id !== 'rs-old')!;
|
|
expect(w).toEqual(['POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems', 'POST /v1/reviewSubmissionItems', 'PATCH /v1/reviewSubmissions/rs-old']);
|
|
expect(created.attributes.state).toBe('READY_FOR_REVIEW');
|
|
expect(r.out).toContain(`draft ${created.id} created by this run was not used: empty draft (harmless; App Store Connect keeps it, not cancellable)`);
|
|
expect(s.asc.res.get('rs-old')!.attributes.state).toBe('WAITING_FOR_REVIEW');
|
|
});
|
|
|
|
test('a submission waiting for review: nothing sent, said so', async () => {
|
|
rejected();
|
|
s.asc.res.get('rs-old')!.attributes.state = 'WAITING_FOR_REVIEW';
|
|
const p = project(CONFIG);
|
|
const a = s.calls.length;
|
|
const r = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--yes'])));
|
|
expect(r.exit).toBeFalsy();
|
|
expect(shown(a)).toEqual([]);
|
|
expect(r.out).toContain('rs-old is WAITING_FOR_REVIEW');
|
|
});
|
|
});
|
|
|
|
// Seen live (2026-10-12): the owner canceled a WAITING_FOR_REVIEW submission; the app version went DEVELOPER_REJECTED,
|
|
// then PREPARE_FOR_SUBMISSION, every in-app purchase version stayed DEVELOPER_REJECTED. iap-submit skipped them all
|
|
// ("change it in App Store Connect (or --new-version)"), but Apple takes the same versions again as items (201
|
|
// READY_FOR_REVIEW) and the submit (200 WAITING_FOR_REVIEW); an empty draft that was open next to it is reused.
|
|
describe('after the owner canceled a submission (DEVELOPER_REJECTED versions)', () => {
|
|
/** rs-sub WAITING_FOR_REVIEW with the version and both IAPs, an empty draft next to it, then canceled in the fake. */
|
|
function canceled(): void {
|
|
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'WAITING_FOR_REVIEW', platform: 'IOS' }, rels: { app: APP } });
|
|
for (const [k, t] of [['gems', 'CONSUMABLE'], ['pro', 'NON_CONSUMABLE']] as const) {
|
|
s.asc.put({ type: 'inAppPurchases', id: `iap-${k}`, attributes: { productId: `com.example.notes.${k}`, inAppPurchaseType: t, state: 'WAITING_FOR_REVIEW' }, rels: { app: APP } });
|
|
s.asc.put({ type: 'inAppPurchaseVersions', id: `iapv-${k}`, attributes: { version: 1, state: 'WAITING_FOR_REVIEW' }, rels: { inAppPurchase: `iap-${k}` } });
|
|
}
|
|
s.asc.put({ type: 'reviewSubmissions', id: 'rs-sub', 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-sub', appStoreVersion: 'ver-1' } });
|
|
for (const k of ['gems', 'pro']) s.asc.put({ type: 'reviewSubmissionItems', id: `it-${k}`, attributes: { state: 'READY_FOR_REVIEW' }, rels: { reviewSubmission: 'rs-sub', inAppPurchaseVersion: `iapv-${k}` } });
|
|
s.asc.put({ type: 'reviewSubmissions', id: 'rs-empty', attributes: { platform: 'IOS', state: 'READY_FOR_REVIEW' }, rels: { app: APP } });
|
|
expect(s.asc.handle('PATCH', '/v1/reviewSubmissions/rs-sub', { data: { attributes: { canceled: true } } }).status).toBe(200);
|
|
}
|
|
|
|
test('the fake: a canceled submission leaves DEVELOPER_REJECTED versions, which are accepted again as items; APPROVED ones are not', () => {
|
|
canceled();
|
|
expect(s.asc.res.get('iapv-gems')!.attributes.state).toBe('DEVELOPER_REJECTED');
|
|
expect(s.asc.res.get('ver-1')!.attributes.appVersionState).toBe('PREPARE_FOR_SUBMISSION');
|
|
const add = (vid: string): Response => s.asc.handle('POST', '/v1/reviewSubmissionItems', { data: { type: 'reviewSubmissionItems',
|
|
relationships: { reviewSubmission: { data: { type: 'reviewSubmissions', id: 'rs-empty' } }, inAppPurchaseVersion: { data: { type: 'inAppPurchaseVersions', id: vid } } } } });
|
|
expect(add('iapv-gems').status).toBe(201);
|
|
s.asc.res.get('iapv-pro')!.attributes.state = 'APPROVED';
|
|
expect(add('iapv-pro').status).toBe(409);
|
|
});
|
|
|
|
test('plan: DEVELOPER_REJECTED IAP, subscription and group versions go in as they are; --new-version stays for APPROVED / REJECTED', () => {
|
|
const st: SubmitState = {
|
|
appVersions: [{ id: 'v-1', versionString: '1.0.0', state: 'DEVELOPER_REJECTED' }],
|
|
iaps: [{ id: 'i-1', productId: 'com.example.game.gems', type: 'CONSUMABLE', state: 'READY_TO_SUBMIT', versions: [{ id: 'iv-1', version: 1, state: 'DEVELOPER_REJECTED' }] },
|
|
{ id: 'i-2', productId: 'com.example.game.old', type: 'CONSUMABLE', state: 'APPROVED', versions: [{ id: 'iv-2', version: 1, state: 'APPROVED' }] }],
|
|
subs: [{ id: 's-1', productId: 'com.example.game.plus', groupId: 'g-1', state: 'READY_TO_SUBMIT', versions: [{ id: 'sv-1', version: 1, state: 'DEVELOPER_REJECTED' }] }],
|
|
groups: [{ id: 'g-1', name: 'Plus', versions: [{ id: 'gv-1', version: 1, state: 'DEVELOPER_REJECTED' }] }],
|
|
draft: { id: 'rs-empty', itemVersionIds: [], state: 'READY_FOR_REVIEW' },
|
|
};
|
|
const plan = planIapSubmission(st, ['com.example.game.gems', 'com.example.game.plus', 'com.example.game.old'], { withVersion: true });
|
|
expect(plan.blockers).toEqual([]);
|
|
expect(plan.items.map(i => [i.kind, i.versionId, !!i.devRejected])).toEqual([['appStoreVersion', 'v-1', true], ['inAppPurchaseVersion', 'iv-1', true],
|
|
['subscriptionVersion', 'sv-1', true], ['subscriptionGroupVersion', 'gv-1', true]]);
|
|
expect(plan.skipped).toEqual([{ id: 'com.example.game.old', why: 'latest version is APPROVED: add --new-version to send a new version' }]);
|
|
expect(plan.prepare.map(r => r.path)).not.toContain('/v1/reviewSubmissions');
|
|
const rows = resubmitRows(st, plan, { number: '8', id: 'b-8', current: 'b-8', action: 'same', reason: 'attached already' });
|
|
expect(rows.filter(r => r.step === 'item').map(r => r.action)).toEqual(Array(4).fill('add (developer rejected, re-submitted as is)'));
|
|
});
|
|
|
|
test('iap-submit --with-version: the dry plan adds them as they are; a real run fills the empty draft and submits it, no new version, no new draft', async () => {
|
|
canceled();
|
|
const p = project(CONFIG);
|
|
const a = s.calls.length;
|
|
const dry = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--new-version', '--dry'])));
|
|
expect([dry.exit ?? 0, dry.err]).toEqual([0, '']);
|
|
expect(shown(a)).toEqual([]);
|
|
expect(dry.out).toContain('draft review submission rs-empty reused');
|
|
expect(dry.out).toContain('add (developer rejected, re-submitted as is): com.example.notes.gems (consumable, version 1)');
|
|
expect(dry.out).toContain('add (developer rejected, re-submitted as is): com.example.notes.pro (non-consumable, version 1)');
|
|
expect(dry.out).not.toContain('skip');
|
|
expect(dry.out).not.toContain('nothing to submit');
|
|
expect(dry.out).not.toContain('/v1/inAppPurchaseVersions');
|
|
const b = s.calls.length;
|
|
const r = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--yes'])));
|
|
expect([r.exit ?? 0, r.err]).toEqual([0, '']);
|
|
expect(shown(b)).toEqual(['POST /v1/reviewSubmissionItems', 'POST /v1/reviewSubmissionItems', 'POST /v1/reviewSubmissionItems', 'PATCH /v1/reviewSubmissions/rs-empty']);
|
|
expect(s.asc.all('reviewSubmissions').map(x => [x.id, x.attributes.state])).toEqual([['rs-sub', 'CANCELING'], ['rs-empty', 'WAITING_FOR_REVIEW']]);
|
|
expect(s.asc.children('rs-empty', 'reviewSubmissionItems').map(i => Object.values(i.rels).find(v => v !== 'rs-empty'))).toEqual(['ver-1', 'iapv-gems', 'iapv-pro']);
|
|
});
|
|
});
|
|
|
|
describe('apple status', () => {
|
|
test('an UNRESOLVED_ISSUES submission with its items and the resubmit command', async () => {
|
|
rejected();
|
|
const p = project(CONFIG);
|
|
const r = await capture(() => appleStatus(p.ctx([])));
|
|
expect(r.out).toContain('review submission rs-old (iOS) is back from App Review with UNRESOLVED_ISSUES');
|
|
expect(r.out).toMatch(/app version 1\.0\.0 \(iOS\)\s+REJECTED/);
|
|
expect(r.out).toMatch(/inAppPurchaseVersion iapv-gems\s+READY_FOR_REVIEW/);
|
|
expect(r.out).toContain('resubmit with: storebound apple resubmit --build <n>');
|
|
expect(r.out).toMatch(/app version 1\.0\.0 \(iOS\)\s+REJECTED \(not resolved/);
|
|
});
|
|
|
|
test('an empty draft is labelled harmless; --cancel-submission on it answers 409 "not in cancellable state", left as is', async () => {
|
|
s.asc.put({ type: 'reviewSubmissions', id: 'rs-empty', attributes: { platform: 'IOS', state: 'READY_FOR_REVIEW' }, rels: { app: APP } });
|
|
const p = project(CONFIG);
|
|
const r = await capture(() => appleStatus(p.ctx([])));
|
|
expect(r.out).toContain('review submission rs-empty iOS READY_FOR_REVIEW: empty draft (harmless; App Store Connect keeps it, not cancellable)');
|
|
const c = await capture(() => appleStatus(p.ctx(['--cancel-submission', 'rs-empty', '--yes'])));
|
|
expect([c.exit ?? 0, c.err]).toEqual([0, '']);
|
|
expect(c.out).toContain('rs-empty is not cancellable');
|
|
expect(s.asc.res.get('rs-empty')!.attributes.state).toBe('READY_FOR_REVIEW');
|
|
});
|
|
});
|
|
|
|
test('formatRows aligns the columns', () => {
|
|
expect(formatRows([{ step: 'build', what: 'build 8', now: 'attached: 7', action: 'attach' }]).split('\n')).toEqual([
|
|
'STEP WHAT NOW ACTION', 'build build 8 attached: 7 attach']);
|
|
});
|