storebound/tests/helpers/teaser-store-mock.ts
CRT f07f3acbd8 teaser v4: calm 60 s 16:9 cut, from an empty folder to ready on both stores
Nine scenes of 6-7 s, one slow camera move each, soft dissolves, one plain-English caption per scene at the top
(on screen at least 4 s): the stores' to-do list and one rejection mail, `init` in an empty folder, the agent prompt
and `release plan` (25 steps), store text and renders in 7 sizes x 4 languages, `play products --dry` and
`catalog diff`, the scan's declarations proposal with the ask-owner line, three findings with plain reasons turning
green, the desktop release timeline and Store readiness "No blockers", Submit and the end card.

Every CLI line is real output: tests/teaser.test.ts runs each command (store answers from
tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml, never the network) and checks scene lengths,
caption holds, the 55-65 s total and 16:9 only. The vertical output is gone; the web version targets 4.5 MB.
Calmer music (D major pads, soft pluck, bells on scene changes). desktop.ts adds the Store readiness frame.
2026-10-12 01:48:03 +08:00

90 lines
7.3 KiB
TypeScript

// Store double for the teaser's CLI scenes (tests/teaser.test.ts): loaded with `preload` from a bunfig.toml in a temp
// project, so every `bun cli/index.ts …` started there (and the children `release plan` starts) answers from here and
// never reaches the network. It answers in the shapes App Store Connect and Google Play use (sanitized: placeholder
// ids only) for a brand-new app record of the placeholder project:
// TEASER_STORE=new the app record exists, nothing else: no version, no build, no listings, no products
// TEASER_STORE=catalog the two products of the teaser project exist on both stores, priced in every region
// Regions: the App Store's 175 storefronts (ALPHA3 in cli/lib/money.ts); Google Play the same list without mainland
// China (Play is not offered there), 174 regions, the count Play answered in a recorded read-only run
// (tests/play-post-reads.test.ts). Pages under https://example.com/ (the template's privacy policy and
// delete-account URLs) answer 200; any other request answers 599 with its URL.
import { ALPHA3, toMoney } from '../../cli/lib/money';
import { CURRENCY } from '../../cli/lib/ppp';
const STATE = process.env.TEASER_STORE ?? 'new';
const APP = '6400000101';
const ASC = 'https://api.appstoreconnect.apple.com';
const json = (o: unknown, status = 200): Response => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
const list = (data: unknown[], included: unknown[] = []): Response => json({ data, included, links: {}, meta: { paging: { total: data.length, limit: 200 } } });
/** The teaser project's products (the config in tests/teaser.test.ts): id, Apple type, US price. */
const PRODUCTS = [
{ id: 'com.example.app.no_ads', type: 'NON_CONSUMABLE', usd: 2.99 },
{ id: 'com.example.app.gems_100', type: 'CONSUMABLE', usd: 0.99 },
];
const APPLE_REGIONS = Object.keys(ALPHA3);
const PLAY_REGIONS = APPLE_REGIONS.filter(r => r !== 'CN');
/** One price per product everywhere (the values are not shown; the same price on both stores, so no parity gaps). */
const currency = (r: string): string => CURRENCY[r] ?? 'USD';
function apple(m: string, p: string): Response {
if (/^\/v1\/apps\?filter\[bundleId\]=/.test(p)) return list([{ type: 'apps', id: APP, attributes: { name: 'Example App', bundleId: decodeURIComponent(p.split('=')[1]!), primaryLocale: 'en-US', sku: 'EXAMPLE' } }]);
if (p.startsWith(`/v1/apps/${APP}/appInfos`)) return list([{ type: 'appInfos', id: 'info-1', attributes: { appStoreState: 'PREPARE_FOR_SUBMISSION', state: 'PREPARE_FOR_SUBMISSION' } }]);
if (STATE === 'catalog') {
if (p.startsWith(`/v1/apps/${APP}/inAppPurchasesV2`)) return list(PRODUCTS.map((x, i) => ({ type: 'inAppPurchases', id: `iap-${i}`, attributes: { productId: x.id, name: x.id.split('.').pop(), inAppPurchaseType: x.type, state: 'READY_TO_SUBMIT' } })));
const loc = p.match(/^\/v2\/inAppPurchases\/iap-\d\/inAppPurchaseLocalizations/);
if (loc) return list(['en-US', 'tr'].map((locale, i) => ({ type: 'inAppPurchaseLocalizations', id: `loc-${i}`, attributes: { locale, name: 'Example', description: 'Example.' } })));
const sched = p.match(/^\/v2\/inAppPurchases\/(iap-\d)\/iapPriceSchedule/);
if (sched) return json({ data: { type: 'inAppPurchasePriceSchedules', id: sched[1] } });
const prices = p.match(/^\/v1\/inAppPurchasePriceSchedules\/iap-(\d)\/(manualPrices|automaticPrices)/);
if (prices) {
const x = PRODUCTS[Number(prices[1])]!;
// US is the base territory (manual); every other storefront is equalized by Apple (automatic)
const regions = prices[2] === 'manualPrices' ? ['US'] : APPLE_REGIONS.filter(r => r !== 'US');
const data = regions.map(r => ({ type: 'inAppPurchasePrices', id: `price-${r}`, attributes: { startDate: null, endDate: null, manual: r === 'US' },
relationships: { inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: `point-${r}` } }, territory: { data: { type: 'territories', id: ALPHA3[r] } } } }));
const included = regions.flatMap(r => [
{ type: 'inAppPurchasePricePoints', id: `point-${r}`, attributes: { customerPrice: String(x.usd), proceeds: String(x.usd * 0.85) } },
{ type: 'territories', id: ALPHA3[r], attributes: { currency: currency(r) } },
]);
return list(data, included);
}
if (/^\/v1\/inAppPurchasePriceSchedules\/iap-\d\/baseTerritory/.test(p)) return json({ data: { type: 'territories', id: 'USA', attributes: { currency: 'USD' } } });
}
if (m === 'GET') return list([]);
return json({ errors: [{ status: '599', detail: `teaser store double: no route for ${m} ${p}` }] }, 599);
}
function play(m: string, url: string): Response {
if (m === 'POST' && /\/edits$/.test(url)) return json({ id: 'edit-1', expiryTimeSeconds: '9999999999' });
if (m === 'DELETE') return new Response(null, { status: 204 });
if (/\/edits\/edit-1\/tracks$/.test(url)) return json({ kind: 'androidpublisher#tracksListResponse', tracks: [] });
if (/\/edits\/edit-1\/listings$/.test(url)) return json({ kind: 'androidpublisher#listingsListResponse' });
if (m === 'POST' && /\/pricing:convertRegionPrices$/.test(url)) return json({
convertedRegionPrices: Object.fromEntries(PLAY_REGIONS.map(r => [r, { regionCode: r, price: toMoney(currency(r), 1.99), taxAmount: toMoney(currency(r), 0) }])),
convertedOtherRegionsPrice: { usdPrice: toMoney('USD', 1.99), eurPrice: toMoney('EUR', 1.99) },
regionVersion: { version: '2022/02' },
});
const product = (x: (typeof PRODUCTS)[number]) => ({
packageName: 'com.example.app', productId: x.id,
listings: [{ languageCode: 'en-US', title: 'Example', description: 'Example.' }, { languageCode: 'tr-TR', title: 'Example', description: 'Example.' }],
purchaseOptions: [{ purchaseOptionId: 'buy', state: 'ACTIVE', buyOption: { legacyCompatible: true },
regionalPricingAndAvailabilityConfigs: PLAY_REGIONS.map(r => ({ regionCode: r, availability: 'AVAILABLE', price: toMoney(currency(r), x.usd) })) }],
});
if (/\/oneTimeProducts\?pageSize=/.test(url)) return json(STATE === 'catalog' ? { oneTimeProducts: PRODUCTS.map(product) } : {});
const one = url.match(/\/oneTimeProducts\/([^/?]+)$/);
if (one) { const x = PRODUCTS.find(p => p.id === decodeURIComponent(one[1]!)); return x && STATE === 'catalog' ? json(product(x)) : json({ error: { code: 404, message: 'not found', status: 'NOT_FOUND' } }, 404); }
if (/\/subscriptions\?pageSize=/.test(url)) return json({});
if (m === 'GET') return json({});
return json({ error: { code: 599, message: `teaser store double: no route for ${m} ${url}` } }, 599);
}
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input instanceof Request ? input.url : input);
const m = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
if (url.startsWith('https://oauth2.googleapis.com/token')) return json({ access_token: 'ya29.example', expires_in: 3600 });
if (url.startsWith(ASC)) return apple(m, url.slice(ASC.length));
if (url.startsWith('https://androidpublisher.googleapis.com/')) return play(m, url);
if (url.startsWith('https://example.com/')) return new Response('<!doctype html><title>Example</title><p>Example page.</p>', { status: 200, headers: { 'content-type': 'text/html' } });
return new Response(`teaser store double: no route for ${m} ${url}`, { status: 599 });
}) as typeof fetch;