// One app record, two App Store platforms (pitfall 77). Seen live: `apple resubmit` said "nothing to resubmit" while // the macOS version was REJECTED in an UNRESOLVED_ISSUES submission, and `apple listing --version 0.8.7` refused // because the iOS 0.8.7 was live while the macOS 0.8.7 was rejected. Against the fake App Store Connect. import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; import type { ShipConfig } from '../cli/lib/config'; import { planVersion } from '../cli/lib/listing-plan'; import { upsertTiming } from '../cli/lib/upsert'; import { appleRules } from '../cli/lib/validate-rules'; import { applePlatformWarnings, appleReleases } from '../cli/lib/portfolio'; import { parsePlatform, pickPlatform, platformLabel } from '../cli/apple/platform'; import { run as appleResubmit } from '../cli/apple/resubmit'; import { run as appleStatus } from '../cli/apple/status'; import { run as appleRelease } from '../cli/apple/release'; import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock'; import { installStores, type Stores, uninstallStores } from './helpers/fake-stores'; const APP = '6400000101'; const CONFIG: ShipConfig = { ...BASE }; let s: Stores; beforeAll(ascEnv); afterAll(restore); beforeEach(() => { upsertTiming.waitMs = 0; s = installStores(); }); afterEach(uninstallStores); const ver = (id: string, platform: string, versionString: string, state: string) => s.asc.put({ type: 'appStoreVersions', id, attributes: { versionString, appVersionState: state, platform }, rels: { app: APP } }); /** iOS 0.8.7 live, macOS 0.8.7 rejected in an UNRESOLVED_ISSUES submission, build 8 uploaded. */ function split(): void { ver('v-ios', 'IOS', '0.8.7', 'READY_FOR_DISTRIBUTION'); ver('v-mac', 'MAC_OS', '0.8.7', 'REJECTED'); s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); s.asc.put({ type: 'reviewSubmissions', id: 'rs-mac', attributes: { platform: 'MAC_OS', state: 'UNRESOLVED_ISSUES' }, rels: { app: APP } }); s.asc.put({ type: 'reviewSubmissionItems', id: 'it-mac', attributes: { state: 'REJECTED' }, rels: { reviewSubmission: 'rs-mac', appStoreVersion: 'v-mac' } }); } describe('platform picking (pure)', () => { const v = (platform: string, versionString: string, state: string) => ({ attributes: { platform, versionString, appVersionState: state } }); const vs = [v('IOS', '0.8.7', 'READY_FOR_DISTRIBUTION'), v('MAC_OS', '0.8.7', 'REJECTED')]; test('--platform values, labels', () => { expect(parsePlatform('macos')).toBe('MAC_OS'); expect(parsePlatform('MAC_OS')).toBe('MAC_OS'); expect(parsePlatform('visionOS')).toBe('VISION_OS'); expect(parsePlatform(undefined)).toBeUndefined(); expect(() => parsePlatform('android')).toThrow(/ios \| macos/); expect(platformLabel('MAC_OS')).toBe('macOS'); }); test('one platform has it -> that one; both -> refuse naming both; none -> the only platform, else iOS', () => { const rejected = (x: typeof vs[0]) => x.attributes.appVersionState === 'REJECTED'; expect(pickPlatform(vs, rejected, undefined, 'a rejected version')).toBe('MAC_OS'); expect(pickPlatform(vs, rejected, 'IOS', 'a rejected version')).toBe('IOS'); expect(() => pickPlatform([...vs, v('IOS', '0.8.8', 'REJECTED')], rejected, undefined, 'a rejected version')) .toThrow(/a rejected version on more than one platform \(0\.8\.7 \(macOS\) REJECTED, 0\.8\.8 \(iOS\) REJECTED\): run it again with --platform macos or --platform ios/); expect(pickPlatform([v('MAC_OS', '1.0', 'READY_FOR_SALE')], rejected, undefined, 'x')).toBe('MAC_OS'); expect(pickPlatform(vs, () => false, undefined, 'x')).toBe('IOS'); }); test('listing --version 0.8.7: the macOS one is editable, so macOS; its plan updates it instead of refusing', () => { const p = pickPlatform(vs, [x => x.attributes.versionString === '0.8.7' && x.attributes.appVersionState === 'REJECTED', () => true], undefined, 'x'); expect(p).toBe('MAC_OS'); const rows = [{ id: 'v-mac', attributes: { versionString: '0.8.7', appVersionState: 'REJECTED', platform: 'MAC_OS', copyright: '2026 Example', releaseType: 'MANUAL' } }]; expect(planVersion(rows, '0.8.7', { copyright: '2026 Example', releaseType: 'MANUAL' }, 'MAC_OS').action).toBe('keep'); const ios = [{ id: 'v-ios', attributes: { versionString: '0.8.7', appVersionState: 'READY_FOR_DISTRIBUTION', platform: 'IOS' } }]; expect(planVersion(ios, '0.8.7', { copyright: 'c', releaseType: 'MANUAL' }, 'IOS').action).toBe('refuse'); expect(planVersion([], '1.0.0', { copyright: 'c', releaseType: 'MANUAL' }, 'MAC_OS').attributes.platform).toBe('MAC_OS'); }); test('validate and release status name the other platform', () => { const f = appleRules({ version: { versionString: '0.8.7', state: 'READY_FOR_DISTRIBUTION', platform: 'IOS' }, otherPlatformVersions: [{ versionString: '0.8.7', state: 'REJECTED', platform: 'MAC_OS' }], build: null, expectedLocales: [], locales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 } }); expect(f.find(x => x.code === 'version')!.msg).toBe('version 0.8.7 (iOS) is READY_FOR_DISTRIBUTION (nothing to prepare)'); expect(f.find(x => x.code === 'version-platform')).toMatchObject({ level: 'warn', msg: 'version 0.8.7 (macOS) is REJECTED too: this report covers 0.8.7 (iOS) only' }); const data = { versions: [{ id: 'v-ios', attributes: { platform: 'IOS', versionString: '0.8.7', appVersionState: 'READY_FOR_DISTRIBUTION' } }, { id: 'v-mac', attributes: { platform: 'MAC_OS', versionString: '0.8.7', appVersionState: 'REJECTED' } }] }; expect(applePlatformWarnings(data)).toEqual(['App Store macOS 0.8.7: REJECTED (storebound apple status --platform macos)']); expect(appleReleases(data).map(r => [r.version, r.platform])).toEqual([['0.8.7', 'IOS']]); }); }); describe('commands against an app with iOS live and macOS rejected', () => { test('apple resubmit picks the macOS version and its UNRESOLVED_ISSUES submission', async () => { split(); const r = await capture(() => appleResubmit(project(CONFIG).ctx(['--build', '8', '--dry']))); expect(r.out + r.err).toContain('app version 0.8.7 (macOS): REJECTED'); expect(r.out).toMatch(/rs-mac\s+UNRESOLVED_ISSUES\s+reuse/); }); test('apple resubmit --platform ios: nothing on iOS, said per platform', async () => { split(); await expect(appleResubmit(project(CONFIG).ctx(['--build', '8', '--platform', 'ios', '--dry']))).rejects.toThrow(/no rejected iOS App Store version and no iOS review submission/); }); test('both platforms rejected: refuse and ask for --platform', async () => { split(); s.asc.all('appStoreVersions').find(x => x.id === 'v-ios')!.attributes.appVersionState = 'REJECTED'; await expect(appleResubmit(project(CONFIG).ctx(['--build', '8', '--dry']))).rejects.toThrow(/more than one platform .*--platform (ios|macos) or --platform (ios|macos)/); }); test('apple status labels every version and submission with its platform; --platform shows one', async () => { split(); const r = await capture(() => appleStatus(project(CONFIG).ctx([]))); expect(r.out).toMatch(/version 0\.8\.7\s+iOS\s+READY_FOR_DISTRIBUTION/); expect(r.out).toMatch(/version 0\.8\.7\s+macOS\s+REJECTED/); expect(r.out).toContain('review submission rs-mac (macOS) is back from App Review'); expect(r.out).toContain('storebound apple resubmit --build --platform macos'); const ios = await capture(() => appleStatus(project(CONFIG).ctx(['--platform', 'ios']))); expect(ios.out).not.toContain('macOS'); }); test('apple release --phased on picks the platform with a version not yet live', async () => { split(); const r = await capture(() => appleRelease(project(CONFIG).ctx(['--phased', 'on', '--dry']))); expect(r.out + r.err).toContain('0.8.7 macOS (REJECTED)'); }); });