// Read paths against what the stores really answered in a read-only run on live accounts (2026-10, GET only plus the // Play edit insert / delete, the Reporting :query and convertRegionPrices). Recorded responses are sanitized: ids, // team ids, vendor numbers and app names are placeholders; only the shapes and the store's words are kept. // - apple signing status: the bundle id's only App Store profile was Xcode-managed ("iOS Team Store Provisioning // Profile: ..."); /v1/bundleIds/{id}/profiles lists it, /v1/profiles/{id}/relationships/certificates answers 404, // and the whole status failed on that 404. // - scan --dir --config /abs/path: the absolute path was joined onto the project folder (ENOENT). // - scan read .claude/worktrees/* (agent worktrees: whole copies of the repo), so evidence pointed into them, and // "(Poki rate-limits), PokiSDK.rewardedBreak()" was reported as a reward for rating (3.2.2(x)). // - api --all-pages: the merged result still carried meta.paging.nextCursor / limit of the first page. // - apple iap-submit --dry --json with every product already WAITING_FOR_REVIEW listed the submit PATCH anyway. // - release status / plan: builds of 0.6.0 were VALID in TestFlight without an App Store version; the train was // missing and the plan said "upload a build". Also the version's attached build was older than the five newest, // so the App Store cell had no build number. // - reports apple-sales / apple-finance with a wrong vendor number: a bare 500 / 400 without a hint. import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { mergePages } from '../cli/api/client'; import { planRequests } from '../cli/apple/iap-submit'; import { appleReleases, releaseTrains, testflightReleases } from '../cli/lib/portfolio'; import { runScan } from '../cli/lib/scan'; import { listFiles } from '../cli/lib/scan/project'; import { SIGNALS } from '../cli/lib/scan/rules'; import { planProfile, readProfiles } from '../cli/lib/signing'; import { laneText, releasePlan, trainLanes } from '../cli/lib/train'; import { appleReportHint } from '../cli/reports'; describe('apple signing: Xcode-managed profiles', () => { // GET /v1/bundleIds/{id}/profiles (sanitized) const bundleProfiles = [{ type: 'profiles', id: 'PROFILE001', attributes: { name: 'iOS Team Store Provisioning Profile: com.example.game', platform: 'IOS', profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', createdDate: '2026-09-15T01:29:54.000+00:00', expirationDate: '2027-09-15T01:29:54.000+00:00', uuid: '00000000-0000-0000-0000-000000000000' }, }, { type: 'profiles', id: 'PROFILE002', attributes: { name: 'old dev', profileType: 'IOS_APP_DEVELOPMENT', profileState: 'ACTIVE' } }]; // GET /v1/profiles/PROFILE001/relationships/certificates (sanitized) const notFound = 'GET /v1/profiles/PROFILE001/relationships/certificates?limit=200 -> 404 {"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 \'profiles\' with id \'PROFILE001\'","source":{"pointer":"data/id"}}]}'; test('a 404 on the certificates of a listed profile marks it managed instead of failing', async () => { const asked: string[] = []; const list = await readProfiles(async p => { asked.push(p); if (p.startsWith('/v1/bundleIds/')) return bundleProfiles; throw new Error(notFound); }, 'BUNDLE01', 'IOS_APP_STORE'); expect(list).toEqual([{ id: 'PROFILE001', attributes: bundleProfiles[0]!.attributes, managed: true }]); expect(asked).toEqual(['/v1/bundleIds/BUNDLE01/profiles?limit=200', '/v1/profiles/PROFILE001/relationships/certificates?limit=200']); const plan = planProfile(list, { bundleId: 'com.example.game', bundleIdId: 'BUNDLE01', certId: 'CERT01', type: 'IOS_APP_STORE', now: Date.parse('2026-10-11') }); expect(plan.reuse).toBeNull(); expect(plan.why).toContain('1 Xcode-managed'); expect(plan.why).not.toContain('none active'); }); test('other errors still fail the read', async () => { expect(readProfiles(async p => { if (p.startsWith('/v1/bundleIds/')) return bundleProfiles; throw new Error('GET /v1/profiles/x -> 401 {}'); }, 'B', 'IOS_APP_STORE')).rejects.toThrow('-> 401'); }); test('a readable profile keeps its certificates', async () => { const list = await readProfiles(async p => (p.startsWith('/v1/bundleIds/') ? bundleProfiles : [{ type: 'certificates', id: 'CERT01' }]), 'B', 'IOS_APP_STORE'); expect(list[0]?.certIds).toEqual(['CERT01']); expect(list[0]?.managed).toBeUndefined(); }); }); describe('scan on a real project folder', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-live2-')); const put = (rel: string, text: string) => { mkdirSync(join(dir, rel, '..'), { recursive: true }); writeFileSync(join(dir, rel), text); }; put('package.json', '{"name":"game","dependencies":{"@capacitor/core":"7.0.0"}}'); put('src/main.ts', 'export const x = 1;\n'); put('.claude/worktrees/agent-1/src/main.ts', 'copy of the repo\n'); put('wt/.git', 'gitdir: /repo/.git/worktrees/wt\n'); put('wt/src/main.ts', 'another checkout\n'); put('sub/.git', 'gitdir: ../.git/modules/sub\n'); put('sub/lib.ts', 'a submodule is part of the app\n'); const cfgDir = mkdtempSync(join(tmpdir(), 'sb-live2-cfg-')); writeFileSync(join(cfgDir, 'storebound.config.json'), JSON.stringify({ app: { name: 'Example', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'hi@example.com' }, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], apple: { bundleId: 'com.example.game' }, play: { packageName: 'com.example.game' } })); test('agent folders and linked worktrees are not the app; submodules are', () => { const files = listFiles(dir); expect(files).toContain('src/main.ts'); expect(files).toContain('sub/lib.ts'); expect(files.some(f => f.startsWith('.claude/') || f.startsWith('wt/'))).toBe(false); }); test('--config with an absolute path outside the project', async () => { const r = await runScan(dir, { config: join(cfgDir, 'storebound.config.json') }); expect(r.configPath).toBe(join(cfgDir, 'storebound.config.json')); expect(r.report.config).toBe(join(cfgDir, 'storebound.config.json')); }); test('a rate limit next to a rewarded ad is not a reward for rating', () => { const re = SIGNALS.incentiveRating; expect(re.test(`notes: 'PokiSDK.commercialBreak() at every "next" transition (Poki rate-limits), PokiSDK.rewardedBreak(), '`)).toBe(false); expect(re.test('frame rate limit reward')).toBe(false); for (const t of ['Rate us 5 stars and get 100 coins', 'review the app to earn a reward', 'Get 50 gems for rating us', 'rate the game for a bonus']) expect(re.test(t)).toBe(true); }); test('cleanup', () => { rmSync(dir, { recursive: true, force: true }); rmSync(cfgDir, { recursive: true, force: true }); }); }); describe('api --all-pages', () => { // GET /v1/territories, two pages (trimmed) const p1 = { data: [{ type: 'territories', id: 'AFG', attributes: { currency: 'USD' } }], links: { self: 'https://api.appstoreconnect.apple.com/v1/territories', next: 'https://api.appstoreconnect.apple.com/v1/territories?cursor=Mg' }, meta: { paging: { total: 175, nextCursor: 'Mg', limit: 50 } } }; const p2 = { data: [{ type: 'territories', id: 'ZWE', attributes: { currency: 'USD' } }], links: { self: 'https://api.appstoreconnect.apple.com/v1/territories?cursor=Mg' }, meta: { paging: { total: 175, limit: 50 } } }; test('the merged result keeps the total and drops the cursor of the first page', () => { const m = mergePages(p1, p2) as { data: { id: string }[]; meta: unknown; links: { next?: string } }; expect(m.data.map(d => d.id)).toEqual(['AFG', 'ZWE']); expect(m.meta).toEqual({ paging: { total: 175 } }); expect(m.links.next).toBeUndefined(); }); }); describe('apple iap-submit with nothing left to submit', () => { test('no requests when every product is already in review', () => { const submit = { method: 'PATCH', path: '/v1/reviewSubmissions/${submission}' }; expect(planRequests([], { items: [], submit })).toEqual([]); expect(planRequests([{ method: 'POST', path: '/v1/reviewSubmissions' }], { items: [{}], submit })).toHaveLength(2); }); }); describe('release trains with TestFlight-only versions', () => { // `apple status --json` (sanitized): 0.5.0 waits for review with build 3 (older than the five newest builds), // builds 7-11 of 0.6.0 are VALID in TestFlight, no App Store version 0.6.0 yet. const b = (id: string, version: string, extra: Record = {}) => ({ type: 'builds', id, attributes: { version, processingState: 'VALID', expired: false, uploadedDate: '2026-10-10T12:00:00-07:00', ...extra } }); const status = { versions: [{ type: 'appStoreVersions', id: 'V050', attributes: { platform: 'IOS', versionString: '0.5.0', appVersionState: 'WAITING_FOR_REVIEW', appStoreState: 'WAITING_FOR_REVIEW', createdDate: '2026-10-09T01:00:00-07:00' }, relationships: { build: { data: { type: 'builds', id: 'B3' } } } }], builds: [b('B11', '11'), b('B10', '10'), b('B9', '9'), b('B8', '8', { expired: true }), b('B7', '7', { processingState: 'PROCESSING' })], attachedBuilds: [b('B3', '3')], buildVersions: { B11: '0.6.0', B10: '0.6.0', B9: '0.6.0', B8: '0.6.0', B7: '0.6.0' }, iaps: [], subs: [], phased: {}, }; test('a version with processed builds and no App Store version is at "testing", with its newest build', () => { const ios = appleReleases(status); expect(ios).toContainEqual(expect.objectContaining({ version: '0.5.0', build: '3', phase: 'review' })); expect(ios).toContainEqual({ store: 'ios', version: '0.6.0', build: '11', state: 'TESTFLIGHT', phase: 'testing', fraction: null, track: 'TestFlight' }); expect(testflightReleases(status.builds, status.buildVersions, new Set(['0.6.0']))).toEqual([]); expect(testflightReleases([b('X', '4', { processingState: 'INVALID' })], { X: '0.7.0' }, new Set())).toEqual([]); }); test('the next train and the plan say what is really left', () => { const ios = appleReleases(status); const t = releaseTrains(ios, [{ store: 'android', version: '0.5.0', build: '2', state: 'completed', phase: 'live', fraction: null, track: 'production' }]); expect(t.current?.version).toBe('0.5.0'); expect(t.next?.version).toBe('0.6.0'); expect(laneText(trainLanes(t.next!)[0]!)).toBe('testing'); const plan = releasePlan({ version: '0.6.0', platforms: { ios: true, android: true }, check: { problems: [], warnings: [] }, validate: [], releases: ios, storeErrors: {} }); expect(plan.ios?.steps[0]).toEqual({ kind: 'store', text: '0.6.0 is in TestFlight (build 11) but has no App Store version yet: create it with that build and its texts', fix: 'storebound apple listing --version 0.6.0 --build 11 --dry' }); expect(plan.android?.steps[0]?.text).toContain('not on Google Play yet'); }); test('older status output (no buildVersions) gives the same rows as before', () => { const { buildVersions: _b, attachedBuilds: _a, ...old } = status; expect(appleReleases(old).map(r => [r.version, r.build, r.phase])).toEqual([['0.5.0', null, 'review']]); }); }); describe('reports: wrong vendor number', () => { test('the store errors get a hint', () => { expect(appleReportHint('GET /v1/salesReports -> 500 {"errors":[{"status":"500","code":"UNEXPECTED_ERROR"}]}')).toContain('vendor number'); expect(appleReportHint('GET /v1/financeReports -> 400 {"errors":[{"status":"400","code":"PARAMETER_ERROR.INVALID","detail":"Invalid vendor number specified"}]}')).toContain('vendor number'); expect(appleReportHint('GET /v1/salesReports -> 403 {}')).toContain('role'); expect(appleReportHint('GET /v1/salesReports -> 404 {}')).toBeNull(); }); });