storebound/tests/live-reads-2.test.ts

157 lines
12 KiB
TypeScript

// 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 <project> --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<string, unknown> = {}) => ({ 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();
});
});