storebound/tests/api.test.ts

353 lines
27 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { appleRows, generate, playRows, scanSource, templateRegex, wrappedOps } from '../tools/gen-api';
import { APPLE_OPS } from '../cli/api/apple.gen';
import { PLAY_APIS, PLAY_OPS } from '../cli/api/play.gen';
import { allOps, appleApi, backoffMs, buildRequest, getOp, mergePages, nextRequest, type Op, playApi, resolveOp, send, sendAll } from '../cli/api/client';
import { decide, gateOf, type GateInput, OWNER_NO_CONFIRM, OWNER_ONLY, ownerReason } from '../cli/api/gate';
import { coverage, describeRequest, parseParams, rows, search } from '../cli/api';
import { parseArgs } from '../cli/lib/args';
import { TOOLS, toArgv, validateInput } from '../cli/mcp-tools';
import { DESKTOP_TOOLS } from '../cli/serve';
const ROOT = join(import.meta.dir, '..');
const op = (store: 'apple' | 'play', id: string): Op => getOp(store, id)!;
describe('generator', () => {
test('deterministic and committed files are fresh', () => {
const a = generate(ROOT), b = generate(ROOT);
expect(a.map(g => g.text)).toEqual(b.map(g => g.text));
for (const g of a) expect({ path: g.path, fresh: readFileSync(join(ROOT, g.path), 'utf8') === g.text }).toEqual({ path: g.path, fresh: true });
});
test('every operation of the pinned specs is in the tables', () => {
const spec = JSON.parse(readFileSync(join(ROOT, 'specs/app-store-connect.oas.json'), 'utf8'));
let n = 0;
for (const item of Object.values<Record<string, unknown>>(spec.paths)) for (const m of ['get', 'post', 'put', 'patch', 'delete']) if (item[m]) n++;
expect(Object.keys(APPLE_OPS).length).toBe(n);
expect(n).toBeGreaterThan(1200);
const ap = playRows(JSON.parse(readFileSync(join(ROOT, 'specs/androidpublisher_v3.discovery.json'), 'utf8')));
const rep = playRows(JSON.parse(readFileSync(join(ROOT, 'specs/playdeveloperreporting_v1beta1.discovery.json'), 'utf8')));
expect(Object.keys(PLAY_OPS).length).toBe(Object.keys(ap).length + Object.keys(rep).length);
expect(Object.keys(PLAY_APIS).sort()).toEqual(['androidpublisher', 'playdeveloperreporting']);
expect(PLAY_APIS.androidpublisher.scope).toBe('https://www.googleapis.com/auth/androidpublisher');
expect(PLAY_APIS.playdeveloperreporting.scope).toBe('https://www.googleapis.com/auth/playdeveloperreporting');
});
test('rows are sorted and the spec order does not matter', () => {
const spec = { paths: { '/v1/b': { get: { operationId: 'b_get', responses: {} } }, '/v1/a': { get: { operationId: 'a_get', responses: {} } } } };
const rev = { paths: { '/v1/a': spec.paths['/v1/a'], '/v1/b': spec.paths['/v1/b'] } };
expect(JSON.stringify(appleRows(spec))).toBe(JSON.stringify(appleRows(rev)));
expect(Object.keys(appleRows(spec))).toEqual(['a_get', 'b_get']);
const ids = Object.keys(APPLE_OPS);
expect([...ids].sort()).toEqual(ids);
});
test('JSON:API shapes and vendor media types', () => {
expect(APPLE_OPS.apps_getCollection[4]).toBe('many:apps');
expect(APPLE_OPS.apps_getInstance[4]).toBe('one:apps');
expect(APPLE_OPS.appStoreVersions_createInstance[3]).toBe('appStoreVersions');
expect(APPLE_OPS.betaGroups_betaTesters_createToManyRelationship[3]).toBe('betaTesters[]');
expect(APPLE_OPS.appScreenshots_deleteInstance[4]).toBe('none');
expect(APPLE_OPS.apps_perfPowerMetrics_getToManyRelated[4]).toBe('raw:application/vnd.apple.xcode-metrics+json');
expect(APPLE_OPS.salesReports_getCollection[4]).toBe('raw:application/a-gzip');
});
});
describe('request building', () => {
test('Apple: path, comma-joined arrays, readable brackets', () => {
const r = buildRequest(op('apple', 'apps_getCollection'), { 'filter[bundleId]': 'com.example.app', 'fields[apps]': ['name', 'bundleId'], limit: 200 });
expect(r.method).toBe('GET');
expect(r.url).toBe('https://api.appstoreconnect.apple.com/v1/apps?filter[bundleId]=com.example.app&fields[apps]=name,bundleId&limit=200');
expect(r.body).toBeUndefined();
const v = buildRequest(op('apple', 'apps_appStoreVersions_getToManyRelated'), { id: 'a/b c', 'filter[platform]': 'IOS' });
expect(v.url).toBe('https://api.appstoreconnect.apple.com/v1/apps/a%2Fb%20c/appStoreVersions?filter[platform]=IOS');
});
test('Apple: body, vendor Accept, missing and unknown parameters', () => {
const body = { data: { type: 'appStoreVersions', id: '1', attributes: { versionString: '1.1' } } };
const p = buildRequest(op('apple', 'appStoreVersions_updateInstance'), { id: '1' }, body);
expect(p).toMatchObject({ method: 'PATCH', url: 'https://api.appstoreconnect.apple.com/v1/appStoreVersions/1', contentType: 'application/json' });
expect(JSON.parse(p.body as string)).toEqual(body);
expect(buildRequest(op('apple', 'apps_perfPowerMetrics_getToManyRelated'), { id: '9' }).accept).toBe('application/vnd.apple.xcode-metrics+json');
expect(() => buildRequest(op('apple', 'apps_getInstance'), {})).toThrow('path parameter "id" is required');
expect(() => buildRequest(op('apple', 'betaAppReviewDetails_getCollection'), {})).toThrow('query parameter "filter[app]" is required');
expect(() => buildRequest(op('apple', 'apps_getInstance'), { id: '1', 'filter[nope]': 'x' })).toThrow('unknown parameter "filter[nope]"');
expect(() => buildRequest(op('apple', 'apps_getCollection'), {}, { x: 1 })).toThrow('GET takes no body');
});
test('Play: API roots, uploads, downloads, {+name} shapes, global parameters', () => {
expect(buildRequest(op('play', 'androidpublisher.edits.tracks.update'), { packageName: 'com.example.app', editId: 'e1', track: 'beta' }, { track: 'beta' }).url)
.toBe('https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.example.app/edits/e1/tracks/beta');
const up = buildRequest(op('play', 'androidpublisher.edits.bundles.upload'), { packageName: 'com.example.app', editId: 'e1' }, undefined,
{ upload: { bytes: new Uint8Array([1, 2]), contentType: 'application/octet-stream' } });
expect(up.url).toBe('https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/com.example.app/edits/e1/bundles?uploadType=media');
expect(up.body).toEqual(new Uint8Array([1, 2]));
expect(() => buildRequest(op('play', 'androidpublisher.edits.tracks.list'), { packageName: 'p', editId: 'e' }, undefined, { upload: { bytes: new Uint8Array(), contentType: 'x' } })).toThrow('takes no file upload');
expect(buildRequest(op('play', 'androidpublisher.generatedapks.download'), { packageName: 'p', versionCode: 3, downloadId: 'd' }, undefined, { download: true }).url).toEndWith('?alt=media');
const q = buildRequest(op('play', 'playdeveloperreporting.vitals.crashrate.query'), { name: 'apps/com.example.app/crashRateMetricSet' }, { metrics: ['crashRate'] });
expect(q.url).toBe('https://playdeveloperreporting.googleapis.com/v1beta1/apps/com.example.app/crashRateMetricSet:query');
expect(() => buildRequest(op('play', 'playdeveloperreporting.vitals.crashrate.query'), { name: 'apps/com.example.app/anrRateMetricSet' })).toThrow('must look like apps/*/crashRateMetricSet');
expect(buildRequest(op('play', 'androidpublisher.reviews.list'), { packageName: 'p', fields: 'reviews(reviewId)', maxResults: 10 }).url)
.toBe('https://androidpublisher.googleapis.com/androidpublisher/v3/applications/p/reviews?fields=reviews(reviewId)&maxResults=10');
});
test('repeated Google parameters are sent once per value', () => {
const synthetic: Op = { ...op('play', 'androidpublisher.reviews.list'), query: [{ name: 'x', required: false, repeated: true }] };
expect(buildRequest(synthetic, { packageName: 'p', x: ['a', 'b'] }).url).toEndWith('/reviews?x=a&x=b');
});
test('METHOD path resolves to the operation and its parameters', () => {
const a = resolveOp('apple', 'GET', '/v1/apps/123/appStoreVersions?filter[platform]=IOS&limit=5');
expect(a.op.id).toBe('apps_appStoreVersions_getToManyRelated');
expect(a.params).toEqual({ id: '123', 'filter[platform]': 'IOS', limit: '5' });
expect(resolveOp('apple', 'get', 'https://api.appstoreconnect.apple.com/v1/apps/9').op.id).toBe('apps_getInstance');
const crash = resolveOp('play', 'POST', '/v1beta1/apps/com.example.app/crashRateMetricSet:query');
expect(crash.op.id).toBe('playdeveloperreporting.vitals.crashrate.query');
expect(crash.params).toEqual({ name: 'apps/com.example.app/crashRateMetricSet' });
expect(resolveOp('play', 'POST', '/v1beta1/apps/com.example.app/anrRateMetricSet:query').op.id).toBe('playdeveloperreporting.vitals.anrrate.query');
expect(resolveOp('play', 'POST', '/upload/androidpublisher/v3/applications/p/edits/e/bundles').op.id).toBe('androidpublisher.edits.bundles.upload');
expect(resolveOp('play', 'reviews.list').op.id).toBe('androidpublisher.reviews.list');
expect(() => resolveOp('apple', 'apps_nope')).toThrow('no operation');
expect(() => resolveOp('apple', 'GET', '/v9/nothing')).toThrow('no GET operation');
});
test('typed facades (checked by tsc)', () => {
const api = appleApi(() => 'Bearer t');
expect(api.build('apps_getInstance', { id: '1', 'fields[apps]': 'name' }).url).toContain('/v1/apps/1?fields[apps]=name');
// @ts-expect-error id is a required path parameter
expect(() => api.build('apps_getInstance', {})).toThrow();
// @ts-expect-error not a parameter of this operation
expect(() => api.build('apps_getInstance', { id: '1', nope: 'x' })).toThrow();
// @ts-expect-error filter[app] is required
expect(() => api.build('betaAppReviewDetails_getCollection', {})).toThrow();
api.build('appStoreVersions_updateInstance', { id: '1' }, { data: { type: 'appStoreVersions', id: '1', attributes: { copyright: 'x' } } });
// @ts-expect-error the body's resource type is appStoreVersions
api.build('appStoreVersions_updateInstance', { id: '1' }, { data: { type: 'apps', id: '1' } });
const play = playApi(() => 'Bearer t');
expect(play.build('androidpublisher.edits.tracks.list', { packageName: 'p', editId: 'e' }).method).toBe('GET');
// @ts-expect-error editId is required
expect(() => play.build('androidpublisher.edits.tracks.list', { packageName: 'p' })).toThrow();
});
});
describe('write gating', () => {
const base: GateInput = { kind: 'read', method: 'GET', dry: false, yes: false, tty: true, agent: false, getOnly: false };
test('gate kinds', () => {
expect(gateOf('apple', 'apps_getCollection', 'GET')).toBe('read');
expect(gateOf('apple', 'apps_updateInstance', 'PATCH')).toBe('write');
expect(gateOf('apple', 'reviewSubmissions_updateInstance', 'PATCH')).toBe('owner');
expect(gateOf('apple', 'reviewSubmissions_createInstance', 'POST')).toBe('owner');
expect(gateOf('apple', 'betaAppReviewSubmissions_createInstance', 'POST')).toBe('owner');
expect(gateOf('apple', 'appStoreVersionReleaseRequests_createInstance', 'POST')).toBe('owner');
expect(gateOf('apple', 'appStoreVersionPhasedReleases_createInstance', 'POST')).toBe('write');
expect(gateOf('apple', 'appStoreVersionPhasedReleases_updateInstance', 'PATCH')).toBe('owner');
expect(gateOf('apple', 'subscriptionPrices_createInstance', 'POST')).toBe('owner');
expect(gateOf('apple', 'customerReviewResponses_createInstance', 'POST')).toBe('owner');
expect(gateOf('apple', 'betaTesterInvitations_createInstance', 'POST')).toBe('owner');
expect(gateOf('apple', 'appStoreVersionExperimentsV2_updateInstance', 'PATCH')).toBe('owner');
expect(gateOf('apple', 'reviewSubmissionItems_createInstance', 'POST')).toBe('write');
expect(gateOf('play', 'androidpublisher.edits.insert', 'POST')).toBe('write');
expect(gateOf('play', 'androidpublisher.edits.commit', 'POST')).toBe('owner');
expect(gateOf('play', 'androidpublisher.reviews.reply', 'POST')).toBe('owner');
expect(gateOf('play', 'androidpublisher.purchases.subscriptionsv2.cancel', 'POST')).toBe('owner');
expect(gateOf('play', 'androidpublisher.orders.refund', 'POST')).toBe('owner');
expect(gateOf('play', 'androidpublisher.monetization.subscriptions.basePlans.migratePrices', 'POST')).toBe('owner');
expect(gateOf('play', 'androidpublisher.inappproducts.batchDelete', 'POST')).toBe('owner');
expect(gateOf('play', 'androidpublisher.edits.testers.update', 'PUT')).toBe('owner');
expect(gateOf('play', 'androidpublisher.appsigning.rotateAppSigningKey', 'POST')).toBe('owner');
expect(gateOf('play', 'playdeveloperreporting.vitals.crashrate.query', 'POST')).toBe('read');
expect(gateOf('play', 'androidpublisher.monetization.convertRegionPrices', 'POST')).toBe('read');
expect(ownerReason('apple', 'apps_getCollection', 'GET')).toBeNull();
});
test('every delete, submit, release and cancel operation is owner-only', () => {
for (const s of ['apple', 'play'] as const) for (const o of allOps(s)) {
const word = /delete|submi|release(?!s?_get)|cancel|refund|revoke|commit|reply|deploy/i.test(o.id.split('.').pop()!.replace(/_get\w+$/, ''));
if (o.method === 'DELETE' || (o.method !== 'GET' && word && !/PhasedReleases_createInstance|reviewSubmissionItems_|SubmissionItems|appClipDefaultExperiences_releaseWithAppStoreVersion/.test(o.id)))
expect({ id: o.id, gate: gateOf(s, o.id, o.method) }).toEqual({ id: o.id, gate: 'owner' });
}
expect(OWNER_ONLY.apple.length).toBeGreaterThan(5);
});
test('decisions: GET runs, writes are dry until --yes in a terminal, owner-only asks for the typed id', () => {
expect(decide(base)).toEqual({ action: 'send' });
expect(decide({ ...base, dry: true }).action).toBe('dry');
const w = { ...base, kind: 'write' as const, method: 'PATCH' };
expect(decide(w)).toMatchObject({ action: 'dry', why: expect.stringContaining('--yes') });
expect(decide({ ...w, yes: true, dry: true }).action).toBe('dry');
expect(decide({ ...w, yes: true, tty: false })).toMatchObject({ action: 'refuse', why: expect.stringContaining('need an interactive terminal') });
expect(decide({ ...w, yes: true })).toEqual({ action: 'send' });
const o = { ...w, kind: 'owner' as const };
expect(decide({ ...o, yes: true })).toEqual({ action: 'confirm', typed: true });
expect(decide({ ...o, yes: true, tty: false }).action).toBe('refuse');
// MCP / desktop: never a write, whatever the flags
expect(decide({ ...w, yes: true, agent: true }).action).toBe('refuse');
expect(decide({ ...o, yes: true, agent: true })).toMatchObject({ action: 'refuse', why: expect.stringContaining('never through MCP') });
expect(decide({ ...base, agent: true })).toEqual({ action: 'send' });
// api get: GET only, even the read-only POST queries
expect(decide({ ...base, getOnly: true, method: 'POST' }).action).toBe('refuse');
expect(decide({ ...base, getOnly: true })).toEqual({ action: 'send' });
});
test('--confirm <operationId>: an agent without a terminal sends a plain write, never an owner-only one', () => {
const w = { ...base, kind: 'write' as const, method: 'PATCH', yes: true, tty: false, opId: 'appStoreVersions_updateInstance' };
expect(decide({ ...w, confirm: 'appStoreVersions_updateInstance' })).toEqual({ action: 'send' });
expect(decide({ ...w, confirm: 'appInfos_updateInstance' })).toMatchObject({ action: 'refuse', why: expect.stringContaining('does not equal') });
expect(decide({ ...w, confirm: 'x', opId: undefined }).action).toBe('refuse');
// still dry without --yes, still nothing under MCP / desktop
expect(decide({ ...w, yes: false, confirm: w.opId }).action).toBe('dry');
expect(decide({ ...w, agent: true, confirm: w.opId }).action).toBe('refuse');
// owner-only: --confirm never replaces the owner's typed id, with or without a terminal
const o = { ...w, kind: 'owner' as const, method: 'DELETE', opId: 'appScreenshots_deleteInstance' };
expect(decide({ ...o, confirm: o.opId })).toMatchObject({ action: 'refuse', why: OWNER_NO_CONFIRM });
expect(decide({ ...o, tty: true, confirm: o.opId })).toMatchObject({ action: 'refuse', why: OWNER_NO_CONFIRM });
expect(decide({ ...o, tty: true })).toEqual({ action: 'confirm', typed: true });
});
test('the dry run shows the exact request without the credential', () => {
const r = buildRequest(op('apple', 'appStoreVersions_updateInstance'), { id: '1' }, { data: { type: 'appStoreVersions', id: '1' } });
const text = describeRequest(r);
expect(text).toStartWith('PATCH https://api.appstoreconnect.apple.com/v1/appStoreVersions/1\n');
expect(text).toContain('Bearer <App Store Connect API key JWT, not shown>');
expect(text).toContain('"type": "appStoreVersions"');
});
test('MCP exposes api_get only, read-only, with every parameter as one --param= token', () => {
const apiTools = TOOLS.filter(t => t.command[0] === 'api');
expect(apiTools.map(t => t.name)).toEqual(['api_get']);
const t = apiTools[0]!;
expect(t.write).toBe(false);
const argv = toArgv(t, { store: 'apple', operation: 'apps_getCollection', params: { 'filter[bundleId]': 'com.example.app', limit: 5 }, all_pages: true }).argv;
expect(argv).toEqual(['api', 'get', 'apple', 'apps_getCollection', '--param=filter[bundleId]=com.example.app', '--param=limit=5', '--all-pages', '--json']);
expect(argv).not.toContain('--yes');
expect(validateInput(t, { store: 'apple', operation: '--yes' })[0]).toContain('does not match');
expect(validateInput(t, { store: 'apple', operation: 'x', params: { a: ['b'] } })[0]).toContain('must be an object');
expect(validateInput(t, { store: 'apple', operation: 'x', params: { '--yes': 'x' } })[0]).toContain('must be an object');
expect(validateInput(t, { store: 'apple', operation: 'x', params: { a: 'b\nc' } })[0]).toContain('must be an object');
expect(validateInput(t, { store: 'apple', operation: 'x', params: { a: 'b', n: 1, f: true } })).toEqual([]);
expect(DESKTOP_TOOLS.some(d => d.command[0] === 'api')).toBe(false);
});
test('repeated --param values', () => {
const a = parseArgs(['apple', 'x', '--param', 'a=1', '--param=b=2', '--param', 'a=3', '--all-pages', 'y']);
expect(a.many('param')).toEqual(['a=1', 'b=2', 'a=3']);
expect(a.has('all-pages')).toBe(true);
expect(a._).toEqual(['apple', 'x', 'y']);
expect(parseParams(a.many('param'))).toEqual({ a: ['1', '3'], b: '2' });
expect(() => parseParams(['nope'])).toThrow('name=value');
});
});
describe('the CLI never sends a write it should not', () => {
const cli = (args: string[], env: Record<string, string> = {}) => {
const r = Bun.spawnSync(['bun', join(ROOT, 'cli/index.ts'), 'api', ...args], { cwd: ROOT, env: { ...process.env, ASC_KEY_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', ...env }, stdin: 'ignore' });
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
};
test('a write without --yes prints the request (no credentials needed, nothing sent)', () => {
const r = cli(['play', 'edits.tracks.update', '--param', 'packageName=com.example.app', '--param', 'editId=1', '--param', 'track=beta', '--json']);
expect(r.code).toBe(0);
expect(JSON.parse(r.out)).toMatchObject({ dryRun: true, gate: 'write', request: { method: 'PUT', url: 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.example.app/edits/1/tracks/beta' } });
});
test('--yes outside a terminal (piped / redirected) is refused loudly: exit 1, stderr, nothing sent', () => {
const r = cli(['apple', 'DELETE', '/v1/appScreenshots/1', '--yes']);
expect(r.code).not.toBe(0);
expect(r.err).toContain('refused: writes through `storebound api` need an interactive terminal');
expect(r.err).toContain('Nothing was sent');
expect(r.out).not.toContain('dry run');
});
test('without --yes, a piped write is still a plain dry run (exit 0)', () => {
const r = cli(['apple', 'DELETE', '/v1/appScreenshots/1']);
expect(r.code).toBe(0);
expect(r.out).toContain('owner only: this deletes');
});
test('under MCP and in api get, writes are refused', () => {
const m = cli(['play', 'edits.commit', '--param', 'packageName=p', '--param', 'editId=1', '--yes'], { STOREBOUND_MCP: '1' });
expect(m.code).toBe(1);
expect(m.err).toContain('never through MCP');
const g = cli(['get', 'apple', 'apps_updateInstance', '--param', 'id=1']);
expect(g.code).toBe(1);
expect(g.err).toContain('GET operations only');
});
test('list and coverage', () => {
const l = JSON.parse(cli(['list', '--store', 'play', '--search', 'tracks update', '--json']).out) as { id: string }[];
expect(l.map(x => x.id)).toEqual(['androidpublisher.edits.tracks.update']);
const c = JSON.parse(cli(['coverage', '--json']).out) as ReturnType<typeof coverage>;
expect(c.apis.map(a => a.api)).toEqual(['appstoreconnect', 'androidpublisher', 'playdeveloperreporting']);
expect(c.apis[0]!.operations).toBe(Object.keys(APPLE_OPS).length);
expect(c.resources.reduce((n, r) => n + r.operations, 0)).toBe(Object.keys(APPLE_OPS).length + Object.keys(PLAY_OPS).length);
});
});
describe('coverage scan', () => {
test('call sites map to operations, the most specific template wins', () => {
const src = [
"const v = await c.asc('PATCH', `/v1/appStoreVersions/${v.id}`, body);",
'const all = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);',
"await play.call('PUT', `${play.API}/edits/${id}/tracks/${track}`, t);",
"await g.json('POST', `${app}/crashRateMetricSet:query`, q); // androidpublisher",
"await play.call('POST', `${API}/edits/${e.id}:commit${o.commitQuery ?? ''}`);",
"const x = { method: 'POST', path: '/v1/betaTesterInvitations' };",
"await c.uploadAsset('appScreenshots', rel, f);",
"await c.asc('POST', `/v1/${type}`, body);",
].join('\n');
const sites = scanSource('demo', src);
const w = wrappedOps(sites, APPLE_OPS as never, PLAY_OPS as never);
expect(Object.keys(w.apple).sort()).toEqual(['appScreenshots_createInstance', 'appScreenshots_updateInstance', 'appStoreVersions_updateInstance',
'apps_appStoreVersions_getToManyRelated', 'betaTesterInvitations_createInstance']);
expect(Object.keys(w.play).sort()).toEqual(['androidpublisher.edits.commit', 'androidpublisher.edits.tracks.update', 'playdeveloperreporting.vitals.crashrate.query']);
expect(templateRegex('v1beta1/{+name}:query', 'name=apps/*/anrRateMetricSet').test('v1beta1/apps/X/crashRateMetricSet:query')).toBe(false);
});
test('another ${base} in a file that never talks to Google is not a Play call', () => {
expect(scanSource('shots/x', 'const u = `${base}/frames/${n}.png`;')).toEqual([]);
});
test('storebound commands already wrap a good share, reported per operation', () => {
const r = rows();
expect(r.filter(x => x.wrapped).length).toBeGreaterThan(100);
expect(r.find(x => x.id === 'apps_getCollection')!.wrapped).toContain('apple/bundle');
expect(search(r, 'crashrate query').map(x => x.id)).toEqual(['playdeveloperreporting.vitals.crashrate.query']);
});
});
describe('sending: retries and pagination', () => {
const json = (body: unknown, status = 200, headers: Record<string, string> = {}) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } });
test('429 with Retry-After and 5xx are retried with backoff, then the error has the API text', async () => {
const waits: number[] = [];
const answers = [json({}, 429, { 'retry-after': '3' }), json({}, 503), json({ data: [] }, 200, { 'x-rate-limit': 'user-hour-lim:3600;user-hour-rem:3599;' })];
const seen: Record<string, string>[] = [];
const r = await send(buildRequest(op('apple', 'apps_getCollection'), {}), () => 'Bearer t',
{ fetch: async (_u, i) => { seen.push(i.headers as Record<string, string>); return answers.shift()!; }, sleep: async ms => { waits.push(ms); } });
expect(waits).toEqual([3000, 2000]);
expect(r).toEqual({ status: 200, json: { data: [] }, rateLimit: 'user-hour-lim:3600;user-hour-rem:3599;' });
expect(seen[0]!.Authorization).toBe('Bearer t');
expect(backoffMs(1)).toBe(1000);
expect(backoffMs(10)).toBe(30_000);
expect(backoffMs(1, '120')).toBe(60_000);
const fail = send(buildRequest(op('apple', 'apps_getInstance'), { id: '1' }), () => 'Bearer t',
{ fetch: async () => json({ errors: [{ status: '404', detail: 'not\nfound' }] }, 404), sleep: async () => {} });
expect(fail).rejects.toThrow('GET /v1/apps/1 -> 404 {"errors":[{"status":"404","detail":"not\\nfound"}]}');
});
test('Apple pages follow links.next on the same host; included is de-duplicated', async () => {
const B = 'https://api.appstoreconnect.apple.com/v1/apps';
const pages: Record<string, unknown> = {
[B + '?limit=2']: { data: [{ type: 'apps', id: '1' }, { type: 'apps', id: '2' }], included: [{ type: 'builds', id: 'b' }], links: { self: 's', next: B + '?cursor=2' } },
[B + '?cursor=2']: { data: [{ type: 'apps', id: '3' }], included: [{ type: 'builds', id: 'b' }, { type: 'builds', id: 'c' }], links: { self: 's2' } },
};
const urls: string[] = [];
const r = await sendAll(buildRequest(op('apple', 'apps_getCollection'), { limit: 2 }), () => 'Bearer t', { fetch: async u => { urls.push(u); return json(pages[u]); } });
expect(urls).toEqual([B + '?limit=2', B + '?cursor=2']);
expect(r.pages).toBe(2);
const j = r.json as { data: { id: string }[]; included: { id: string }[] };
expect(j.data.map(d => d.id)).toEqual(['1', '2', '3']);
expect(j.included.map(d => d.id)).toEqual(['b', 'c']);
const req = buildRequest(op('apple', 'apps_getCollection'), {});
expect(() => nextRequest(req, { links: { next: 'https://evil.example.com/v1/apps?cursor=1' } })).toThrow('another host');
});
test('Google pageToken and token paging', async () => {
const urls: string[] = [];
const tracks = [{ a: [1], nextPageToken: 'n1' }, { a: [2], nextPageToken: 'n2' }, { a: [3] }];
const r = await sendAll(buildRequest(op('play', 'playdeveloperreporting.apps.search'), { pageSize: 1 }), () => 'Bearer t', { fetch: async u => { urls.push(u); return json(tracks.shift()); } });
expect(urls[1]).toBe('https://playdeveloperreporting.googleapis.com/v1beta1/apps:search?pageSize=1&pageToken=n1');
expect(r.json).toEqual({ a: [1, 2, 3] });
const reviews = [{ reviews: [{ reviewId: 'r1' }], tokenPagination: { nextPageToken: 't1' } }, { reviews: [{ reviewId: 'r2' }], tokenPagination: {} }];
const ru: string[] = [];
const rv = await sendAll(buildRequest(op('play', 'androidpublisher.reviews.list'), { packageName: 'p' }), () => 'Bearer t', { fetch: async u => { ru.push(u); return json(reviews.shift()); } });
expect(ru[1]).toEndWith('/reviews?token=t1');
expect(rv.json).toEqual({ reviews: [{ reviewId: 'r1' }, { reviewId: 'r2' }] });
expect(mergePages({ x: [1], k: 'a' }, { x: [2], k: 'b', nextPageToken: 'z' })).toEqual({ x: [1, 2], k: 'a' });
});
test('a server that never stops paging is cut off', async () => {
const r = sendAll(buildRequest(op('play', 'playdeveloperreporting.apps.search'), {}), () => 'Bearer t',
{ maxPages: 3, fetch: async () => json({ apps: [1], nextPageToken: 'again' }) });
expect(r).rejects.toThrow('more than 3 pages');
});
});