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_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>(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('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 '); 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 = {}) => { 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; 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 = {}) => 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[] = []; const r = await send(buildRequest(op('apple', 'apps_getCollection'), {}), () => 'Bearer t', { fetch: async (_u, i) => { seen.push(i.headers as Record); 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 = { [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'); }); });