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++;