diff --git a/tests/game-services.test.ts b/tests/game-services.test.ts new file mode 100644 index 0000000..8287d4f --- /dev/null +++ b/tests/game-services.test.ts @@ -0,0 +1,210 @@ +// 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, 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 { 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 { 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('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('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']); + 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'); + }); +}); diff --git a/tests/helpers/fake-stores.ts b/tests/helpers/fake-stores.ts index 9ef43e1..6897d42 100644 --- a/tests/helpers/fake-stores.ts +++ b/tests/helpers/fake-stores.ts @@ -75,6 +75,12 @@ const LISTS: Record = { 'appAssetLibraries/images': { type: 'appAssetLibraryImages' }, 'appAssetLibraries/videos': { type: 'appAssetLibraryVideos' }, 'appStoreVersionLocalizations/placements': { type: 'appAssetLibraryPlacements' }, + 'apps/gameCenterDetail': { type: 'gameCenterDetails', one: true }, + 'appStoreVersions/gameCenterAppVersion': { type: 'gameCenterAppVersions', one: true }, + 'gameCenterDetails/gameCenterLeaderboards': { type: 'gameCenterLeaderboards' }, + 'gameCenterDetails/gameCenterAchievements': { type: 'gameCenterAchievements' }, + 'gameCenterLeaderboards/localizations': { type: 'gameCenterLeaderboardLocalizations' }, + 'gameCenterAchievements/localizations': { type: 'gameCenterAchievementLocalizations' }, }; /** Stores refuse a second resource with the same key (409 ENTITY_ERROR ... already exists). */ const UNIQUE: Record string> = { @@ -100,6 +106,12 @@ const UNIQUE: Record string> = { promotedPurchases: r => String(r.rels.inAppPurchaseV2 ?? r.rels.subscription), winBackOffers: r => `${r.rels.subscription}|${r.attributes.offerId}`, betaTesters: r => String(r.attributes.email).toLowerCase(), + gameCenterDetails: r => String(r.rels.app), + gameCenterAppVersions: r => String(r.rels.appStoreVersion), + gameCenterLeaderboards: r => String(r.attributes.vendorIdentifier), + gameCenterAchievements: r => String(r.attributes.vendorIdentifier), + gameCenterLeaderboardLocalizations: r => `${r.rels.gameCenterLeaderboard}|${r.attributes.locale}`, + gameCenterAchievementLocalizations: r => `${r.rels.gameCenterAchievement}|${r.attributes.locale}`, }; const ASSETS = /Screenshots$|^appPreviews$|^appScreenshots$|^appAssetLibrary(Images|Videos)$/; /** App Asset Library assets: a state of their own (AWAITING_UPLOAD -> PREPARE_FOR_SUBMISSION after the commit, as Apple's @@ -496,13 +508,43 @@ export class FakePlay { // ------------------------------------------------------------------------------------------------- fetch + counting -export interface Stores { asc: FakeAsc; play: FakePlay; calls: Call[]; faults: Fault[]; failPart?: (n: number) => boolean } +// ----------------------------------------------------------------- Play Games Services Configuration API (v1configuration) + +/** achievementConfigurations / leaderboardConfigurations: list (items, nextPageToken), insert (Google generates the + * id, CgkI...), update (PUT replaces the configuration; `published` stays Google's). Shapes from Google's reference. */ +export class FakeGames { + achievements = new Map>(); + leaderboards = new Map>(); + next = 1; + handle(method: string, url: string, body: unknown): Response { + const p = new URL(url).pathname.replace(/^\/games\/v1configuration/, ''); + let m: RegExpExecArray | null; + if ((m = /^\/applications\/(\d+)\/(achievements|leaderboards)$/.exec(p))) { + const map = this[m[2] as 'achievements' | 'leaderboards']; + if (method === 'GET') return json({ kind: `gamesConfiguration#${m[2] === 'achievements' ? 'achievement' : 'leaderboard'}ConfigurationListResponse`, items: [...map.values()] }); + const id = `CgkI${m[1]}EAIQ${this.next++}`; + const r = { ...(body as Record), id, token: id }; + map.set(id, r); + return json(r); + } + if ((m = /^\/(achievements|leaderboards)\/([^/]+)$/.exec(p))) { + const map = this[m[1] as 'achievements' | 'leaderboards']; + const id = decodeURIComponent(m[2]!); + if (!map.has(id)) return json({ error: { code: 404, status: 'NOT_FOUND', message: 'Not found' } }, 404); + if (method === 'PUT') map.set(id, { ...(body as Record), id, token: id, published: map.get(id)!.published }); + return json(map.get(id)); + } + return json({ error: { code: 599, message: `fake games: ${method} ${p}` } }, 599); + } +} + +export interface Stores { asc: FakeAsc; play: FakePlay; games: FakeGames; calls: Call[]; faults: Fault[]; failPart?: (n: number) => boolean } const realFetch = globalThis.fetch; /** Installs both fakes as fetch. Faults apply in order (`times`, default 1). */ export function installStores(): Stores { - const s: Stores = { asc: new FakeAsc(), play: new FakePlay(), calls: [], faults: [] }; + const s: Stores = { asc: new FakeAsc(), play: new FakePlay(), games: new FakeGames(), calls: [], faults: [] }; let partNo = 0; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = String(input instanceof Request ? input.url : input); @@ -524,6 +566,7 @@ export function installStores(): Stores { res = new Response(null, { status: 200 }); } else if (url.startsWith(ASC)) res = s.asc.handle(method, path, body); else if (url.startsWith('https://androidpublisher.googleapis.com/')) res = s.play.handle(method, url, body instanceof Uint8Array ? body : body); + else if (url.startsWith('https://gamesconfiguration.googleapis.com/')) res = s.games.handle(method, url, body); else res = new Response(`fake: no route ${method} ${url}`, { status: 599 }); if (f?.when === 'after') return json({ errors: [{ status: String(f.status ?? 502), detail: 'injected after the write' }] }, f.status ?? 502); return res;