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
This commit is contained in:
parent
0c4689a81b
commit
21fa91afd1
3 changed files with 41 additions and 5 deletions
|
|
@ -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<T extends { attributes?: { appStoreState?: string | null; appVersionState?: string | null; createdDate?: string | null } }>(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 {
|
||||
|
|
|
|||
|
|
@ -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<NonNullable<StoreContact['apple']>>
|
|||
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];
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> } = {}): Call[] {
|
||||
function liveStores(o: { review?: Record<string, unknown>; 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<string, unknown> } = {}): 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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue