Google API errors as typed findings, never raw JSON: cli/lib/google-errors.ts parses SERVICE_DISABLED (api-disabled: service, Cloud project, console page), PERMISSION_DENIED (no-app-access: which app, Users and permissions), 401 / invalid_grant (auth-revoked) and 429 (quota); health / analytics print one line with the URL, raw text in detail and the log; desktop Health tab: the line, 'Turn it on in Google Cloud' and 'Check again', raw text in a collapsed Details; Portfolio shows a Cloud project blocking several apps once. Findings exit codes are results: the sidecar reports outcome (ok / findings / declined / failed) and the command's own error line; scan / check / validate / health exit 1 with findings show and cache the findings; no bare 'Exit code N' anywhere. tests/google-errors.test.ts, boot check errors cases (api-disabled, scan-findings, scan-failed) (pitfall 57)
This commit is contained in:
parent
f69c10d3a0
commit
89256f1e80
28 changed files with 666 additions and 47 deletions
76
tests/google-errors.test.ts
Normal file
76
tests/google-errors.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// 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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -107,7 +107,9 @@ describe('Play vitals: errorIssues:search time zone', () => {
|
|||
reporting(() => new Response(UNSUPPORTED_TZ, { status: 400 }));
|
||||
const h = await collectPlayHealth(mkdtempSync(join(tmpdir(), 'sb-post-')), PKG, 7);
|
||||
expect(h.crash).toEqual([]);
|
||||
expect(h.issuesError).toStartWith(`GET /v1beta1/apps/${PKG}/errorIssues:search -> 400 {"error":{"code":400,"message":"Unsupported timezone`);
|
||||
// one plain line: the API's message without the JSON around it (pitfall 57)
|
||||
expect(h.issuesError).toStartWith(`GET /v1beta1/apps/${PKG}/errorIssues:search -> 400 Unsupported timezone`);
|
||||
expect(h.issuesError).not.toContain('{"error"');
|
||||
const f = healthFindings({ date: '2026-10-11', play: h });
|
||||
expect(f.map(x => x.code)).toEqual(['vitals', 'vitals-issues']);
|
||||
expect(f[1]).toMatchObject({ level: 'warn', msg: expect.stringContaining('Unsupported timezone') });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue