// Google API errors as typed findings (pitfall 57): the live 403 SERVICE_DISABLED body of the Play Developer Reporting // API, a missing app permission, a revoked key and a quota; the one plain line the CLI prints; and the desktop // runner's outcome of exit codes (scan / check / validate exit 1 with findings is a result, not an error). import { describe, expect, test } from 'bun:test'; import { EXAMPLE_CLOUD_PROJECT, healthApiDisabled, SCAN_FAILED, SCAN_FAILED_LINE, serviceDisabledMessage } from '../apps/desktop/src/dev/error-fixtures'; import { errorLine, outcomeOf } from '../cli/lib/desktop-protocol'; import { findingText, parseGoogleError, plainError, storeReadError } from '../cli/lib/google-errors'; import { healthFindings, healthTable } from '../cli/lib/health'; const URL = `https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=${EXAMPLE_CLOUD_PROJECT}`; const LINE = `Crash and ANR data needs the Google Play Developer Reporting API turned on in Google Cloud project ${EXAMPLE_CLOUD_PROJECT}.`; describe('403 SERVICE_DISABLED (live shape)', () => { const msg = serviceDisabledMessage('com.example.demo'); test('parses to api-disabled with the service, project and a console URL the desktop may open', () => { expect(parseGoogleError(msg)).toEqual({ kind: 'api-disabled', service: 'playdeveloperreporting.googleapis.com', serviceTitle: 'Google Play Developer Reporting API', projectId: EXAMPLE_CLOUD_PROJECT, activationUrl: URL }); }); test('the CLI line: one line with the URL, no JSON', () => { const e = storeReadError(msg, { app: 'com.example.demo' }); expect(e.error).toBe(`${LINE} Turn it on: ${URL}`); expect(e.error).not.toContain('{'); expect(e.detail).toBe(msg); expect(findingText(e.finding!)).toBe(LINE); }); test('the plain line parses back (cached snapshots and portfolio store errors carry only the line)', () => { expect(parseGoogleError(storeReadError(msg).error)).toEqual(parseGoogleError(msg)); }); test('a body cut at 800 characters (errorBody) still parses', () => { expect(parseGoogleError(msg.slice(0, 400))?.kind).toBe('api-disabled'); }); test('health: the table and findings say it in words', () => { const h = healthApiDisabled('com.example.demo'); expect(healthTable(h)).not.toContain('{"error"'); const f = healthFindings(h); expect(f[0]!.msg).toContain(`Reporting API is off in the service account's Google Cloud project ${EXAMPLE_CLOUD_PROJECT}`); expect(f[0]!.fix).toContain(URL); }); }); describe('other Google errors', () => { test('403 PERMISSION_DENIED without SERVICE_DISABLED: no access to that app, with the Users and permissions step', () => { const m = 'GET /androidpublisher/v3/applications/com.example.somegame/edits -> 403 {"error":{"code":403,"message":"The caller does not have permission","status":"PERMISSION_DENIED"}}'; const f = parseGoogleError(m); expect(f).toMatchObject({ kind: 'no-app-access', app: 'com.example.somegame' }); expect(findingText(f!)).toContain('no access to com.example.somegame in Play Console'); expect(findingText(f!)).toContain('Users and permissions'); }); test('invalid_grant and 401: the key is revoked or expired', () => { expect(parseGoogleError('Google token: invalid_grant: Invalid JWT Signature.')?.kind).toBe('auth-revoked'); expect(parseGoogleError('GET /v1beta1/apps:search -> 401 {"error":{"code":401,"status":"UNAUTHENTICATED"}}')?.kind).toBe('auth-revoked'); }); test('429 / RESOURCE_EXHAUSTED: quota', () => { const f = parseGoogleError('POST /v1beta1/apps/com.example.a/anrRateMetricSet:query -> 429 {"error":{"code":429,"status":"RESOURCE_EXHAUSTED","details":[{"metadata":{"service":"playdeveloperreporting.googleapis.com"}}]}}'); expect(f).toEqual({ kind: 'quota', serviceTitle: 'Google Play Developer Reporting API' }); }); test('an unknown error keeps its message, never the JSON', () => { const p = plainError('GET /x -> 500 {"error":{"code":500,"message":"Internal error encountered.","status":"INTERNAL"}}'); expect(p).toBe('GET /x -> 500 Internal error encountered.'); }); }); describe('desktop runner: exit codes', () => { test('scan / check / validate / health exit 1 with the result printed are findings; without it, failed', () => { for (const m of ['scan', 'check', 'validate', 'health']) expect(outcomeOf(m, 1, true)).toBe('findings'); expect(outcomeOf('scan', 1, false)).toBe('failed'); expect(outcomeOf('play_status', 1, true)).toBe('failed'); expect(outcomeOf('owner.apple.submit', 3, false)).toBe('declined'); expect(outcomeOf('scan', 0, true)).toBe('ok'); }); test('the error line is the command\'s own: "error: ..." of stderr, else the first message before the usage', () => { expect(SCAN_FAILED.error).toBe(SCAN_FAILED_LINE); expect(errorLine('unknown option for storebound scan: --x\n\nusage: storebound scan [--json]\n --json print JSON')).toBe('unknown option for storebound scan: --x'); expect(errorLine('', '12:00:00 [scan] reading\n12:00:01 [scan] the folder has no app')).toBe('the folder has no app'); expect(errorLine('', '')).toBe(''); }); });