storebound/tests/apple-release-gaps.test.ts

405 lines
27 KiB
TypeScript

// Three gaps found in real use, against the stateful App Store Connect double (tests/helpers/fake-stores.ts):
// 1. release type: updates sat in Pending Developer Release because versions were created / submitted as MANUAL
// (apple.releaseType, apple submit, apple release --after-approval | --manual)
// 2. device families: an iPhone-only build of an app whose live version runs on iPad is refused AFTER the archive (ITMS-90101)
// 3. TestFlight: inviting a tester of a group without an installable build is 409 NO_INSTALLABLE_BUILDS
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import { parseArgs } from '../cli/lib/args';
import { runShipStep } from '../cli/lib/ship-step';
import { run as appleSubmit } from '../cli/apple/submit';
import { run as appleRelease } from '../cli/apple/release';
import { run as appleTestflight } from '../cli/apple/testflight';
import { run as appleBuild } from '../cli/apple/build';
import { validateConfig } from '../cli/lib/config';
import { planVersion } from '../cli/lib/listing-plan';
import { releaseTypeChanges, validateReleaseType, wantedRelease } from '../cli/lib/release-type';
import { familyVerdicts, liveFamilies, pbxFamilies, projectFamilies } from '../cli/lib/device-families';
import { liveDeviceFamilies } from '../cli/apple/device-families';
import { appleRules } from '../cli/lib/validate-rules';
import { installableBuild, noInstallableBuildHint, noInstallableBuilds409 } from '../cli/lib/testflight';
import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock';
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
const APP = '6400000101';
/** Runs a ship step in-process with stdout / stderr captured. */
async function ship(key: string, argv: string[], body: (a: ReturnType<typeof parseArgs>) => Promise<void>): Promise<{ code: number; out: string; err: string }> {
const out: string[] = [], err: string[] = [];
const o = process.stdout.write, e = process.stderr.write, cl = console.log, ce = console.error, cw = console.warn, ci = console.info;
process.stdout.write = ((s: string | Uint8Array) => { out.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stdout.write;
process.stderr.write = ((s: string | Uint8Array) => { err.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stderr.write;
console.log = (...a: unknown[]) => { out.push(a.map(String).join(' ') + '\n'); };
console.info = console.log;
console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ') + '\n'); };
console.warn = console.error;
try {
const code = await runShipStep(key, argv, parseArgs, async a => {
const keep = console.log;
if (a.has('json')) console.log = (...x: unknown[]) => console.error(...x);
try { await body(a); } finally { console.log = keep; }
});
return { code, out: out.join(''), err: err.join('') };
} finally { process.stdout.write = o; process.stderr.write = e; console.log = cl; console.error = ce; console.warn = cw; console.info = ci; }
}
// ------------------------------------------------------------------------------------------------ 1. release type
describe('release type (pure)', () => {
test('config: afterApproval | manual | scheduled (+ date); scheduled needs the date, the date needs scheduled', () => {
expect(validateReleaseType({ releaseType: 'afterApproval' })).toEqual([]);
expect(validateReleaseType({ releaseType: 'scheduled', earliestReleaseDate: '2027-03-01T09:00:00Z' })).toEqual([]);
expect(validateReleaseType({ releaseType: 'soon' }).join()).toContain('afterApproval | manual | scheduled');
expect(validateReleaseType({ releaseType: 'scheduled' }).join()).toContain('earliestReleaseDate is required');
expect(validateReleaseType({ releaseType: 'manual', earliestReleaseDate: '2027-03-01T09:00:00Z' }).join()).toContain('only with apple.releaseType "scheduled"');
expect(validateReleaseType({ releaseType: 'scheduled', earliestReleaseDate: 'tomorrow' }).join()).toContain('ISO 8601');
expect(validateConfig({ ...BASE, apple: { ...BASE.apple!, releaseType: 'later' as never } }).join()).toContain('apple.releaseType');
expect(wantedRelease({})).toBeNull();
expect(wantedRelease({ releaseType: 'afterApproval' })).toEqual({ releaseType: 'AFTER_APPROVAL' });
});
test('changes are old -> new; the same instant is not a change', () => {
expect(releaseTypeChanges({ releaseType: 'MANUAL' }, { releaseType: 'AFTER_APPROVAL' })).toEqual([{ field: 'releaseType', from: 'MANUAL', to: 'AFTER_APPROVAL' }]);
expect(releaseTypeChanges({ releaseType: 'AFTER_APPROVAL' }, { releaseType: 'AFTER_APPROVAL' })).toEqual([]);
expect(releaseTypeChanges({ releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00+00:00' }, { releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' })).toEqual([]);
expect(releaseTypeChanges({ releaseType: 'MANUAL' }, { releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' }).map(c => c.field)).toEqual(['releaseType', 'earliestReleaseDate']);
});
test('apple listing: a new version takes the configured type (MANUAL when unset); an existing one keeps its own unless asked', () => {
const rows = [{ id: 'v1', attributes: { versionString: '1.0.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' } }];
expect(planVersion(rows, '1.1.0', { copyright: 'c' }).attributes.releaseType).toBe('MANUAL');
expect(planVersion(rows, '1.1.0', { copyright: 'c', releaseType: 'AFTER_APPROVAL' }).attributes.releaseType).toBe('AFTER_APPROVAL');
const sched = planVersion(rows, '1.1.0', { copyright: 'c', releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' });
expect(sched.attributes).toMatchObject({ releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' });
const editable = [...rows, { id: 'v2', attributes: { versionString: '1.1.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS', copyright: 'c', releaseType: 'AFTER_APPROVAL' } }];
expect(planVersion(editable, '1.1.0', { copyright: 'c' }).action).toBe('keep');
expect(planVersion(editable, '1.1.0', { copyright: 'c', releaseType: 'MANUAL' }).changes).toEqual([{ field: 'releaseType', from: 'AFTER_APPROVAL', to: 'MANUAL' }]);
});
});
describe('release type (fake App Store Connect)', () => {
setDefaultTimeout(30_000);
let s: Stores;
beforeAll(() => { ascEnv(); });
afterAll(restore);
beforeEach(() => { s = installStores(); });
afterEach(() => { uninstallStores(); });
const version = (id: string, v: string, state: string, releaseType = 'MANUAL'): void => { s.asc.put({ type: 'appStoreVersions', id, attributes: { versionString: v, appVersionState: state, platform: 'IOS', releaseType }, rels: { app: APP } }); };
const build = (): void => { s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); };
const typeOf = (id: string): unknown => s.asc.all('appStoreVersions').find(v => v.id === id)!.attributes.releaseType;
const cfg = (releaseType: 'afterApproval' | 'manual') => ({ ...BASE, apple: { ...BASE.apple!, releaseType } });
test('apple submit with apple.releaseType: the dry run shows old -> new, the real run sets it BEFORE the submit, the re-run is exit 7', async () => {
version('ver-0', '1.0.0', 'READY_FOR_SALE');
version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION');
build();
const p = project(cfg('afterApproval'));
const dry = await ship('apple submit', ['--json', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a }));
expect(dry.code).toBe(0);
expect(JSON.parse(dry.out).releaseType).toEqual({ changes: [{ field: 'releaseType', from: 'MANUAL', to: 'AFTER_APPROVAL' }] });
expect(typeOf('ver-1')).toBe('MANUAL');
expect(writes(s.calls)).toEqual([]);
const r = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a }));
expect(r.err).not.toContain('error:');
expect(r.code).toBe(0);
expect(r.err).toContain('release type of 1.1.0: releaseType MANUAL -> AFTER_APPROVAL');
expect(typeOf('ver-1')).toBe('AFTER_APPROVAL');
const w = writes(s.calls).map(c => `${c.method} ${c.path}`);
expect(w.indexOf('PATCH /v1/appStoreVersions/ver-1')).toBeGreaterThanOrEqual(0);
expect(w.indexOf('PATCH /v1/appStoreVersions/ver-1')).toBeLessThan(w.findIndex(x => /^PATCH \/v1\/reviewSubmissions\//.test(x)));
const again = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a }));
expect(again.code).toBe(7);
});
test('apple submit without apple.releaseType leaves the version\'s release type alone', async () => {
version('ver-0', '1.0.0', 'READY_FOR_SALE');
version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION');
build();
const p = project(BASE);
const r = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a }));
expect(r.code).toBe(0);
expect(typeOf('ver-1')).toBe('MANUAL');
expect(writes(s.calls).some(c => c.path === '/v1/appStoreVersions/ver-1')).toBe(false);
});
test('apple release --after-approval --yes on an update: set, then exit 7 when it is that type; --manual puts it back', async () => {
version('ver-0', '1.0.0', 'READY_FOR_SALE');
version('ver-1', '1.1.0', 'WAITING_FOR_REVIEW');
const p = project(BASE);
const run = (argv: string[]) => ship('apple release', ['--json', '--yes', ...argv], a => appleRelease({ loaded: p.loaded, args: a }));
const r = await run(['--after-approval', '--version', '1.1.0']);
expect(r.err).not.toContain('error:');
expect(r.code).toBe(0);
expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'release', version: '1.1.0', releaseType: 'AFTER_APPROVAL', from: 'MANUAL', changed: true, releaseKind: 'update' });
expect(writes(s.calls).map(c => `${c.method} ${c.path}`)).toEqual(['PATCH /v1/appStoreVersions/ver-1']);
expect(typeOf('ver-1')).toBe('AFTER_APPROVAL');
const n = s.calls.length;
const again = await run(['--after-approval']);
expect(again.code).toBe(7);
expect(JSON.parse(again.out)).toMatchObject({ ok: true, alreadyThere: true });
expect(writes(s.calls.slice(n))).toEqual([]);
const back = await run(['--manual', '--version', '1.1.0']);
expect(back.code).toBe(0);
expect(typeOf('ver-1')).toBe('MANUAL');
});
test('apple release --after-approval --dry changes nothing; --scheduled sets the date; two type flags are a usage error', async () => {
version('ver-0', '1.0.0', 'READY_FOR_SALE');
version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION');
const p = project(BASE);
const dry = await ship('apple release', ['--json', '--after-approval', '--dry'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(dry.code).toBe(0);
expect(JSON.parse(dry.out)).toMatchObject({ dryRun: true, changes: [{ field: 'releaseType', from: 'MANUAL', to: 'AFTER_APPROVAL' }] });
expect(writes(s.calls)).toEqual([]);
const sch = await ship('apple release', ['--json', '--yes', '--scheduled', '2027-03-01T09:00:00Z'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(sch.code).toBe(0);
expect(s.asc.all('appStoreVersions').find(v => v.id === 'ver-1')!.attributes).toMatchObject({ releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' });
const both = await ship('apple release', ['--json', '--yes', '--manual', '--after-approval'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(both.code).not.toBe(0);
expect(both.err).toContain('alternatives');
});
test('an approved version (Pending Developer Release) is refused with the way out; a released one too', async () => {
version('ver-0', '1.0.0', 'READY_FOR_SALE');
version('ver-1', '1.1.0', 'PENDING_DEVELOPER_RELEASE');
const p = project(BASE);
const r = await ship('apple release', ['--json', '--yes', '--after-approval', '--version', '1.1.0'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(r.code).not.toBe(0);
expect(r.err).toContain('apple release --manual-release --version 1.1.0');
expect(writes(s.calls)).toEqual([]);
// already MANUAL and asked for MANUAL: nothing to do even in that state
const same = await ship('apple release', ['--json', '--yes', '--manual', '--version', '1.1.0'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(same.code).toBe(7);
});
test('--after-approval on the first version of an app is the owner\'s step (exit 3 OWNER_STEP) unless --first-release', async () => {
version('ver-1', '1.0.0', 'PREPARE_FOR_SUBMISSION');
const p = project(BASE);
const r = await ship('apple release', ['--json', '--yes', '--after-approval'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(r.code).toBe(3);
expect(JSON.parse(r.out)).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } });
expect(writes(s.calls)).toEqual([]);
const manual = await ship('apple release', ['--json', '--yes', '--manual'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(manual.code).toBe(7);
const ok = await ship('apple release', ['--json', '--yes', '--after-approval', '--first-release'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(ok.code).toBe(0);
expect(typeOf('ver-1')).toBe('AFTER_APPROVAL');
});
});
// ------------------------------------------------------------------------------------------------ 2. device families
const PBX = (family: string, bundle = 'com.example.notes'): string => `// !$*UTF8*$!
{
objects = {
/* Begin XCBuildConfiguration section */
AA01 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
PRODUCT_BUNDLE_IDENTIFIER = ${bundle};
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA02 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = ZZ01;
buildSettings = {
PRODUCT_BUNDLE_IDENTIFIER = ${bundle};
TARGETED_DEVICE_FAMILY = ${family};
};
name = Release;
};
AA03 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
PRODUCT_BUNDLE_IDENTIFIER = ${bundle}.Share;
TARGETED_DEVICE_FAMILY = "1,2";
WRAPPER_EXTENSION = appex;
};
name = Release;
};
/* End XCBuildConfiguration section */
};
}
`;
describe('device families (pure)', () => {
test('pbxproj: the Release configuration of the app\'s bundle id, not the extension\'s or Debug', () => {
expect(pbxFamilies(PBX('1'), 'com.example.notes')).toMatchObject({ list: [1], families: { iphone: true, ipad: false }, configurations: ['1'] });
expect(pbxFamilies(PBX('"1,2"'), 'com.example.notes')).toMatchObject({ list: [1, 2], families: { iphone: true, ipad: true } });
expect(pbxFamilies('no project here', 'com.example.notes')).toBeNull();
});
test('live signals: any iPad signal means iPad; no signal at all is unknown, never iPhone only', () => {
expect(liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'screenshot sets', values: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] }])?.families).toEqual({ iphone: true, ipad: true });
expect(liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'a', values: [] }, { name: 'b', values: ['APP_IPHONE_67'] }])?.families).toEqual({ iphone: true, ipad: false });
expect(liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'a', values: [] }])).toBeNull();
});
test('verdicts: the exact fix; unknown is a warning; iPhone-only live version is fine', () => {
const live = liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'screenshot sets', values: ['APP_IPAD_PRO_3GEN_129'] }]);
const project = pbxFamilies(PBX('1'), 'com.example.notes', 'ios/App/App.xcodeproj/project.pbxproj');
const v = familyVerdicts({ live, project, ipad: false });
expect(v.map(x => x.level)).toEqual(['blocker', 'blocker']);
expect(v[0]!.msg).toContain('ITMS-90101');
expect(v[0]!.fix).toContain('"1,2"');
expect(v[1]!.fix).toContain('apple.ipad: true');
expect(familyVerdicts({ live, project: pbxFamilies(PBX('"1,2"'), 'com.example.notes'), ipad: true }).map(x => x.level)).toEqual(['ok']);
expect(familyVerdicts({ live: null, project, ipad: false })[0]!.level).toBe('warn');
expect(familyVerdicts({ live: liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'a', values: ['APP_IPHONE_67'] }]), project, ipad: false })[0]!.level).toBe('ok');
});
test('validate rules turn the verdicts into findings', () => {
const f = appleRules({ deviceFamilies: [{ level: 'blocker', msg: 'x ITMS-90101', fix: 'y' }], version: null, build: null, expectedLocales: [], locales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com', status: 200 } }).filter(x => x.code === 'device-families');
expect(f).toMatchObject([{ store: 'apple', level: 'blocker', code: 'device-families', fix: 'y' }]);
});
});
describe('device families (fake App Store Connect + project)', () => {
setDefaultTimeout(30_000);
let s: Stores;
beforeAll(() => { ascEnv(); });
afterAll(restore);
beforeEach(() => { s = installStores(); });
afterEach(() => { uninstallStores(); });
/** a live version 1.0.0 with a build whose bundle names the families; screenshotSets: display types of its first locale */
function live(o: { protocols?: string[]; sets?: string[]; models?: string[] }): void {
s.asc.put({ type: 'appStoreVersions', id: 'ver-live', attributes: { versionString: '1.0.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' }, rels: { app: APP } });
s.asc.put({ type: 'builds', id: 'b-live', attributes: { version: '4', processingState: 'VALID' }, rels: { app: APP, appStoreVersion: 'ver-live' } });
s.asc.put({ type: 'buildBundles', id: 'bb-live', attributes: { bundleId: 'com.example.notes', bundleType: 'APP', deviceProtocols: o.protocols ?? [] }, rels: { build: 'b-live' } });
for (const [i, m] of (o.models ?? []).entries()) s.asc.put({ type: 'buildBundleFileSizes', id: `fs-${i}`, attributes: { deviceModel: m }, rels: { buildBundle: 'bb-live' } });
if (o.sets) {
s.asc.put({ type: 'appStoreVersionLocalizations', id: 'loc-live', attributes: { locale: 'en-US' }, rels: { appStoreVersion: 'ver-live' } });
for (const [i, t] of o.sets.entries()) s.asc.put({ type: 'appScreenshotSets', id: `set-${i}`, attributes: { screenshotDisplayType: t }, rels: { appStoreVersionLocalization: 'loc-live' } });
}
}
const IPHONE_ONLY = { 'ios/App/App.xcodeproj/project.pbxproj': PBX('1') };
const cfg = { ...BASE, apple: { ...BASE.apple!, ipad: false, signing: { teamId: 'ABCDE12345' } } };
test('the live version\'s families are read from the build bundle, its device models and its screenshot sets', async () => {
const c = (await import('../cli/apple/asc')).ascClient;
live({ sets: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] });
expect((await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP))?.families).toEqual({ iphone: true, ipad: true });
uninstallStores(); s = installStores();
live({ protocols: ['iphone', 'ipad'] });
expect((await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP))?.evidence[0]).toContain('deviceProtocols');
uninstallStores(); s = installStores();
live({ models: ['iPad_Pro_12_9_6th', 'iPhone15,2'] });
expect((await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP))?.families.ipad).toBe(true);
uninstallStores(); s = installStores();
expect(await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP)).toBeNull();
});
test('apple build stops BEFORE the archive when the project drops iPad that the live version has; --allow-device-gap goes on', async () => {
live({ sets: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] });
const argv = ['--build-number', '5', '--no-prebuild'];
const p = project(cfg, IPHONE_ONLY);
const r = await capture(() => appleBuild(p.ctx(argv))).catch(e => ({ out: '', err: String((e as Error).message ?? e), exit: 1 }));
expect(r.exit).toBe(1);
expect(r.err).toContain('stopped before the archive');
expect(r.err).toContain('ITMS-90101');
expect(r.err).toContain('TARGETED_DEVICE_FAMILY to "1,2"');
expect(r.err).toContain('apple.ipad: true');
expect(r.err).toContain('--allow-device-gap');
// dry: warns, never throws
const dry = await capture(() => appleBuild(p.ctx([...argv, '--dry'])));
expect(dry.err).toContain('ITMS-90101');
// the escape hatch passes the check (the run then stops later: no Xcode on this machine)
const gap = await capture(() => appleBuild(p.ctx([...argv, '--allow-device-gap']))).catch(e => ({ out: '', err: String((e as Error).message ?? e), exit: 1 }));
expect(gap.err).not.toContain('stopped before the archive');
});
test('apple build passes the check with iPad in the project and apple.ipad true; an iPhone-only live version never blocks', async () => {
live({ sets: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] });
const ok = project({ ...cfg, apple: { ...cfg.apple, ipad: true } }, { 'ios/App/App.xcodeproj/project.pbxproj': PBX('"1,2"') });
const r = await capture(() => appleBuild(ok.ctx(['--build-number', '5', '--no-prebuild', '--dry'])));
expect(r.err).not.toContain('ITMS-90101');
uninstallStores(); s = installStores();
live({ sets: ['APP_IPHONE_67'] });
const iph = project(cfg, IPHONE_ONLY);
const r2 = await capture(() => appleBuild(iph.ctx(['--build-number', '5', '--no-prebuild', '--dry'])));
expect(r2.err).not.toContain('ITMS-90101');
});
test('projectFamilies reads the project found under the root (and apple.build.project)', () => {
const p = project(cfg, IPHONE_ONLY);
expect(projectFamilies(p.root, 'com.example.notes')).toMatchObject({ file: 'ios/App/App.xcodeproj/project.pbxproj', list: [1] });
expect(projectFamilies(p.root, 'com.example.notes', 'ios/App/App.xcodeproj')?.list).toEqual([1]);
expect(projectFamilies(project(cfg).root, 'com.example.notes')).toBeNull();
});
});
// ------------------------------------------------------------------------------------------------ 3. TestFlight invite
describe('TestFlight invite needs an installable build', () => {
setDefaultTimeout(30_000);
let s: Stores;
beforeAll(() => { ascEnv(); });
afterAll(restore);
beforeEach(() => { s = installStores(); });
afterEach(() => { uninstallStores(); });
const CSV = { '.storebound/testers.csv': 'email,first_name,groups\ntester1@example.com,Ex,Friends\n' };
const cfg = { ...BASE, apple: { ...BASE.apple!, testflight: { groups: [{ name: 'Friends' }] } } };
const group = (): void => { s.asc.put({ type: 'betaGroups', id: 'grp-friends', attributes: { name: 'Friends', isInternalGroup: false }, rels: { app: APP } }); };
const buildIn = (external: string): void => {
s.asc.put({ type: 'builds', id: 'b-1', attributes: { version: '7', processingState: 'VALID', expired: false }, rels: { app: APP, betaGroups: ['grp-friends'] } });
s.asc.put({ type: 'buildBetaDetails', id: 'bd-1', attributes: { externalBuildState: external, internalBuildState: 'IN_BETA_TESTING' }, rels: { build: 'b-1' } });
};
const invites = (): number => s.asc.all('betaTesterInvitations').length;
test('pure: external needs BETA_APPROVED / IN_BETA_TESTING, internal READY_FOR_BETA_TESTING / IN_BETA_TESTING', () => {
const b = { attributes: { processingState: 'VALID' } };
expect(installableBuild(b, { externalBuildState: 'READY_FOR_BETA_SUBMISSION' }, false)).toBe(false);
expect(installableBuild(b, { externalBuildState: 'WAITING_FOR_BETA_REVIEW' }, false)).toBe(false);
expect(installableBuild(b, { externalBuildState: 'BETA_APPROVED' }, false)).toBe(true);
expect(installableBuild(b, { externalBuildState: 'IN_BETA_TESTING' }, false)).toBe(true);
expect(installableBuild(b, { internalBuildState: 'READY_FOR_BETA_TESTING' }, true)).toBe(true);
expect(installableBuild({ attributes: { processingState: 'VALID', expired: true } }, { externalBuildState: 'BETA_APPROVED' }, false)).toBe(false);
expect(noInstallableBuildHint(['Friends'])).toBe('tester added; the invite goes out when a build in Friends passes Beta App Review (re-run with --invite then)');
expect(noInstallableBuilds409(new Error('POST /v1/betaTesterInvitations -> 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS'))).toBe(true);
expect(noInstallableBuilds409(new Error('-> 409 ALREADY_EXISTS'))).toBe(false);
});
test('a new external group without an approved build: the tester is added, no invitation is sent, exit 0 with the hint', async () => {
group();
buildIn('WAITING_FOR_BETA_REVIEW');
const p = project(cfg, CSV);
const r = await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes'])));
expect(r.exit).toBeNull();
expect(r.err).toContain('tester added; the invite goes out when a build in Friends passes Beta App Review (re-run with --invite then)');
expect(s.asc.all('betaTesters')).toHaveLength(1);
expect(invites()).toBe(0);
expect(writes(s.calls).some(c => c.path === '/v1/betaTesterInvitations')).toBe(false);
});
test('once a build passed Beta App Review, --invite sends the invitation', async () => {
group();
const p = project(cfg, CSV);
await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes'])));
expect(invites()).toBe(0);
buildIn('BETA_APPROVED');
const r = await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes'])));
expect(r.exit).toBeNull();
expect(invites()).toBe(1);
});
test('if the 409 still comes back (the build looked installable), it is the same case: warning, no throw, exit 0', async () => {
group();
buildIn('BETA_APPROVED');
const p = project(cfg, CSV);
// Apple's answer wins over our read: the double refuses the invitation as the live API did
s.faults.push({ method: 'POST', match: /\/v1\/betaTesterInvitations/, when: 'before', status: 409, code: 'STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS', detail: 'The tester cannot be invited because the groups they belong to have no installable builds.' });
const r = await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes'])));
expect(r.exit).toBeNull();
expect(r.err).toContain('passes Beta App Review');
expect(s.asc.all('betaTesters')).toHaveLength(1);
});
test('distribute --submit-review prints the hint for testers that are added but not invited', async () => {
group();
buildIn('READY_FOR_BETA_SUBMISSION');
const p = project(cfg, CSV);
await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--yes'])));
s.asc.all('buildBetaDetails')[0]!.attributes.processingState = 'x';
s.asc.put({ type: 'preReleaseVersions', id: 'pre-1', attributes: { version: '1.0.0', platform: 'IOS' }, rels: { app: APP } });
s.asc.all('builds')[0]!.rels.preReleaseVersion = 'pre-1';
s.asc.all('builds')[0]!.rels.buildBetaDetail = 'bd-1';
const r = await capture(() => appleTestflight(p.ctx(['distribute', '--build', '7', '--group', 'Friends', '--submit-review', '--yes'])));
expect(r.out + r.err).toContain('tester added; the invite goes out when a build in Friends passes Beta App Review');
});
});