storebound/tests/helpers/store-mock.ts
CRT b63f2fdfbc Store operations: TestFlight wait / expire, IAP review submissions, listing pulls, release notes per language, offers, custom product pages, in-app events, reports, triage, keyword ranks, AdAttributionKit scan
- apple testflight wait --build N (progress, timeout, exit 0/1/7) and expire --keep N | --older-than D (dry run, asks)
- apple iap-submit: IAP / subscription / group versions into a review submission; first of a type only with the app
  version (--with-version), --no-submit keeps the draft; read-only MCP plan
- apple listing pull, play listing pull (diff, --write), catalog pull (config proposal, never rewritten)
- whats-new --languages / check / apply: one file per language, agent or hook translation, per-store limits
- apple offer-codes, win-back, promoted, preorder; Play pre-registration as a guide (no API)
- apple cpp and apple events: list, create, upload, submit (asks)
- reports: Apple sales / finance, Play earnings / sales into .storebound/reports with summary and CSV
- triage: TestFlight crash / feedback and Play error reports grouped by signature, device, OS
- aso rank: iTunes Search API ranks and competitors, rate limited
- ads scan: AdAttributionKit / SKAdNetwork Info.plist and source checks
- tests against recorded, sanitized answers; skills, README, CHANGELOG
2026-10-11 23:58:17 +08:00

107 lines
6.6 KiB
TypeScript

// Recorded-response harness for command tests: throwaway API keys, a fetch router that answers like App Store Connect,
// Google and the iTunes Search API did (sanitized: every id, name and email is a placeholder), a temp project with a
// config, and capture of stdout / stderr / process.exit. Nothing here reaches the network.
import { generateKeyPairSync } from 'node:crypto';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, isAbsolute, join } from 'node:path';
import { parseArgs } from '../../cli/lib/args';
import type { Loaded, ShipConfig } from '../../cli/lib/config';
import type { Ctx } from '../../cli/lib/ctx';
export type J = Record<string, unknown>;
export interface Call { method: string; url: string; path: string; body: unknown }
type Answer = Response | J | ((c: Call) => Response | J | Promise<Response | J>);
export interface Route { method?: string; match: RegExp; answer: Answer; times?: number }
export const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
export const notFound = () => json({ errors: [{ id: '00000000-0000-0000-0000-000000000000', status: '404', code: 'NOT_FOUND', title: 'The specified resource does not exist', detail: 'There is no resource' }] }, 404);
const saved = { ...process.env };
const realFetch = globalThis.fetch;
export function ascEnv(): void {
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
process.env.ASC_KEY_ID = 'ABCDE12345';
process.env.ASC_ISSUER_ID = '00000000-0000-0000-0000-000000000000';
process.env.ASC_P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
}
export function playEnv(): void {
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' });
}
/** Installs the router; unknown requests fail the test loudly (answer 599 with the path). Returns the call log. */
export function mockFetch(routes: Route[]): Call[] {
const calls: Call[] = [];
const left = routes.map(r => ({ ...r, left: r.times ?? Infinity }));
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input instanceof Request ? input.url : input);
const method = (init?.method ?? 'GET').toUpperCase();
const raw = init?.body;
let body: unknown = raw;
if (typeof raw === 'string') { try { body = JSON.parse(raw); } catch { body = raw; } }
const path = url.replace(/^https:\/\/[^/]+/, '');
const c: Call = { method, url, path, body };
if (url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'ya29.test', expires_in: 3600 });
calls.push(c);
const r = left.find(x => x.left > 0 && (x.method ?? 'GET') === method && x.match.test(decodeURIComponent(path)));
if (!r) return new Response(`not recorded: ${method} ${path}`, { status: 599 });
r.left--;
const a = typeof r.answer === 'function' ? await r.answer(c) : r.answer;
return a instanceof Response ? a : json(a);
}) as typeof fetch;
return calls;
}
export function restore(): void {
globalThis.fetch = realFetch;
for (const k of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'PLAY_SERVICE_ACCOUNT', 'ASC_VENDOR_NUMBER']) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; }
}
export const BASE: ShipConfig = {
app: { name: 'Example Notes', profile: 'app', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' },
languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'de', apple: 'de-DE', play: 'de-DE' }, { id: 'tr', apple: 'tr', play: 'tr-TR' }],
apple: { bundleId: 'com.example.notes', appId: '6400000101' },
play: { packageName: 'com.example.notes', reportsBucket: 'pubsite_prod_rev_01234567890123456789' },
};
/** A temp project folder with optional files; returns a Ctx factory for that config. */
export function project(config: ShipConfig = BASE, files: Record<string, string | Uint8Array> = {}): { root: string; ctx: (argv: string[], booleans?: string[]) => Ctx; loaded: Loaded } {
const root = mkdtempSync(join(tmpdir(), 'sb-ops-'));
for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); }
const loaded: Loaded = { config, root, path: join(root, 'storebound.config.ts'), resolve: (p: string) => (isAbsolute(p) ? p : join(root, p)) };
return { root, loaded, ctx: (argv, booleans = []) => ({ loaded, args: parseArgs(argv, booleans) }) };
}
/** Runs fn with console output captured and process.exit turned into a thrown { exit } (the code is returned). */
export async function capture(fn: () => Promise<void>): Promise<{ out: string; err: string; exit: number | null }> {
const out: string[] = [], err: string[] = [];
const log = console.log, warn = console.warn, error = console.error, exit = process.exit, write = process.stdout.write;
console.log = (...a: unknown[]) => { out.push(a.map(String).join(' ')); };
console.warn = (...a: unknown[]) => { err.push(a.map(String).join(' ')); };
console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ')); };
process.stdout.write = ((s: string) => { out.push(String(s)); return true; }) as typeof process.stdout.write;
let code: number | null = null;
process.exit = ((c?: number) => { code = c ?? 0; throw new ExitSignal(code); }) as typeof process.exit;
const before = process.exitCode;
process.exitCode = 0;
try { await fn(); } catch (e) { if (!(e instanceof ExitSignal)) { restoreIo(); throw e; } }
restoreIo();
if (code === null && process.exitCode) code = Number(process.exitCode);
process.exitCode = before ?? 0;
return { out: out.join('\n'), err: err.join('\n'), exit: code };
function restoreIo(): void { console.log = log; console.warn = warn; console.error = error; process.exit = exit; process.stdout.write = write; }
}
class ExitSignal extends Error { constructor(readonly code: number) { super(`exit ${code}`); } }
/** The --json object printed by a command (printJson pretty-prints one object; log lines may follow). */
export function jsonOut(out: string): J {
const i = out.indexOf('\n{\n') >= 0 ? out.indexOf('\n{\n') + 1 : out.indexOf('{\n');
if (i < 0) throw new Error(`no JSON in output:\n${out.slice(0, 500)}`);
// find the matching closing brace at column 0
const end = out.indexOf('\n}', i);
return JSON.parse(out.slice(i, end + 2)) as J;
}