apple resubmit: REJECTED review submission items are marked resolved before the submit (PATCH reviewSubmissionItems resolved: true; the version item by default, IAP / subscription items with --resolve-iaps), plan table 'mark resolved' rows; on 409 'not ready yet' the items are re-read and a REJECTED item out of scope stops the run at once naming it and how to resolve it, backoff only when none is left; an empty READY_FOR_REVIEW draft is never canceled (409 'not in cancellable state'): left as is, no cancel command, apple status labels it harmless and treats the cancel 409 as such; fake ASC blocks submit on REJECTED items and refuses to cancel empty drafts. Pitfall 30
This commit is contained in:
parent
0da5ee9d01
commit
1724296e59
17 changed files with 291 additions and 78 deletions
|
|
@ -262,9 +262,20 @@ export class FakeAsc {
|
|||
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)) {
|
||||
// seen live (2026-10-12): a REJECTED item that is not marked resolved blocks every submit with this 409, and an
|
||||
// empty draft cannot be canceled
|
||||
const items = this.children(self.id, 'reviewSubmissionItems');
|
||||
if (attrs.submitted && items.some(it => it.attributes.state === 'REJECTED'))
|
||||
return json({ errors: [{ status: '409', code: 'STATE_ERROR', title: 'The request cannot be fulfilled because of the state of another resource.', detail: 'Version is not ready to be submitted yet, please try again later' }] }, 409);
|
||||
if (attrs.canceled && self.attributes.state === 'READY_FOR_REVIEW' && !items.length)
|
||||
return json({ errors: [{ status: '409', code: 'STATE_ERROR', title: 'The request cannot be fulfilled because of the state of another resource.', detail: 'Resource is not in cancellable state' }] }, 409);
|
||||
// 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 if (self.type === 'reviewSubmissionItems' && attrs.resolved === true) {
|
||||
// marking a REJECTED item resolved puts it back to READY_FOR_REVIEW (seen live)
|
||||
if (self.attributes.state === 'REJECTED') self.attributes.state = 'READY_FOR_REVIEW';
|
||||
self.attributes.resolved = true;
|
||||
} else Object.assign(self.attributes, attrs);
|
||||
return json({ data: this.view(self) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
// "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 } from '../cli/lib/review-submit';
|
||||
|
|
@ -42,7 +44,7 @@ const CONFIG: ShipConfig = {
|
|||
|
||||
/** 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 {
|
||||
function rejected(o: { pro?: boolean; ver?: string; gems?: string } = {}): 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 } });
|
||||
|
|
@ -51,8 +53,8 @@ function rejected(o: { pro?: boolean } = {}): void {
|
|||
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' } });
|
||||
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' } });
|
||||
}
|
||||
|
||||
|
|
@ -90,28 +92,36 @@ describe('apple resubmit --build <n>', () => {
|
|||
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+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: [] });
|
||||
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, submit retried after "not ready"', async () => {
|
||||
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',
|
||||
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']);
|
||||
expect(s.asc.all('reviewSubmissions').map(x => [x.id, x.attributes.state])).toEqual([['rs-old', 'WAITING_FOR_REVIEW']]);
|
||||
// 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 (try 1');
|
||||
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;
|
||||
|
|
@ -133,6 +143,27 @@ describe('apple resubmit --build <n>', () => {
|
|||
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';
|
||||
|
|
@ -148,7 +179,7 @@ describe('apple resubmit --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();
|
||||
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;
|
||||
|
|
@ -165,17 +196,30 @@ describe('apple iap-submit with a submission back from App Review', () => {
|
|||
expect(again.out).toContain('nothing to submit');
|
||||
});
|
||||
|
||||
test('every item in it already: resubmitted as it is (the submit alone)', async () => {
|
||||
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 canceled, rs-old used', async () => {
|
||||
rejected();
|
||||
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);
|
||||
|
|
@ -186,9 +230,9 @@ describe('apple iap-submit with a submission back from App Review', () => {
|
|||
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(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');
|
||||
});
|
||||
|
||||
|
|
@ -213,6 +257,18 @@ describe('apple status', () => {
|
|||
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>');
|
||||
expect(r.out).toMatch(/app version 1\.0\.0\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 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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue