// Game Center and Play Games Services: config validation, the capability ensure, Game Center enable + read-back, the // leaderboard / achievement upsert on both stores (create, update, no-op) against the fake stores, the entitlement // and APP_ID checks, the PGS console plan's start URLs and the validate findings. Nothing reaches the network. import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; import type { ShipConfig } from '../cli/lib/config'; import { validateConfig } from '../cli/lib/config'; import { capabilitySpecs, planCapabilities } from '../cli/lib/signing'; import { gamesPrivacyFindings, gcFindings, gcLocaleMap, liveGcLocales, suggestLocaleMap, pgsFindings, planGameCenter, playAchievementBody, playLeaderboardBody, readPgsProject, validateGames } from '../cli/lib/game-services'; import { consolePlan } from '../cli/lib/console-plan'; import { verifyText } from '../cli/lib/console-verify'; import { gameItemRows, 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'; let s: Stores; beforeAll(() => { ascEnv(); playEnv(); }); afterAll(restore); beforeEach(() => { upsertTiming.waitMs = 0; s = installStores(); }); afterEach(() => { uninstallStores(); }); const APP = '6400000101'; const PGS = '123456789012'; const GAME: ShipConfig = { ...BASE, app: { ...BASE.app, profile: 'game' }, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'tr', apple: 'tr', play: 'tr-TR' }], games: { gameCenter: true, playGames: { appId: PGS }, leaderboards: [{ id: 'com.example.game.highscore', locales: { en: { name: 'High score', suffix: ' points' }, tr: { name: 'En yüksek skor' } } }], achievements: [{ id: 'com.example.game.first-win', points: 10, locales: { en: { name: 'First win', description: 'Win a round.' } } }, { id: 'com.example.game.secret', points: 20, hidden: true, locales: { en: { name: 'Secret', description: 'Find the secret room.', earned: 'You found it.' } } }], }, }; const run2 = async (fn: () => Promise) => { const a = s.calls.length; const r = await capture(fn); if (r.exit) throw new Error(`exit ${r.exit}\n${r.out}\n${r.err}`); return { ...r, writes: writes(s.calls.slice(a)).map(c => `${c.method} ${decodeURIComponent(c.path).replace(/\?.*/, '')}`) }; }; describe('config', () => { test('a valid games section passes; limits and ids are checked', () => { expect(validateConfig(GAME)).toEqual([]); const bad = validateGames({ gameCenter: true, playGames: {}, achievements: [{ id: 'a', points: 7, locales: { en: { name: 'A', description: 'x' } } }, { id: 'a', points: 101, locales: {} }] }, GAME.languages); expect(bad.join('\n')).toContain('not a multiple of 5'); expect(bad.join('\n')).toContain('used twice'); expect(bad.join('\n')).toContain('points 0-100'); expect(validateGames({ leaderboards: [{ id: 'x', locales: { de: { name: 'X' } } }] }, GAME.languages).join()).toContain('not a language id'); }); test('Play bodies: number format, hidden, suffix per language', () => { expect(playLeaderboardBody({ id: 't', format: 'ELAPSED_TIME_SECOND', sort: 'low', locales: { en: { name: 'Time' } } }, GAME.languages)).toMatchObject({ scoreOrder: 'SMALLER_IS_BETTER', draft: { scoreFormat: { numberFormatType: 'TIME_DURATION' } } }); expect(playAchievementBody(GAME.games!.achievements![1]!, GAME.languages)).toMatchObject({ initialState: 'HIDDEN', achievementType: 'STANDARD', draft: { pointValue: 20 } }); }); }); describe('capability ensure', () => { test('games.gameCenter adds GAME_CENTER to what signing ensure wants, once', () => { expect(capabilitySpecs(undefined, true)).toEqual([{ type: 'GAME_CENTER' }]); expect(capabilitySpecs({ capabilities: ['GAME_CENTER', 'IN_APP_PURCHASE'] }, true).map(c => c.type)).toEqual(['GAME_CENTER', 'IN_APP_PURCHASE']); const p = planCapabilities('bid-1', ['IN_APP_PURCHASE'], capabilitySpecs({ capabilities: ['IN_APP_PURCHASE'] }, true)); expect(p.create.map(r => (r.body as { data: { attributes: { capabilityType: string } } }).data.attributes.capabilityType)).toEqual(['GAME_CENTER']); expect(planCapabilities('bid-1', ['GAME_CENTER'], capabilitySpecs(undefined, true)).create).toEqual([]); }); }); describe('apple game-center (fake App Store Connect)', () => { const version = () => s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } }); test('--dry writes nothing and names every create', async () => { version(); const r = await run2(() => gameCenter(project(GAME).ctx(['--dry']))); expect(r.writes).toEqual([]); expect(r.out).toContain('POST /v1/gameCenterDetails'); expect(r.out).toContain('leaderboard com.example.game.highscore'); }); test('enable + version + create, read back; the re-run writes nothing; a changed text is one PATCH', async () => { version(); const p = project(GAME); const first = await run2(() => gameCenter(p.ctx(['--yes']))); expect(first.writes).toEqual([ 'POST /v1/gameCenterDetails', 'POST /v1/gameCenterAppVersions', 'POST /v1/gameCenterLeaderboards', 'POST /v1/gameCenterLeaderboardLocalizations', 'POST /v1/gameCenterLeaderboardLocalizations', 'POST /v1/gameCenterAchievements', 'POST /v1/gameCenterAchievementLocalizations', 'POST /v1/gameCenterAchievements', 'POST /v1/gameCenterAchievementLocalizations', ]); expect(s.asc.all('gameCenterLeaderboards')[0]!.attributes).toMatchObject({ vendorIdentifier: 'com.example.game.highscore', scoreSortType: 'DESC', submissionType: 'BEST_SCORE', defaultFormatter: 'INTEGER' }); expect(s.asc.all('gameCenterAchievements')[1]!.attributes).toMatchObject({ showBeforeEarned: false, points: 20 }); expect(first.err).not.toContain('read back'); const again = await run2(() => gameCenter(p.ctx(['--yes']))); expect(again.writes).toEqual([]); expect(again.out).toContain('in step'); const changed: ShipConfig = { ...GAME, games: { ...GAME.games!, achievements: [{ ...GAME.games!.achievements![0]!, points: 15, locales: { en: { name: 'First victory', description: 'Win a round.' } } }, GAME.games!.achievements![1]!] } }; const upd = await run2(() => gameCenter(project(changed).ctx(['--yes']))); expect(upd.writes).toEqual(['PATCH /v1/gameCenterAchievements/' + s.asc.all('gameCenterAchievements')[0]!.id, 'PATCH /v1/gameCenterAchievementLocalizations/' + s.asc.all('gameCenterAchievementLocalizations')[0]!.id]); }); test('an existing detail with the leaderboard already there is kept (matched by vendor id, no duplicate)', () => { const plan = planGameCenter(GAME.games!, GAME.languages, { leaderboards: [{ id: 'lb-1', attributes: { vendorIdentifier: 'com.example.game.highscore', referenceName: 'com.example.game.highscore', defaultFormatter: 'INTEGER', submissionType: 'BEST_SCORE', scoreSortType: 'DESC' }, localizations: [{ id: 'l1', attributes: { locale: 'en-US', name: 'High score', formatterSuffix: ' points' } }, { id: 'l2', attributes: { locale: 'tr', name: 'En yüksek skor' } }] }], achievements: [] }); expect(plan[0]).toMatchObject({ action: 'keep', locales: [{ action: 'keep' }, { action: 'keep' }] }); }); test('a lost create answer (5xx after the write) is not sent twice', async () => { version(); s.faults.push({ method: 'POST', match: /\/v1\/gameCenterLeaderboards$/, when: 'after' }); const r = await run2(() => gameCenter(project(GAME).ctx(['--yes']))); expect(r.writes.filter(w => w === 'POST /v1/gameCenterLeaderboards')).toHaveLength(1); expect(s.asc.all('gameCenterLeaderboards')).toHaveLength(1); }); }); describe('Game Center locale map', () => { const live = { leaderboards: [{ id: 'lb-1', attributes: { vendorIdentifier: 'com.example.game.highscore', referenceName: 'com.example.game.highscore', defaultFormatter: 'INTEGER', submissionType: 'BEST_SCORE', scoreSortType: 'DESC' }, localizations: [{ id: 'l1', attributes: { locale: 'en-GB', name: 'High score', formatterSuffix: ' points' } }, { id: 'l2', attributes: { locale: 'tr', name: 'En yüksek skor' } }] }], achievements: [] }; const only = { ...GAME.games!, achievements: [] }; const sig = (p: ReturnType) => p[0]!.locales.map(l => l.locale + ':' + l.action); test('without a map the config en-US is created next to the live en-GB, and the suggestion names the map', () => { expect(sig(planGameCenter(only, GAME.languages, live))).toEqual(['en-US:create', 'tr:keep']); expect(suggestLocaleMap(GAME.languages, liveGcLocales(live))).toEqual([{ id: 'en', from: 'en-US', to: 'en-GB' }]); }); test('--locale-map en=en-GB (or games.localeMap) leaves nothing to create or update, and no suggestion remains', () => { const map = gcLocaleMap(only, ['en=en-GB']); expect(map).toEqual({ en: 'en-GB' }); const plan = planGameCenter(only, GAME.languages, live, map); expect(sig(plan)).toEqual(['en-GB:keep', 'tr:keep']); expect(plan[0]!.action).toBe('keep'); expect(suggestLocaleMap(GAME.languages, liveGcLocales(live), map)).toEqual([]); expect(gcLocaleMap({ ...only, localeMap: { en: 'en-AU' } }, ['en=en-GB,tr=tr'])).toEqual({ en: 'en-GB', tr: 'tr' }); expect(planGameCenter(only, GAME.languages, live, { 'en-US': 'en-GB' })[0]!.locales[0]!.locale).toBe('en-GB'); }); test('two English variants live: no guess; a bad map entry is refused', () => { expect(suggestLocaleMap(GAME.languages, ['en-GB', 'en-AU', 'tr'])).toEqual([]); expect(() => gcLocaleMap(only, ['en'])).toThrow('--locale-map'); expect(validateGames({ ...only, localeMap: { en: 'english' } }, GAME.languages).join()).toContain('games.localeMap.en'); }); test('apple game-center against the fake store: the dry run warns with the exact flag; with the flag nothing is written', async () => { s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } }); const base = await run2(() => gameCenter(project(GAME).ctx(['--yes']))); expect(base.writes.length).toBeGreaterThan(0); for (const l of [...s.asc.all('gameCenterLeaderboardLocalizations'), ...s.asc.all('gameCenterAchievementLocalizations')]) if (l.attributes.locale === 'en-US') l.attributes.locale = 'en-GB'; const dry = await run2(() => gameCenter(project(GAME).ctx(['--dry']))); expect(dry.out + dry.err).toContain('--locale-map en=en-GB'); const mapped = await run2(() => gameCenter(project(GAME).ctx(['--yes', '--locale-map', 'en=en-GB']))); expect(mapped.writes).toEqual([]); }); }); describe('play games (fake Games Configuration API)', () => { test('create, no-op re-run, update by name match; playId wins', async () => { const p = project(GAME); const first = await run2(() => playGames(p.ctx(['--yes']))); expect(first.writes).toEqual([`POST /games/v1configuration/applications/${PGS}/leaderboards`, `POST /games/v1configuration/applications/${PGS}/achievements`, `POST /games/v1configuration/applications/${PGS}/achievements`]); const lb = [...s.games.leaderboards.values()][0] as { draft: { name: { translations: { locale: string }[] }; scoreFormat: unknown } }; expect(lb.draft.name.translations.map(t => t.locale)).toEqual(['en-US', 'tr-TR']); expect((await run2(() => playGames(p.ctx(['--yes'])))).writes).toEqual([]); const id = [...s.games.achievements.keys()][0]!; const changed: ShipConfig = { ...GAME, games: { ...GAME.games!, achievements: [{ ...GAME.games!.achievements![0]!, points: 15 }, GAME.games!.achievements![1]!] } }; expect((await run2(() => playGames(project(changed).ctx(['--yes'])))).writes).toEqual([`PUT /games/v1configuration/achievements/${id}`]); expect((s.games.achievements.get(id) as { draft: { pointValue: number } }).draft.pointValue).toBe(15); }); test('--dry writes nothing', async () => { const r = await run2(() => playGames(project(GAME).ctx(['--dry']))); expect(r.writes).toEqual([]); expect(r.out).toContain('achievement com.example.game.first-win'); }); test('an insert with an unclear answer is not retried blindly', async () => { s.faults.push({ method: 'POST', match: /\/leaderboards$/, when: 'after', status: 503 }); await run2(() => playGames(project(GAME).ctx(['--yes']))); expect(s.games.leaderboards.size).toBe(1); }); }); describe('play games with the Games Configuration API disabled', () => { test('a 403 SERVICE_DISABLED names the API, the enable URL for the project and the console plan', async () => { s.faults.push({ method: 'GET', match: /leaderboards/, when: 'before', status: 403, times: 5, code: 'SERVICE_DISABLED', detail: 'Google Play Game Services Publishing API has not been used in project 987654321 before or it is disabled. SERVICE_DISABLED accessNotConfigured' }); const err = await run2(() => playGames(project(GAME).ctx(['--dry']))).then(() => null, (e: Error) => e); expect(err?.message).toContain('Google Play Game Services Publishing API'); expect(err?.message).toContain('gamesconfiguration.googleapis.com'); expect(err?.message).toContain('https://console.cloud.google.com/apis/library/gamesconfiguration.googleapis.com?project=987654321'); expect(err?.message).toContain('storebound console plan play-games-services'); }); test('the console plan has the enable step', () => { const plan = consolePlan('play-games-services', GAME); expect(JSON.stringify(plan)).toContain('gamesconfiguration.googleapis.com'); }); }); describe('project checks', () => { const ENT = 'com.apple.developer.game-center'; test('Game Center entitlement: missing, unreferenced, present, present without config', () => { expect(gcFindings(GAME, [])[0]).toMatchObject({ level: 'error', id: 'gc.entitlement' }); expect(gcFindings(GAME, [{ file: 'ios/App/App.entitlements', caps: [{ cap: 'GAME_CENTER' }], referenced: false }])[0]!.msg).toContain('CODE_SIGN_ENTITLEMENTS'); expect(gcFindings(GAME, [{ file: 'ios/App/App.entitlements', caps: [{ cap: 'GAME_CENTER' }], referenced: true }])[0]!.level).toBe('ok'); expect(gcFindings(BASE, [{ file: 'x.entitlements', caps: [{ cap: 'GAME_CENTER' }], referenced: true }])[0]!.id).toBe('gc.config'); void ENT; }); const MANIFEST = ''; const GRADLE = 'dependencies { implementation "com.google.android.gms:play-services-games-v2:20.1.2" }'; test('APP_ID: resolved from games-ids.xml and compared with the config', () => { const p = project(GAME, { 'android/app/build.gradle': GRADLE, 'android/app/src/main/AndroidManifest.xml': MANIFEST, 'android/app/src/main/res/values/games-ids.xml': `${PGS}` }); const pgs = readPgsProject(p.root); expect(pgs.appId).toEqual({ value: PGS, file: 'android/app/src/main/res/values/games-ids.xml' }); expect(pgsFindings(GAME, pgs).map(f => f.level)).toEqual(['ok']); expect(pgsFindings({ games: { playGames: { appId: '999999999999' } } }, pgs)[0]).toMatchObject({ level: 'error', id: 'pgs.app-id' }); }); test('APP_ID: the dependency without the meta-data is an error; without config a warning', () => { const p = project(BASE, { 'android/app/build.gradle': GRADLE, 'android/app/src/main/AndroidManifest.xml': '' }); const f = pgsFindings(BASE, readPgsProject(p.root)); expect(f.map(x => x.id)).toEqual(['pgs.config', 'pgs.manifest']); }); test('player id reminders: App Privacy USER_ID and Data safety PSL_USER_ACCOUNT', () => { const f = gamesPrivacyFindings({ ...GAME, dataSafety: { collectsData: true, data: {} } as never, appPrivacy: { types: {} } as never }); expect(f.map(x => x.id)).toEqual(['games.data-safety', 'games.app-privacy']); expect(gamesPrivacyFindings({ ...GAME, dataSafety: { data: { PSL_USER_ACCOUNT: {} } } as never, appPrivacy: { types: { USER_ID: {} } } as never })).toEqual([]); }); }); describe('console plan play-games-services', () => { test('deep link with ids, absolute Google Cloud links, Publish is the owner\'s', () => { const p = consolePlan('play-games-services', { ...GAME, play: { ...GAME.play!, developerId: '4444', appId: '9876' } }); expect(p.start).toEqual({ url: 'https://play.google.com/console/u/0/developers/4444/app/9876/play-games-services/setup', deepLink: true, reach: [] }); const text = p.steps.map(x => x.text).join('\n'); expect(text).toContain('https://play.google.com/console/u/0/developers/4444/app/9876/keymanagement'); expect(text).toContain('https://console.cloud.google.com/apis/credentials'); expect(p.buttons.final).toBe('Publish'); expect(p.buttons.neverPress).toContain('Publish'); expect(p.ownerBatch.some(o => o.kind === 'final' && o.text.includes('Publish'))).toBe(true); expect(p.askOwner.join()).toContain('Web OAuth client'); }); test('without ids: the console and how to reach it', () => { const p = consolePlan('play-games-services', GAME); expect(p.start.deepLink).toBe(false); expect(p.start.url.startsWith('https://play.google.com/console')).toBe(true); }); test('verify from the page text', () => { expect(verifyText('play-games-services', GAME, `Project ID ${PGS}\nCredentials\nAndroid com.example.notes\nReady to publish`).ok).toBe(true); expect(verifyText('play-games-services', GAME, 'Project ID 1\nNo credentials').mismatches).toBeGreaterThan(0); }); }); describe('validate findings', () => { test('missing capability and detail are blockers; in step is ok; fewer than 5 Play achievements warns', () => { const st: GameServicesStatus = { apple: { capability: false, enabled: false, version: '1.0.0', versionEnabled: false, leaderboards: 0, achievements: 0, config: { leaderboards: 1, achievements: 2 }, changes: ['leaderboard x: create'] }, play: { appId: PGS, resource: null, leaderboards: 1, achievements: 2, config: { leaderboards: 1, achievements: 2 }, changes: [] }, 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', '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-0', attributes: { versionString: '0.9.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' }, rels: { app: APP } }); // an update, not a first release 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); }); }); describe('games status rows (desktop Players section)', () => { test('one row per item, store states from the plans, null when a store was not read', () => { const g = { leaderboards: [{ id: 'com.example.lb', locales: { en: { name: 'Best' } } }], achievements: [{ id: 'com.example.a1', points: 10, hidden: true, platforms: ['android' as const], locales: { en: { name: 'First', description: 'd' } } }] }; const plan = [{ kind: 'leaderboard' as const, id: 'com.example.lb', action: 'update' as const, attributes: {}, changed: ['scoreSortType'], locales: [{ locale: 'en-US', action: 'create' as const, attributes: {}, changed: [] }] }]; const rows = gameItemRows(g, plan, null); expect(rows.map(r => [r.kind, r.id, r.name, r.ios, r.android])).toEqual([['leaderboard', 'com.example.lb', 'Best', true, true], ['achievement', 'com.example.a1', 'First', false, true]]); expect(rows[0]!.apple).toEqual({ action: 'update', changed: ['scoreSortType'], locales: [{ locale: 'en-US', action: 'create' }] }); expect(rows[0]!.play).toBeNull(); expect(rows[1]).toMatchObject({ points: 10, hidden: true, apple: null }); }); });