From 21fa91afd166232ab286785c7b4231d64b3b4ef9 Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 02:08:39 +0800 Subject: [PATCH] onboard reads review access from the live App Store version (READY_FOR_SALE / READY_FOR_DISTRIBUTION, newest live first; the newest version only when nothing is live): a newer draft's empty review detail no longer hides the live demo account or turns signInRequired off; reviewVersion in cli/lib/onboard.ts, tests with a live version holding a demo account and a newer draft without --- cli/lib/onboard.ts | 11 +++++++++++ cli/onboard.ts | 6 ++++-- tests/onboard.test.ts | 29 ++++++++++++++++++++++++++--- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/cli/lib/onboard.ts b/cli/lib/onboard.ts index bc21746..6ef8a47 100644 --- a/cli/lib/onboard.ts +++ b/cli/lib/onboard.ts @@ -211,6 +211,17 @@ export function maskContact(c: ReviewContact): string { return `${c.firstName ? c.firstName[0] + '.' : ''}${c.lastName ? ` ${c.lastName[0]}.` : ''} ${maskUser(c.email)}${digits ? `, phone ***${digits.slice(-2)}` : ''}`.trim(); } +/** + * The App Store version whose review detail tells the truth about review access: the LIVE one (READY_FOR_SALE / + * READY_FOR_DISTRIBUTION; the newest live one when several report it), else the newest version. A newer draft's empty + * review detail must never hide the demo account the live version was reviewed with. + */ +export function reviewVersion(versions: T[]): T | null { + const LIVE = new Set(['READY_FOR_SALE', 'READY_FOR_DISTRIBUTION']); + const newest = [...versions].sort((a, b) => (b.attributes?.createdDate ?? '').localeCompare(a.attributes?.createdDate ?? '')); + return newest.find(v => LIVE.has(v.attributes?.appVersionState ?? '') || LIVE.has(v.attributes?.appStoreState ?? '')) ?? newest[0] ?? null; +} + // ---- sign-in for review ---- export interface SignInDecision { diff --git a/cli/onboard.ts b/cli/onboard.ts index 5dda5fb..92cebb5 100644 --- a/cli/onboard.ts +++ b/cli/onboard.ts @@ -22,7 +22,7 @@ import type { StoreItem } from './lib/catalog'; import { reviewContact, reviewDemoAccount } from './lib/credentials'; import type { Ctx } from './lib/ctx'; import { done, log, printJson, warn } from './lib/log'; -import { type AppleReviewDetail, contactProposal, type DeletionResult, findDeletionUrl, importCatalog, maskContact, type PageGet, reviewContactOf, signInDecision, type StoreContact } from './lib/onboard'; +import { type AppleReviewDetail, contactProposal, type DeletionResult, findDeletionUrl, importCatalog, maskContact, type PageGet, reviewContactOf, reviewVersion, signInDecision, type StoreContact } from './lib/onboard'; import { authEvidence } from './lib/project-signals'; import { detectProject } from './lib/scan/project'; import { upsertBlock, upsertNestedString } from './lib/scan/config-edit'; @@ -57,8 +57,10 @@ async function appleFacts(ctx: Ctx): Promise> const l = locs.find((x: J) => x.attributes.locale === primary) ?? locs[0]; supportUrl = l?.attributes?.supportUrl ?? undefined; marketingUrl = l?.attributes?.marketingUrl ?? undefined; - review = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`))?.data?.attributes ?? null; } + // review access comes from the live version's review detail (a newer draft's empty one must not hide its demo account) + const rv = reviewVersion(versions); + if (rv) review = (await c.maybe(`/v1/appStoreVersions/${rv.id}/appStoreReviewDetail`))?.data?.attributes ?? null; const info = await c.editableAppInfo(); const infoLocs = info ? await c.all(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=50`) : []; const il = infoLocs.find((x: J) => x.attributes.locale === primary) ?? infoLocs[0]; diff --git a/tests/onboard.test.ts b/tests/onboard.test.ts index f88a83f..a276a30 100644 --- a/tests/onboard.test.ts +++ b/tests/onboard.test.ts @@ -8,7 +8,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { catalogDiff, type StoreItem, wantedCatalog } from '../cli/lib/catalog'; import { loadConfig, type ShipConfig } from '../cli/lib/config'; -import { contactProposal, deletionLinks, findDeletionUrl, importCatalog, isTemplateValue, maskContact, signInDecision } from '../cli/lib/onboard'; +import { contactProposal, deletionLinks, findDeletionUrl, importCatalog, isTemplateValue, maskContact, reviewVersion, signInDecision } from '../cli/lib/onboard'; import { toTs } from '../cli/lib/scan/config-edit'; import { run as onboard } from '../cli/onboard'; import { run as catalog } from '../cli/catalog'; @@ -27,7 +27,7 @@ const money = (currencyCode: string, units: string, nanos = 0) => ({ currencyCod /** App Store Connect + Google Play as they answered for a live app (sanitized): Pro has its price schedule's base * territory in Turkey, Tips is on the App Store only, Plus monthly is a Play-only subscription. */ -function liveStores(o: { review?: Record } = {}): Call[] { +function liveStores(o: { review?: Record; draft?: boolean } = {}): Call[] { const P = '/androidpublisher/v3/applications/com.example.notes'; return mockFetch([ // ---- App Store Connect: catalog @@ -48,7 +48,13 @@ function liveStores(o: { review?: Record } = {}): Call[] { { match: /^\/v1\/apps\/6400000101\/subscriptionGroups/, answer: page([]) }, // ---- App Store Connect: app details and review detail { match: /^\/v1\/apps\/6400000101$/, answer: { data: { type: 'apps', id: '6400000101', attributes: { bundleId: 'com.example.notes', primaryLocale: 'en-US' } } } }, - { match: /^\/v1\/apps\/6400000101\/appStoreVersions\?filter\[platform\]=IOS/, answer: page([{ type: 'appStoreVersions', id: 'v1', attributes: { versionString: '2.3.0', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION' } }]) }, + // with `draft`: a newer 2.4.0 in PREPARE_FOR_SUBMISSION (listed first, as App Store Connect does) whose review detail is empty + { match: /^\/v1\/apps\/6400000101\/appStoreVersions\?filter\[platform\]=IOS/, answer: page([ + ...(o.draft ? [{ type: 'appStoreVersions', id: 'v2', attributes: { versionString: '2.4.0', appStoreState: 'PREPARE_FOR_SUBMISSION', appVersionState: 'PREPARE_FOR_SUBMISSION', createdDate: '2026-09-20T01:00:00-07:00' } }] : []), + { type: 'appStoreVersions', id: 'v1', attributes: { versionString: '2.3.0', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION', createdDate: '2026-09-01T01:00:00-07:00' } }]) }, + { match: /^\/v1\/appStoreVersions\/v2\/appStoreVersionLocalizations/, answer: page([{ type: 'appStoreVersionLocalizations', id: 'vl2', attributes: { locale: 'en-US', supportUrl: 'https://notes.example.org/help', marketingUrl: 'https://notes.example.org' } }]) }, + { match: /^\/v1\/appStoreVersions\/v2\/appStoreReviewDetail/, answer: { data: { type: 'appStoreReviewDetails', id: 'rd2', attributes: { contactFirstName: 'Ada', contactLastName: 'Example', contactPhone: '+1 555 0100 77', contactEmail: 'review@example.org', + demoAccountName: null, demoAccountPassword: null, demoAccountRequired: false, notes: null } } } }, { match: /^\/v1\/appStoreVersions\/v1\/appStoreVersionLocalizations/, answer: page([{ type: 'appStoreVersionLocalizations', id: 'vl1', attributes: { locale: 'en-US', supportUrl: 'https://notes.example.org/help', marketingUrl: 'https://notes.example.org' } }]) }, { match: /^\/v1\/appStoreVersions\/v1\/appStoreReviewDetail/, answer: { data: { type: 'appStoreReviewDetails', id: 'rd1', attributes: { contactFirstName: 'Ada', contactLastName: 'Example', contactPhone: '+1 555 0100 77', contactEmail: 'review@example.org', demoAccountName: 'demo-reviewer', demoAccountPassword: 'demo-secret-sentinel', demoAccountRequired: true, notes: 'Sign in with the demo account.', ...o.review } } } }, @@ -125,6 +131,12 @@ describe('contact, sign-in, deletion page (pure)', () => { expect(isTemplateValue('https://notes.example.org')).toBe(false); expect(maskContact({ firstName: 'Ada', lastName: 'Example', phone: '+1 555 0100 77', email: 'review@example.org' })).toBe('A. E. r***@example.org, phone ***77'); }); + test('review version: the live one, newest first; the newest only when nothing is live', () => { + const v = (id: string, state: string, createdDate: string) => ({ id, attributes: { appStoreState: state, appVersionState: state === 'READY_FOR_SALE' ? 'READY_FOR_DISTRIBUTION' : state, createdDate } }); + expect(reviewVersion([v('d', 'PREPARE_FOR_SUBMISSION', '2026-09-20'), v('l1', 'READY_FOR_SALE', '2026-08-01'), v('l2', 'READY_FOR_SALE', '2026-09-01')])?.id).toBe('l2'); + expect(reviewVersion([v('a', 'PREPARE_FOR_SUBMISSION', '2026-08-01'), v('b', 'WAITING_FOR_REVIEW', '2026-09-01')])?.id).toBe('b'); + expect(reviewVersion([])).toBeNull(); + }); test('sign-in: Apple review detail first, then the code, never a default yes', () => { expect(signInDecision({ auth: [], apple: null })).toMatchObject({ signInRequired: false, question: null }); expect(signInDecision({ auth: ['src/auth.ts:1'], apple: null })).toMatchObject({ signInRequired: null }); @@ -187,6 +199,17 @@ describe('storebound onboard (recorded live shapes)', () => { expect(d.exit).toBeNull(); expect(d.err + d.out).toContain('"errors": 0'); }); + test('a newer draft with an empty review detail: the live version demo account is imported and sign-in is required', async () => { + ascEnv(); playEnv(); + const p = liveProject(); + liveStores({ draft: true }); + const r = await capture(() => onboard(p.ctx(['--write'], ['write']))); + expect(r.out + r.err).not.toContain('demo-secret-sentinel'); + expect(r.out + r.err).toContain('sign-in for review: required (App Store review detail: demo account required (a demo account is set there))'); + const creds = JSON.parse(readFileSync(join(p.root, '.storebound', 'credentials.json'), 'utf8')); + expect(creds.reviewDemoAccount).toEqual({ username: 'demo-reviewer', password: 'demo-secret-sentinel' }); + expect((await loadConfig(p.loaded.path)).config.reviewAccess).toEqual({ signInRequired: true }); + }); test('without --write nothing is written; sign-in unproven becomes the one owner question', async () => { ascEnv(); playEnv(); const p = liveProject();