Game Center switch before submit: apple submit / resubmit and signing ensure create gameCenterDetails and gameCenterAppVersions when Game Center is in use (config, entitlements or the built IPA), shown in the dry run; validate blocks on either missing; fake 409 BUILD_INDICATES_GAME_CENTER_ENABLED until both exist; pitfall 89
This commit is contained in:
parent
dd9e059036
commit
163e666834
9 changed files with 152 additions and 26 deletions
|
|
@ -5,7 +5,7 @@
|
|||
// find-then-create (createOnce: a conflict or an unclear answer reads the store again, never a second create) or a
|
||||
// PATCH of the fields that differ, so a re-run writes nothing. Nothing is deleted or archived; nothing is submitted
|
||||
// (leaderboards and achievements go to review with the next app version, the owner's Submit for review).
|
||||
import { findBundleId, readProjectEntitlements } from './signing';
|
||||
import { builtIpaEntitlements, findBundleId, readProjectEntitlements } from './signing';
|
||||
import { consoleLink } from '../lib/console-links';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { confirm, need } from '../lib/ctx';
|
||||
|
|
@ -90,21 +90,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
console.log(dryPlan('apple game-center (to write)', rows).replace(/^dry run: /, '').replace(/; nothing written/, ''));
|
||||
if (!(await confirm(ctx, 'Write these Game Center changes to App Store Connect? (nothing is submitted; they go to review with the next version)'))) return;
|
||||
|
||||
let detail = s.detail;
|
||||
if (!detail) {
|
||||
const r = await createOnce({ what: 'Game Center detail', tag: TAG, unique: true,
|
||||
create: async () => (await c.asc('POST', '/v1/gameCenterDetails', { data: { type: 'gameCenterDetails', relationships: { app: { data: { type: 'apps', id: appId } } } } })).data.id as string,
|
||||
find: async () => (await c.maybe(`/v1/apps/${appId}/gameCenterDetail`))?.data?.id ?? null });
|
||||
detail = r.value;
|
||||
log(TAG, `Game Center switched on for the app (${detail})`);
|
||||
}
|
||||
if (s.version && !s.version.enabled) {
|
||||
const vid = s.version.id;
|
||||
await createOnce({ what: `Game Center for version ${s.version.versionString}`, tag: TAG, unique: true,
|
||||
create: async () => (await c.asc('POST', '/v1/gameCenterAppVersions', { data: { type: 'gameCenterAppVersions', relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: vid } } } } })).data.id as string,
|
||||
find: async () => (await c.maybe(`/v1/appStoreVersions/${vid}/gameCenterAppVersion`))?.data?.id ?? null });
|
||||
log(TAG, `Game Center switched on for version ${s.version.versionString}`);
|
||||
}
|
||||
const sw = await ensureGcSwitch(c, { appId, detail: s.detail, version: s.version });
|
||||
const detail = sw.detail;
|
||||
for (const p of plan) {
|
||||
const type = p.kind === 'leaderboard' ? 'gameCenterLeaderboards' as const : 'gameCenterAchievements' as const;
|
||||
const locType = p.kind === 'leaderboard' ? 'gameCenterLeaderboardLocalizations' : 'gameCenterAchievementLocalizations';
|
||||
|
|
@ -139,3 +126,49 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (!after.detail || left.length) warn(TAG, `read back: ${!after.detail ? 'no Game Center detail yet; ' : ''}${left.map(p => `${p.kind} ${p.id}`).join(', ') || ''} not listed as written yet (App Store Connect lists lag after a create): run storebound apple game-center status in a minute`);
|
||||
done(TAG, `Game Center: ${after.leaderboards.length} leaderboard(s), ${after.achievements.length} achievement(s); images and review go with the next version (${consoleLink('asc.version')})`);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the Game Center switch (submit, signing ensure)
|
||||
// Seen live on a first submission: a build with com.apple.developer.game-center and an app version without Game
|
||||
// Center answer 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED ("appStoreVersion is missing
|
||||
// gameCenterConfigurations relationship"). What passes: POST /v1/gameCenterDetails {app} when the app has none, then
|
||||
// POST /v1/gameCenterAppVersions {appStoreVersion} (both verified in specs/app-store-connect.oas.json).
|
||||
|
||||
export interface GcSwitch { appId: string; detail: string | null; version: { id: string; versionString: string; enabled: boolean } | null }
|
||||
|
||||
export async function readGcSwitch(c: Asc, appId: string, version: J | null): Promise<GcSwitch> {
|
||||
const detail = (await c.maybe(`/v1/apps/${appId}/gameCenterDetail`))?.data?.id ?? null;
|
||||
return { appId, detail, version: version ? { id: version.id, versionString: String(version.attributes?.versionString ?? ''), enabled: !!(await c.maybe(`/v1/appStoreVersions/${version.id}/gameCenterAppVersion`))?.data } : null };
|
||||
}
|
||||
|
||||
/** The writes the switch still needs, as request lines (empty: on). */
|
||||
export function gcSwitchPlan(s: GcSwitch): string[] {
|
||||
return [...(s.detail ? [] : [`POST /v1/gameCenterDetails {app=${s.appId}}`]),
|
||||
...(s.version && !s.version.enabled ? [`POST /v1/gameCenterAppVersions {appStoreVersion=${s.version.id}} # version ${s.version.versionString}`] : [])];
|
||||
}
|
||||
|
||||
/** Creates what gcSwitchPlan names (find-then-create, never twice). */
|
||||
export async function ensureGcSwitch(c: Asc, s: GcSwitch, tag = TAG): Promise<GcSwitch> {
|
||||
let detail = s.detail;
|
||||
if (!detail) {
|
||||
detail = (await createOnce({ what: 'Game Center detail', tag, unique: true,
|
||||
create: async () => (await c.asc('POST', '/v1/gameCenterDetails', { data: { type: 'gameCenterDetails', relationships: { app: { data: { type: 'apps', id: s.appId } } } } })).data.id as string,
|
||||
find: async () => (await c.maybe(`/v1/apps/${s.appId}/gameCenterDetail`))?.data?.id ?? null })).value;
|
||||
log(tag, `Game Center switched on for the app (${detail})`);
|
||||
}
|
||||
const v = s.version;
|
||||
if (v && !v.enabled) {
|
||||
await createOnce({ what: `Game Center for version ${v.versionString}`, tag, unique: true,
|
||||
create: async () => (await c.asc('POST', '/v1/gameCenterAppVersions', { data: { type: 'gameCenterAppVersions', relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: v.id } } } } })).data.id as string,
|
||||
find: async () => (await c.maybe(`/v1/appStoreVersions/${v.id}/gameCenterAppVersion`))?.data?.id ?? null });
|
||||
log(tag, `Game Center switched on for version ${v.versionString}`);
|
||||
}
|
||||
return { ...s, detail, version: v ? { ...v, enabled: true } : null };
|
||||
}
|
||||
|
||||
/** Game Center is in use when the config says so, the iOS project has the entitlement, or the newest local IPA does. */
|
||||
export function gameCenterInUse(config: Ctx['loaded']['config'], root: string): { inUse: boolean; why: string | null } {
|
||||
if (usesGameCenter(config)) return { inUse: true, why: 'games.gameCenter' };
|
||||
try { const p = readProjectEntitlements(root)?.find(e => e.caps.some(x => x.cap === 'GAME_CENTER')); if (p) return { inUse: true, why: `${p.file} has com.apple.developer.game-center` }; } catch { /* unreadable */ }
|
||||
try { const b = builtIpaEntitlements(root, config); if (b?.caps.some(x => x.cap === 'GAME_CENTER')) return { inUse: true, why: `build ${b.number} (${b.file}) has com.apple.developer.game-center` }; } catch { /* no IPA */ }
|
||||
return { inUse: false, why: null };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { allSubscriptions } from '../lib/subscriptions';
|
|||
import { ascClient, type J } from './asc';
|
||||
import { readSubmitState, submitPlanned } from './iap-submit';
|
||||
import { openSubmissions } from './submission';
|
||||
import { ensureGcSwitch, gameCenterInUse, gcSwitchPlan, readGcSwitch } from './game-center';
|
||||
import { allVersions, parsePlatform, pickPlatform, platformLabel, platformOf, versionsPath } from './platform';
|
||||
|
||||
/** App Store version states a first submission may send: the editable version (Prepare for Submission, a rejected
|
||||
|
|
@ -88,14 +89,20 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
|
|||
const marks = state.choice?.action === 'reuse' ? toResolve(state.choice.sub.items, resolve).map(i => ({ item: i.id ?? null, kind: i.kind, what: labelOf(state, i.versionId, i.kind) })) : [];
|
||||
const blockers = [...(build.action === 'refuse' ? [build.reason] : []), ...plan.blockers];
|
||||
const dry = a.has('dry') || !a.has('yes');
|
||||
// Game Center: a build with the entitlement needs the app's Game Center detail and the version's switch, else the
|
||||
// submission answers 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED (seen live); the same upsert as apple game-center
|
||||
const gcUse = gameCenterInUse(config, root);
|
||||
const gc = gcUse.inUse ? await readGcSwitch(c, appId, v) : null;
|
||||
const gcPlan = gc ? gcSwitchPlan(gc) : [];
|
||||
if (a.has('json')) {
|
||||
printJson({ dryRun: dry, version: { id: v.id, versionString, platform, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers });
|
||||
printJson({ dryRun: dry, gameCenter: gcPlan, version: { id: v.id, versionString, platform, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers });
|
||||
// the desktop's owner dialog shows the output text: the plan table goes with the result there
|
||||
if (process.env.STOREBOUND_MACHINE === '1') { console.log(formatRows(rows)); for (const x of blockers) warn(TAG, x); }
|
||||
if (blockers.length) process.exitCode = 1;
|
||||
if (dry || blockers.length || state.choice?.action === 'busy') return;
|
||||
} else {
|
||||
console.log(formatRows(rows));
|
||||
for (const l of gcPlan) log(TAG, `${dry ? 'would ' : ''}${l} (Game Center: ${gcUse.why})`);
|
||||
for (const s of plan.skipped) log(TAG, `not added ${s.id}: ${s.why}`);
|
||||
for (const x of blockers) warn(TAG, x);
|
||||
}
|
||||
|
|
@ -110,5 +117,6 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
|
|||
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}/relationships/build`, { data: { type: 'builds', id: build.id } });
|
||||
log(TAG, `build ${number} attached to ${versionString}`);
|
||||
}
|
||||
if (gc && gcPlan.length) await ensureGcSwitch(c, gc, TAG);
|
||||
await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: true, appVersionId: v.id, platform, quiet: true, resolve });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
// App Store profile per bundle id (apple.bundleId + apple.signing.extraBundleIds) and installs it for Xcode.
|
||||
// Every write is shown first (--dry shows only that) and asks once before anything is created. Developer ID
|
||||
// certificates need the Account Holder: the API's 403 is explained. Pure planning: cli/lib/signing.ts.
|
||||
import { ensureGcSwitch, gcSwitchPlan, readGcSwitch } from './game-center';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { basename, dirname, join, relative, resolve as resolvePath } from 'node:path';
|
||||
|
|
@ -313,6 +314,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
return !p || p.create ? [`POST /v1/profiles {name=..., profileType="${profileType}"} # ${id}: ${p?.why ?? 'with the new certificate'}`] : [];
|
||||
})),
|
||||
];
|
||||
// GAME_CENTER on the App ID: the app and its editable version need the Game Center switch too (apple game-center)
|
||||
const gcSw = capabilitySpecs(s, config.games?.gameCenter === true).some(w => w.type === 'GAME_CENTER') ? await (async () => { try { return await readGcSwitch(c, await c.app(), await c.editableVersion()); } catch { return null; /* no app record yet: apple game-center later */ } })() : null;
|
||||
const gcPlan = gcSw ? gcSwitchPlan(gcSw) : [];
|
||||
plan.push(...gcPlan);
|
||||
log(TAG, scope.why);
|
||||
if (needCert && state.certificate.limit && state.certificate.onTeam >= state.certificate.limit)
|
||||
warn(TAG, `the team already has ${state.certificate.onTeam} valid ${certType} certificates (limit ${state.certificate.limit}): Apple will refuse a new one until one is revoked (owner decision, developer.apple.com > Certificates)`);
|
||||
|
|
@ -331,6 +336,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
|
||||
const responses = await execute(c.asc, capPlan.create, { dry: false, tag: TAG });
|
||||
if (capPlan.create.length) await checkCapabilitiesStuck(c, bundleId, capabilitySpecs(s, config.games?.gameCenter === true).map(w => w.type), responses);
|
||||
if (gcPlan.length && gcSw) await ensureGcSwitch(c, gcSw, TAG);
|
||||
if (!scope.profiles) {
|
||||
printCapabilityReport(await capabilityReport(c, config, root), TAG);
|
||||
cleanup();
|
||||
|
|
|
|||
|
|
@ -2,18 +2,18 @@
|
|||
// Local part (no network): the Game Center entitlement, the PGS APP_ID resource and Gradle dependency, the player-id
|
||||
// declaration reminders. Store part: Game Center (App ID capability, detail, version switch, counts, pending changes)
|
||||
// and Play Games Services (counts, pending changes).
|
||||
import { readGameCenter } from './apple/game-center';
|
||||
import { gameCenterInUse, readGameCenter } from './apple/game-center';
|
||||
import { ascClient } from './apple/asc';
|
||||
import { readProjectEntitlements } from './apple/signing';
|
||||
import type { Loaded } from './lib/config';
|
||||
import { consoleUrl } from './lib/console-links';
|
||||
import { type Finding, gamesPrivacyFindings, gcFindings, hasAndroidProject, planGameCenter, planPlayGames, pgsFindings, readPgsProject, usesGameCenter, usesPlayGames } from './lib/game-services';
|
||||
import { type Finding, gamesPrivacyFindings, gcFindings, hasAndroidProject, planGameCenter, planPlayGames, pgsFindings, readPgsProject, usesPlayGames } from './lib/game-services';
|
||||
import type { Finding as VFinding } from './lib/validate-rules';
|
||||
import { gamesConfigClient, playDefaultLocale, readPlayGames } from './play/games';
|
||||
|
||||
export interface GameServicesStatus {
|
||||
apple: null | { capability: boolean | null; enabled: boolean; version: string | null; versionEnabled: boolean | null; leaderboards: number; achievements: number;
|
||||
config: { leaderboards: number; achievements: number }; changes: string[]; error?: string };
|
||||
config: { leaderboards: number; achievements: number }; changes: string[]; error?: string; why?: string };
|
||||
play: null | { appId: string | null; resource: string | null; leaderboards: number; achievements: number;
|
||||
config: { leaderboards: number; achievements: number }; changes: string[]; error?: string };
|
||||
findings: (Finding & { store: 'apple' | 'play' })[];
|
||||
|
|
@ -40,13 +40,14 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p
|
|||
const out: GameServicesStatus = { apple: null, play: null, findings, links: [] };
|
||||
const count = (store: 'apple' | 'play') => ({ leaderboards: (g.leaderboards ?? []).filter(x => !x.platforms || x.platforms.includes(store === 'apple' ? 'ios' : 'android')).length,
|
||||
achievements: (g.achievements ?? []).filter(x => !x.platforms || x.platforms.includes(store === 'apple' ? 'ios' : 'android')).length });
|
||||
if (config.apple && usesGameCenter(config) && o.apple !== false) {
|
||||
const gcUse = config.apple ? gameCenterInUse(config, root) : { inUse: false, why: null };
|
||||
if (config.apple && gcUse.inUse && o.apple !== false) {
|
||||
out.links.push({ label: 'App Store Connect: the version page (Game Center section)', url: consoleUrl('asc.version') });
|
||||
try {
|
||||
const c = await ascClient(root, config.apple.bundleId, config.apple.appId);
|
||||
const s = await readGameCenter(c, config.apple.bundleId);
|
||||
out.apple = { capability: s.capability, enabled: !!s.detail, version: s.version?.versionString ?? null, versionEnabled: s.version ? s.version.enabled : null,
|
||||
leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(planGameCenter(g, config.languages, s)) };
|
||||
leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(planGameCenter(g, config.languages, s)), why: gcUse.why ?? undefined };
|
||||
} catch (e) { out.apple = { capability: null, enabled: false, version: null, versionEnabled: null, leaderboards: 0, achievements: 0, config: count('apple'), changes: [], error: (e as Error).message.slice(0, 300) }; }
|
||||
}
|
||||
if (config.play && usesPlayGames(config) && o.play !== false) {
|
||||
|
|
@ -71,8 +72,10 @@ export function gameServicesFindings(s: GameServicesStatus): VFinding[] {
|
|||
if (a.error) out.push({ store: 'apple', level: 'warn', code: 'game-center', msg: `Game Center not read: ${a.error}` });
|
||||
else {
|
||||
if (a.capability === false) out.push({ store: 'apple', level: 'blocker', code: 'game-center.capability', msg: 'games.gameCenter is true but the App ID has no GAME_CENTER capability: the build\'s profile will not allow Game Center', fix: 'storebound apple signing ensure --dry, then without --dry' });
|
||||
if (!a.enabled) out.push({ store: 'apple', level: 'blocker', code: 'game-center.detail', msg: 'Game Center is not switched on for the app in App Store Connect', fix: 'storebound apple game-center --dry, then without --dry' });
|
||||
if (a.versionEnabled === false) out.push({ store: 'apple', level: 'warn', code: 'game-center.version', msg: `version ${a.version} does not have Game Center switched on`, fix: 'storebound apple game-center' });
|
||||
const why = a.why ? ` (${a.why})` : '';
|
||||
const bounce = 'the submission would answer 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED';
|
||||
if (!a.enabled) out.push({ store: 'apple', level: 'blocker', code: 'game-center.detail', msg: `Game Center is in use${why} but the app has no Game Center detail in App Store Connect: ${bounce}`, fix: 'storebound apple game-center --dry, then without --dry (apple submit runs the same switch)' });
|
||||
if (a.versionEnabled === false) out.push({ store: 'apple', level: 'blocker', code: 'game-center.version', msg: `Game Center is in use${why} but version ${a.version} has no Game Center switch (gameCenterAppVersion): ${bounce}`, fix: 'storebound apple game-center --dry, then without --dry (apple submit runs the same switch)' });
|
||||
if (a.changes.length) out.push({ store: 'apple', level: 'warn', code: 'game-center.sync', msg: `Game Center differs from the config: ${a.changes.slice(0, 6).join('; ')}${a.changes.length > 6 ? ` (+${a.changes.length - 6})` : ''}`, fix: 'storebound apple game-center --dry' });
|
||||
if (a.enabled && !a.changes.length) out.push({ store: 'apple', level: 'ok', code: 'game-center', msg: `Game Center on: ${a.leaderboards} leaderboard(s), ${a.achievements} achievement(s), in step with the config` });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
catch (e) { findings.push({ store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 400) }, ...rendersFindings('play', local?.play)); }
|
||||
} else if (a.has('play')) warn('validate', 'no play section in the config');
|
||||
// Game Center / Play Games Services: entitlement, APP_ID resource, store state vs the config
|
||||
if (config.games && (config.games.gameCenter || config.games.playGames)) {
|
||||
if (config.games || config.apple) {
|
||||
try { findings.push(...gameServicesFindings(await gameServicesStatus(ctx.loaded, { apple: both || a.has('apple'), play: both || a.has('play') }))); }
|
||||
catch (e) { warn('validate', `game services: ${(e as Error).message}`); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1087,3 +1087,16 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
- **Guard:** `cli/lib/game-services.ts` (validateGames, planGameCenter, planPlayGames, pgsFindings, gcFindings),
|
||||
`storebound scan` rule `pgs.manifest` / `pgs.app-id`, `cli/play/games.ts` (no 5xx retry of an insert),
|
||||
`tests/game-services.test.ts`.
|
||||
|
||||
## 89. Submit refused: 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED
|
||||
- **Symptom:** `apple submit` of a first version failed with 409 `STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED`:
|
||||
"The build for this app store version has com.apple.developer.game-center entitlement but appStoreVersion is
|
||||
missing gameCenterConfigurations relationship". The build had the entitlement; the app had no Game Center detail.
|
||||
- **Do:** `storebound apple game-center --dry`, then without `--dry`: `POST /v1/gameCenterDetails` (app) when the
|
||||
app has none, then `POST /v1/gameCenterAppVersions` (appStoreVersion). The resubmit then passes. `apple submit` /
|
||||
`resubmit` and `apple signing ensure` (when GAME_CENTER is wanted) now run the same switch first and show it in
|
||||
the dry run; `validate` blocks while either is missing and Game Center is in the config, the entitlements or the
|
||||
built IPA.
|
||||
- **Guard:** `readGcSwitch` / `gcSwitchPlan` / `ensureGcSwitch` / `gameCenterInUse` in `cli/apple/game-center.ts`,
|
||||
`gameServicesFindings` (codes game-center.detail, game-center.version), the fake store's `gameCenterBuild` 409 and
|
||||
`tests/game-services.test.ts`.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { verifyText } from '../cli/lib/console-verify';
|
|||
import { gameServicesFindings, type GameServicesStatus } from '../cli/games-status';
|
||||
import { run as gameCenter } from '../cli/apple/game-center';
|
||||
import { run as playGames } from '../cli/play/games';
|
||||
import { run as appleSubmit } from '../cli/apple/submit';
|
||||
import { upsertTiming } from '../cli/lib/upsert';
|
||||
import { ascEnv, BASE, capture, playEnv, project, restore } from './helpers/store-mock';
|
||||
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
|
||||
|
|
@ -202,9 +203,47 @@ describe('validate findings', () => {
|
|||
findings: [{ store: 'apple', level: 'error', id: 'gc.entitlement', msg: 'no entitlement' }], links: [],
|
||||
};
|
||||
const f = gameServicesFindings(st);
|
||||
expect(f.filter(x => x.level === 'blocker').map(x => x.code)).toEqual(['gc.entitlement', 'game-center.capability', 'game-center.detail']);
|
||||
expect(f.filter(x => x.level === 'blocker').map(x => x.code)).toEqual(['gc.entitlement', 'game-center.capability', 'game-center.detail', 'game-center.version']);
|
||||
expect(f.find(x => x.code === 'game-center.detail')!.msg).toContain('BUILD_INDICATES_GAME_CENTER_ENABLED');
|
||||
expect(f.map(x => x.code)).toContain('game-center.sync');
|
||||
expect(f.find(x => x.code === 'play-games')!.level).toBe('ok');
|
||||
expect(f.find(x => x.code === 'play-games.achievements')!.level).toBe('warn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('apple submit with a Game Center build (live 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED)', () => {
|
||||
const fresh = () => {
|
||||
s.asc.gameCenterBuild = true;
|
||||
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
|
||||
s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } });
|
||||
};
|
||||
test('the fake refuses the submit like App Store Connect while the switch is missing', async () => {
|
||||
fresh();
|
||||
const r = await capture(() => appleSubmit(project(BASE).ctx(['--build', '8', '--yes'])).catch(e => { console.error(String(e)); }));
|
||||
expect(r.err + r.out).toContain('BUILD_INDICATES_GAME_CENTER_ENABLED');
|
||||
expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).not.toContain('WAITING_FOR_REVIEW');
|
||||
});
|
||||
test('dry run names the switch; --yes creates gameCenterDetails and gameCenterAppVersions before the submit, then passes', async () => {
|
||||
fresh();
|
||||
const p = project(GAME);
|
||||
const dry = await run2(() => appleSubmit(p.ctx(['--build', '8'])));
|
||||
expect(dry.writes).toEqual([]);
|
||||
expect(dry.out).toContain('would POST /v1/gameCenterDetails');
|
||||
expect(dry.out).toContain('would POST /v1/gameCenterAppVersions');
|
||||
const r = await run2(() => appleSubmit(p.ctx(['--build', '8', '--yes'])));
|
||||
const i = r.writes.indexOf('POST /v1/gameCenterDetails'), j = r.writes.indexOf('POST /v1/gameCenterAppVersions'), k = r.writes.findIndex(w => /^PATCH \/v1\/reviewSubmissions\//.test(w));
|
||||
expect(i).toBeGreaterThanOrEqual(0);
|
||||
expect(j).toBeGreaterThan(i);
|
||||
expect(k).toBeGreaterThan(j);
|
||||
expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['WAITING_FOR_REVIEW']);
|
||||
expect(s.asc.all('gameCenterDetails')).toHaveLength(1);
|
||||
});
|
||||
test('apple game-center first, then submit: no second switch', async () => {
|
||||
fresh();
|
||||
const p = project(GAME);
|
||||
await run2(() => gameCenter(p.ctx(['--yes'])));
|
||||
const r = await run2(() => appleSubmit(p.ctx(['--build', '8', '--yes'])));
|
||||
expect(r.writes.filter(w => /gameCenter(Details|AppVersions)$/.test(w))).toEqual([]);
|
||||
expect(s.asc.all('gameCenterAppVersions')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -134,6 +134,9 @@ export class FakeAsc {
|
|||
lag: Record<string, number> = {};
|
||||
/** every uploaded part, by asset id */
|
||||
parts: string[] = [];
|
||||
/** The attached builds carry com.apple.developer.game-center: submitting answers Apple's 409 (seen live) until the
|
||||
* app has a gameCenterDetail and the version a gameCenterAppVersion. */
|
||||
gameCenterBuild = false;
|
||||
|
||||
constructor(readonly appId = '6400000101') {
|
||||
this.put({ type: 'apps', id: appId, attributes: { bundleId: 'com.example.notes', name: 'Example Notes' }, rels: {} });
|
||||
|
|
@ -335,6 +338,13 @@ export class FakeAsc {
|
|||
// seen live (2026-10-12): a REJECTED item that is not marked resolved blocks every submit with this 409, and an
|
||||
// empty draft cannot be canceled
|
||||
const items = this.children(self.id, 'reviewSubmissionItems');
|
||||
if (attrs.submitted && this.gameCenterBuild) {
|
||||
const app = String(self.rels.app);
|
||||
const ver = items.map(it => String(it.rels.appStoreVersion ?? '')).find(Boolean);
|
||||
if (!this.children(app, 'gameCenterDetails').length || (ver && !this.children(ver, 'gameCenterAppVersions').length))
|
||||
return json({ errors: [{ status: '409', code: 'STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED', title: 'The request cannot be fulfilled because of the state of another resource.',
|
||||
detail: 'The build for this app store version has com.apple.developer.game-center entitlement but appStoreVersion is missing gameCenterConfigurations relationship.' }] }, 409);
|
||||
}
|
||||
if (attrs.submitted && items.some(it => it.attributes.state === 'REJECTED'))
|
||||
return json({ errors: [{ status: '409', code: 'STATE_ERROR', title: 'The request cannot be fulfilled because of the state of another resource.', detail: 'Version is not ready to be submitted yet, please try again later' }] }, 409);
|
||||
if (attrs.canceled && self.attributes.state === 'READY_FOR_REVIEW' && !items.length)
|
||||
|
|
|
|||
|
|
@ -86,6 +86,9 @@ describe('apple signing ensure', () => {
|
|||
{ match: /^\/v1\/certificates\?/, answer: page([]) },
|
||||
{ match: /^\/v1\/bundleIds\/BUNDLE0001\/profiles/, answer: page([sbProfile]) },
|
||||
{ match: /^\/v1\/profiles\/PROF0001\/relationships\/certificates/, answer: page([{ type: 'certificates', id: 'CERT0001' }]) },
|
||||
// GAME_CENTER is wanted: ensure reads the app's Game Center switch (on here; the editable version list is empty)
|
||||
{ match: /^\/v1\/apps\/6400000101\/gameCenterDetail/, answer: { data: { type: 'gameCenterDetails', id: 'GCD0001' } } },
|
||||
{ match: /^\/v1\/apps\/6400000101\/appStoreVersions/, answer: page([]) },
|
||||
{ method: 'POST', match: /^\/v1\/bundleIdCapabilities$/, answer: (c: { body: unknown }) => ({ data: cap((c.body as { data: { attributes: { capabilityType: string } } }).data.attributes.capabilityType) }) },
|
||||
];
|
||||
|
||||
|
|
@ -103,6 +106,17 @@ describe('apple signing ensure', () => {
|
|||
expect(calls.filter(c => c.method !== 'GET')).toEqual([]);
|
||||
});
|
||||
|
||||
test('GAME_CENTER without the app\'s Game Center detail: the dry run plans gameCenterDetails too (the submit 409 otherwise)', async () => {
|
||||
ascEnv();
|
||||
const p = project(cfg('automatic'));
|
||||
const rs = routes(['IN_APP_PURCHASE']);
|
||||
rs.unshift({ match: /^\/v1\/apps\/6400000101\/gameCenterDetail/, answer: { errors: [{ status: '404' }] } as never, status: 404 } as never);
|
||||
const calls = mockFetch(rs);
|
||||
const r = await capture(() => signing(p.ctx(['ensure', '--dry'])));
|
||||
expect(r.out + r.err).toContain('would POST /v1/gameCenterDetails');
|
||||
expect(calls.filter(c => c.method !== 'GET')).toEqual([]);
|
||||
});
|
||||
|
||||
test('automatic: a real run adds capabilities, reads them back and creates no certificate or profile', async () => {
|
||||
ascEnv();
|
||||
const p = project(cfg('automatic'));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue