tests: game services on both stores against the fakes (capability ensure, Game Center enable and read-back, leaderboard / achievement create, update, no-op, lost answers), entitlement and APP_ID checks, PGS console plan URLs and verify, validate findings; fake Games Configuration API

This commit is contained in:
CRTkafa 2026-10-21 05:06:24 +07:00
parent 9f48dd57a4
commit a4c31e096b
2 changed files with 255 additions and 2 deletions

210
tests/game-services.test.ts Normal file
View file

@ -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<void>) => {
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 = '<?xml version="1.0"?><plist><dict><key>com.apple.developer.game-center</key><true/></dict></plist>';
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 = '<manifest><application><meta-data android:name="com.google.android.gms.games.APP_ID" android:value="@string/game_services_project_id"/></application></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': `<resources><string name="game_services_project_id" translatable="false">${PGS}</string></resources>` });
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': '<manifest/>' });
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');
});
});

View file

@ -75,6 +75,12 @@ const LISTS: Record<string, { type: string; one?: boolean }> = {
'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, (r: R) => string> = {
@ -100,6 +106,12 @@ const UNIQUE: Record<string, (r: R) => 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<string, Record<string, unknown>>();
leaderboards = new Map<string, Record<string, unknown>>();
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<string, unknown>), 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<string, unknown>), 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;