// Shapes the real App Store Connect and Google APIs answered during a read-only run against live accounts, where they // differ from what the first fixtures assumed. Every id, name, email and project number here is a placeholder. // - Google and App Store Connect pretty-print error bodies (multi-line JSON): the reason (SERVICE_DISABLED) sits on a // later line, tables broke and serve's last-line error became a JSON fragment. // - appStoreVersions come grouped by platform, not newest first. // - Play Money leaves out zero fields: 0.99 USD is { currencyCode, nanos } without units. // - App Store Connect answers 404 for diagnostics / power metrics of a build or app with no field data yet. // - Age rating fields an accepted submission left null (gracRatingClassificationNumber, socialMedia*). import { afterEach, describe, expect, test } from 'bun:test'; import { generateKeyPairSync } from 'node:crypto'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { checkAccount, listApps, run as appsRun } from '../cli/apps'; import { collectAppleHealth } from '../cli/health'; import { parseArgs } from '../cli/lib/args'; import { healthFindings } from '../cli/lib/health'; import { errorBody } from '../cli/lib/http'; import { fromMoney } from '../cli/lib/money'; import { appleCheck, latestVersion, listAppleApps, playCheck, pool } from '../cli/lib/store-apps'; import { type AppleSnapshot, appleRules } from '../cli/lib/validate-rules'; type J = Record; // Google's 403 for an API that is off in the service account's Cloud project, as sent (pretty-printed). const SERVICE_DISABLED_BODY = `{ "error": { "code": 403, "message": "Google Play Developer Reporting API has not been used in project 123456789000 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/playdeveloperreporting.googleapis.com/overview?project=123456789000 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", "status": "PERMISSION_DENIED", "details": [ { "@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "SERVICE_DISABLED", "domain": "googleapis.com", "metadata": { "service": "playdeveloperreporting.googleapis.com", "consumer": "projects/123456789000", "containerInfo": "123456789000", "activationUrl": "https://console.developers.google.com/apis/api/playdeveloperreporting.googleapis.com/overview?project=123456789000", "serviceTitle": "Google Play Developer Reporting API" } } ] } } `; // App Store Connect's 404 for diagnostics of a build without field data, as sent (pretty-printed, space before colon). const ASC_NOT_FOUND_BODY = `{ "errors" : [ { "id" : "00000000-0000-0000-0000-000000000000", "status" : "404", "code" : "NOT_FOUND", "title" : "The specified resource does not exist", "detail" : "There is no resource of type '/v1/builds/{id}/diagnosticSignatures' with id 'build-1'" } ] }`; // appStoreVersions of an iOS + macOS app: grouped by platform, macOS first, not sorted by date. const VERSIONS_BY_PLATFORM = { data: [ { type: 'appStoreVersions', id: 'v-mac-2', attributes: { platform: 'MAC_OS', versionString: '0.8.1', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION', createdDate: '2026-09-28T09:36:56-07:00' } }, { type: 'appStoreVersions', id: 'v-mac-1', attributes: { platform: 'MAC_OS', versionString: '0.7.1', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION', createdDate: '2026-09-14T13:26:21-07:00' } }, { type: 'appStoreVersions', id: 'v-ios-3', attributes: { platform: 'IOS', versionString: '0.8.1', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION', createdDate: '2026-09-30T07:37:04-07:00' } }, { type: 'appStoreVersions', id: 'v-ios-2', attributes: { platform: 'IOS', versionString: '0.8.0', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION', createdDate: '2026-09-28T09:36:52-07:00' } }, { type: 'appStoreVersions', id: 'v-ios-1', attributes: { platform: 'IOS', versionString: '0.7.0', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION', createdDate: '2026-09-06T16:22:51-07:00' } }, ], meta: { paging: { total: 5, limit: 200 } } }; const realFetch = globalThis.fetch; const saved = { ...process.env }; afterEach(() => { globalThis.fetch = realFetch; for (const k of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'PLAY_SERVICE_ACCOUNT']) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } }); const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } }); const raw = (text: string, status: number) => new Response(text, { status, headers: { 'content-type': 'application/json' } }); function playKey(): void { const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' }); } function ascKey(): void { const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); process.env.ASC_KEY_ID = 'ABCDE12345'; process.env.ASC_ISSUER_ID = '00000000-0000-0000-0000-000000000000'; process.env.ASC_P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); } describe('error bodies', () => { test('pretty-printed JSON becomes one line; HTML is named; plain text collapses', () => { const one = errorBody(SERVICE_DISABLED_BODY); expect(one).not.toContain('\n'); expect(one).toContain('"reason":"SERVICE_DISABLED"'); expect(errorBody(ASC_NOT_FOUND_BODY)).toStartWith('{"errors":[{"id":'); expect(errorBody('404')).toBe('(HTML error page)'); expect(errorBody('upstream\n timed out')).toBe('upstream timed out'); expect(errorBody('x'.repeat(50), 10)).toBe('xxxxxxxxxx…'); }); }); describe('Play account check with the real 403', () => { test('SERVICE_DISABLED on a later line of the body is api-disabled, not a permission problem', () => { // the error as the getter built it before the fix: the raw, multi-line body after the status const before = new Error(`GET /apps:search?pageSize=1000 -> 403 ${SERVICE_DISABLED_BODY}`); expect(playCheck('sb@example.com', { error: before }).status).toBe('api-disabled'); const after = new Error(`GET /apps:search?pageSize=1000 -> 403 ${errorBody(SERVICE_DISABLED_BODY, 800)}`); expect(playCheck('sb@example.com', { error: after }).status).toBe('api-disabled'); }); test('the CLI: check says api-disabled with the project\'s link; list does not fail: the finding and the local apps (pitfall 46)', async () => { playKey(); globalThis.fetch = (async (input: string | URL | Request) => { const url = String(input instanceof Request ? input.url : input); if (url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'ya29.test', expires_in: 3600 }); if (url.startsWith('https://playdeveloperreporting.googleapis.com/v1beta1/apps:search')) return raw(SERVICE_DISABLED_BODY, 403); return json({ error: 'not recorded' }, 404); }) as typeof fetch; const root = mkdtempSync(join(tmpdir(), 'sb-real-')); const c = await checkAccount('play', root); expect(c).toMatchObject({ status: 'api-disabled', principal: 'sb@example.com', docs: 'https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=123456789000' }); const savedReg = process.env.STOREBOUND_REGISTRY; process.env.STOREBOUND_REGISTRY = join(root, 'projects.json'); const cwd = process.cwd(); process.chdir(root); try { const r = await listApps('play', root, false); expect(r.apps).toEqual([]); expect(r.warnings[0]).not.toContain('\n'); expect(r.warnings[0]).toContain('Reporting API is off'); expect(r.warnings[0]).toContain('Fix: Enable "Google Play Developer Reporting API" for that project: https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=123456789000'); let failed = false; try { await appsRun(parseArgs(['list', '--store', 'play', '--json'], ['json', 'icons'])); } catch { failed = true; } expect(failed).toBe(false); } finally { process.chdir(cwd); if (savedReg === undefined) delete process.env.STOREBOUND_REGISTRY; else process.env.STOREBOUND_REGISTRY = savedReg; } }); }); describe('App Store Connect shapes', () => { test('versions grouped by platform: platforms from every page item, newest iOS version by createdDate', async () => { expect(latestVersion(VERSIONS_BY_PLATFORM.data)).toEqual({ version: '0.8.1', state: 'READY_FOR_DISTRIBUTION', platform: 'IOS' }); // only macOS: the newest of any platform expect(latestVersion(VERSIONS_BY_PLATFORM.data.slice(0, 2).reverse())).toMatchObject({ version: '0.8.1', platform: 'MAC_OS' }); expect(latestVersion([])).toBeNull(); const calls: string[] = []; const r = await listAppleApps(async p => { calls.push(p); if (p.startsWith('/v1/apps?')) return { data: [{ type: 'apps', id: '6400000201', attributes: { name: 'Example Aquarium', bundleId: 'com.example.aquarium', sku: 'AQ1', primaryLocale: 'en-US' } }], links: {} }; return VERSIONS_BY_PLATFORM; }); expect(r.apps[0]).toMatchObject({ platforms: ['MAC_OS', 'IOS'], latest: { version: '0.8.1', platform: 'IOS' } }); expect(calls[1]).toContain('appStoreVersions?limit=200&'); }); test('per-app reads run a few at a time and keep the order of apps and warnings', async () => { let inFlight = 0, most = 0; const out = await pool([1, 2, 3, 4, 5, 6, 7, 8, 9], 4, async n => { inFlight++; most = Math.max(most, inFlight); await Bun.sleep(10 - n); inFlight--; return n * 2; }); expect(out).toEqual([2, 4, 6, 8, 10, 12, 14, 16, 18]); expect(most).toBe(4); const ids = Array.from({ length: 6 }, (_, i) => String(6400000300 + i)); const r = await listAppleApps(async p => { if (p.startsWith('/v1/apps?')) return { data: ids.map(id => ({ type: 'apps', id, attributes: { name: `App ${id.slice(-1)}`, bundleId: `com.example.a${id.slice(-1)}` } })), links: {} }; await Bun.sleep(Number(p.split('/')[3]!.slice(-1)) % 3); throw new Error(`GET ${p} -> 500 ${errorBody(ASC_NOT_FOUND_BODY)}`); }); expect(r.warnings.map(w => w.split(':')[0])).toEqual(ids.map(id => `com.example.a${id.slice(-1)}`)); expect(r.warnings[0]).toContain('There is no resource of type'); }); test('account check counts the team\'s apps from meta.paging.total, one app read', async () => { ascKey(); const seen: string[] = []; globalThis.fetch = (async (input: string | URL | Request) => { seen.push(String(input)); return json({ data: [{ type: 'apps', id: '6400000201', attributes: { bundleId: 'com.example.aquarium' } }], links: {}, meta: { paging: { total: 5, limit: 1 } } }); }) as typeof fetch; expect(await checkAccount('apple', mkdtempSync(join(tmpdir(), 'sb-real-')))).toMatchObject({ status: 'ok', apps: 5 }); expect(seen).toEqual(['https://api.appstoreconnect.apple.com/v1/apps?fields[apps]=bundleId&limit=1']); expect(appleCheck({ error: new Error(`GET /v1/apps -> 403 ${errorBody('{\n "errors" : [ { "status" : "403", "code" : "FORBIDDEN_ERROR" } ]\n}')}`) }).status).toBe('permission'); }); }); describe('health with no field data yet', () => { test('404 for power metrics and diagnostic signatures means none, not an unreadable store', async () => { ascKey(); globalThis.fetch = (async (input: string | URL | Request) => { const url = String(input).replace('https://api.appstoreconnect.apple.com', ''); if (url.startsWith('/v1/apps/6400000401/perfPowerMetrics')) return raw(ASC_NOT_FOUND_BODY, 404); if (url.startsWith('/v1/builds?filter[app]=6400000401')) return json({ data: [{ type: 'builds', id: 'build-1', attributes: { version: '11' } }, { type: 'builds', id: 'build-2', attributes: { version: '10' } }], links: {} }); if (url === '/v1/builds/build-1/diagnosticSignatures?limit=10') return raw(ASC_NOT_FOUND_BODY, 404); if (url === '/v1/builds/build-2/diagnosticSignatures?limit=10') return json({ data: [{ type: 'diagnosticSignatures', id: 's1', attributes: { diagnosticType: 'HANGS', signature: 'main thread', weight: 0.4 } }] }); if (url.startsWith('/v1/apps/6400000401/betaFeedbackCrashSubmissions')) return json({ data: [], links: {} }); return raw(ASC_NOT_FOUND_BODY.replace('404', '500'), 418); }) as typeof fetch; const h = await collectAppleHealth(mkdtempSync(join(tmpdir(), 'sb-real-')), 'com.example.cutter', '6400000401', 7); expect(h.metrics).toEqual([]); expect(h.signatures).toEqual([{ build: '10', type: 'HANGS', signature: 'main thread', weight: 0.4, id: 's1' }]); }); test('other errors still make the store unreadable', async () => { ascKey(); globalThis.fetch = (async () => raw('{\n "errors" : [ { "status" : "403", "code" : "FORBIDDEN_ERROR" } ]\n}', 403)) as unknown as typeof fetch; await expect(collectAppleHealth(mkdtempSync(join(tmpdir(), 'sb-real-')), 'com.example.cutter', '6400000401', 7)).rejects.toThrow(/-> 403 \{"errors"/); }); test('Play vitals behind a disabled Reporting API: says so and how to turn it on', () => { const f = healthFindings({ date: '2026-10-10', play: { error: `GET /v1beta1/apps/com.example.cutter/crashRateMetricSet -> 403 ${errorBody(SERVICE_DISABLED_BODY, 800)}` } }); expect(f).toEqual([expect.objectContaining({ store: 'play', level: 'warn', code: 'vitals', msg: expect.stringContaining('Reporting API is off') })]); expect(f[0]!.fix).toContain('enable "Google Play Developer Reporting API"'); expect(f[0]!.msg).not.toContain('\n'); }); }); describe('Play money without units', () => { test('0.99 comes back as nanos only; whole prices without nanos', () => { expect(fromMoney({ currencyCode: 'USD', nanos: 990000000 })).toBeCloseTo(0.99, 9); expect(fromMoney({ currencyCode: 'USD', units: '3' })).toBe(3); expect(fromMoney({ currencyCode: 'USD', units: '2', nanos: 990000000 })).toBeCloseTo(2.99, 9); }); }); describe('validate: age rating fields accepted submissions leave null', () => { const snap = (ageRating: J): AppleSnapshot => ({ version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION' }, build: { number: '1', processingState: 'VALID', usesNonExemptEncryption: false, declarationState: null }, expectedLocales: ['en-US'], locales: [{ locale: 'en-US', subtitle: 's', description: 'd', keywords: 'k', screenshots: { APP_IPHONE_67: 5 } }], requiredDisplayTypes: ['APP_IPHONE_67'], reviewDetail: { contactEmail: 'review@example.com', contactPhone: '+1 555 0100', contactFirstName: 'A', contactLastName: 'B', notes: 'n' }, ageRating, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, }); // the declaration of an app in review (values as returned; 30 fields in 2026) const IN_REVIEW: J = { advertising: true, gambling: false, kidsAgeBand: null, lootBox: false, messagingAndChat: false, socialMedia: null, socialMediaAgeRestricted: null, userGeneratedContent: false, ageRatingOverride: 'NONE', ageRatingOverrideV2: 'NONE', koreaAgeRatingOverride: 'NONE', gracRatingClassificationNumber: null, developerAgeRatingInfoUrl: null }; test('no blocker; socialMedia unanswered is a warning', () => { const f = appleRules(snap(IN_REVIEW)).filter(x => x.code === 'age-rating'); expect(f.map(x => x.level).sort()).toEqual(['ok', 'warn']); const w = f.find(x => x.level === 'warn')!; expect(w.msg).toContain('socialMedia'); expect(w.msg).not.toContain('socialMediaAgeRestricted'); }); test('socialMediaAgeRestricted is asked once socialMedia is yes; other nulls still block', () => { expect(appleRules(snap({ ...IN_REVIEW, socialMedia: true })).find(x => x.code === 'age-rating')).toMatchObject({ level: 'blocker', msg: 'age rating unanswered: socialMediaAgeRestricted' }); expect(appleRules(snap({ ...IN_REVIEW, socialMedia: false, lootBox: null })).filter(x => x.code === 'age-rating').map(x => `${x.level}:${x.msg}`)).toEqual(['blocker:age rating unanswered: lootBox']); }); });