storebound/tests/game-services.test.ts

249 lines
17 KiB
TypeScript

// 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 { 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<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', '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);
});
});