apple resubmit after a rejection: iap-submit reuses the open review submission (UNRESOLVED_ISSUES gets only missing items and is resubmitted, a draft is reused, one waiting for or in review means nothing is sent), never a second draft; 409 ITEM_PART_OF_ANOTHER_SUBMISSION names the other submission instead of the list-lag message, a draft the run created is canceled and the named one used; submit retried with backoff and heartbeat while Apple says not ready yet; new apple resubmit --build <n> (build by number, plan table, --yes); apple status lists UNRESOLVED_ISSUES items and the resubmit command. Pitfall 30

This commit is contained in:
CRTkafa 2026-10-12 10:33:48 +08:00
parent c8213be2b6
commit 0da5ee9d01
21 changed files with 726 additions and 80 deletions

View file

@ -7,7 +7,7 @@
import { json, type Call } from './store-mock';
export type R = { type: string; id: string; attributes: Record<string, unknown>; rels: Record<string, string | string[]>; order: number; hiddenReads?: number };
export interface Fault { method: string; match: RegExp; when: 'before' | 'after'; status?: number; times?: number }
export interface Fault { method: string; match: RegExp; when: 'before' | 'after'; status?: number; times?: number; code?: string; detail?: string }
const ASC = 'https://api.appstoreconnect.apple.com';
const UPLOAD_HOST = 'https://upload.example.com/';
@ -53,6 +53,7 @@ const LISTS: Record<string, { type: string; one?: boolean }> = {
'appStoreVersionExperimentTreatments/appStoreVersionExperimentTreatmentLocalizations': { type: 'appStoreVersionExperimentTreatmentLocalizations' },
'appStoreVersionExperimentTreatmentLocalizations/appScreenshotSets': { type: 'appScreenshotSets' },
'reviewSubmissions/items': { type: 'reviewSubmissionItems' },
'apps/reviewSubmissions': { type: 'reviewSubmissions' },
'apps/buildUploads': { type: 'buildUploads' },
'inAppPurchases/versions': { type: 'inAppPurchaseVersions' },
'subscriptions/versions': { type: 'subscriptionVersions' },
@ -155,6 +156,14 @@ export class FakeAsc {
Array.isArray(v.data) ? (v.data as { id: string }[]).map(d => temp.get(d.id) ?? d.id) : temp.get((v.data as { id: string }).id) ?? (v.data as { id: string }).id]));
const d = body.data;
const main: Omit<R, 'order'> = { type: d.type, id: idFor(d.type), attributes: { ...DEFAULTS[d.type], ...(d.attributes ?? {}) }, rels: relsOf(d.relationships) };
// App Store Connect keeps an item in one open review submission only
if (d.type === 'reviewSubmissionItems') {
const [kind, vid] = Object.entries(main.rels).find(([k]) => k !== 'reviewSubmission') ?? [];
const holder = this.all('reviewSubmissionItems').find(r => r.rels[kind ?? ''] === vid && r.rels.reviewSubmission !== main.rels.reviewSubmission
&& !['COMPLETE', 'CANCELING'].includes(String(this.res.get(String(r.rels.reviewSubmission))?.attributes.state)));
if (holder) return json({ errors: [{ status: '409', code: 'STATE_ERROR.ITEM_PART_OF_ANOTHER_SUBMISSION', title: 'The item is already part of another submission',
detail: `${this.res.get(String(vid))?.type ?? kind} with id ${vid} was already added to another reviewSubmission with id ${holder.rels.reviewSubmission}` }] }, 409);
}
const u = UNIQUE[d.type];
if (u && this.all(d.type).some(r => u(r) === u(main as R)))
return json({ errors: [{ status: '409', code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE', title: 'The provided entity includes an attribute with a value that has already been used', detail: 'already exists' }] }, 409);
@ -252,6 +261,10 @@ export class FakeAsc {
const { uploaded: _u, ...rest } = attrs;
Object.assign(self.attributes, rest, { assetDeliveryState: { state: this.parts.includes(self.id) ? 'COMPLETE' : 'FAILED' } });
delete self.attributes.uploadOperations;
} else if (self.type === 'reviewSubmissions' && (attrs.submitted === true || attrs.canceled === true)) {
// submitting moves the submission (and its items) on; canceling ends it
self.attributes.state = attrs.submitted ? 'WAITING_FOR_REVIEW' : 'CANCELING';
if (attrs.submitted) for (const it of this.children(self.id, 'reviewSubmissionItems')) it.attributes.state = 'READY_FOR_REVIEW';
} else Object.assign(self.attributes, attrs);
return json({ data: this.view(self) });
}
@ -413,7 +426,7 @@ export function installStores(): Stores {
s.calls.push({ method, url, path, body });
const f = s.faults.find(x => (x.times ?? 1) > 0 && x.method === method && x.match.test(decodeURIComponent(path)));
if (f) f.times = (f.times ?? 1) - 1;
if (f?.when === 'before') return json({ errors: [{ status: String(f.status ?? 503), detail: 'injected' }] }, f.status ?? 503);
if (f?.when === 'before') return json({ errors: [{ status: String(f.status ?? 503), ...(f.code ? { code: f.code } : {}), detail: f.detail ?? 'injected' }] }, f.status ?? 503);
let res: Response;
if (url.startsWith(UPLOAD_HOST)) {
partNo++;

222
tests/resubmit.test.ts Normal file
View file

@ -0,0 +1,222 @@
// 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.
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import type { ShipConfig } from '../cli/lib/config';
import { chooseSubmission, formatRows, type OpenSub } 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 { 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. */
function rejected(o: { pro?: boolean } = {}): void {
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: 'REJECTED' }, rels: { reviewSubmission: 'rs-old', appStoreVersion: 'ver-1' } });
s.asc.put({ type: 'reviewSubmissionItems', id: 'it-gems', attributes: { state: '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/);
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: [] });
expect(shown(a)).toEqual([]);
});
test('--yes: build attached, rs-old reused (no new draft), only the missing IAP added, submit retried after "not ready"', async () => {
rejected();
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/reviewSubmissions/rs-old', 'PATCH /v1/reviewSubmissions/rs-old', 'PATCH /v1/reviewSubmissions/rs-old']);
expect(s.asc.all('reviewSubmissions').map(x => [x.id, x.attributes.state])).toEqual([['rs-old', 'WAITING_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 (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 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 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();
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('every item in it already: resubmitted as it is (the submit alone)', async () => {
rejected({ pro: true });
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 canceled, rs-old used', async () => {
rejected();
// 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.slice(0, 3)).toEqual(['POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems', `PATCH /v1/reviewSubmissions/${created.id}`]);
expect(created.attributes.state).toBe('CANCELING');
expect(w.slice(3)).toEqual(['POST /v1/reviewSubmissionItems', 'PATCH /v1/reviewSubmissions/rs-old']);
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');
});
});
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 is back from App Review with UNRESOLVED_ISSUES');
expect(r.out).toMatch(/app version 1\.0\.0\s+REJECTED/);
expect(r.out).toMatch(/inAppPurchaseVersion iapv-gems\s+READY_FOR_REVIEW/);
expect(r.out).toContain('resubmit with: storebound apple resubmit --build <n>');
});
});
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']);
});

View file

@ -150,6 +150,7 @@ describe('IAP review submission plan', () => {
{ 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' } } },