// 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) => Promise): 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'); }); });