170 lines
12 KiB
TypeScript
170 lines
12 KiB
TypeScript
// Guardrails for lessons from real use (docs/pitfalls.md, skills/store-pitfalls): the store's product list lagging
|
|
// right after a create, and dry runs that print a count instead of a plan. Recorded App Store Connect / Google Play
|
|
// answers; every id is a placeholder.
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { Glob } from 'bun';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { countOnlyDryExits, dryPlan, isPlanLine } from '../cli/lib/dry';
|
|
import { LAG_HINT, missingFromStore } from '../cli/lib/store-lag';
|
|
import { run as iapReview } from '../cli/apple/iap-review';
|
|
import { run as iap } from '../cli/apple/iap';
|
|
import { run as playProducts } from '../cli/play/products';
|
|
import type { Product } from '../cli/lib/config';
|
|
import { ascEnv, BASE, capture, mockFetch, notFound, playEnv, project, restore } from './helpers/store-mock';
|
|
|
|
const env = { ...process.env };
|
|
afterEach(() => { restore(); if (env.STOREBOUND_LIST_SETTLE_MS === undefined) delete process.env.STOREBOUND_LIST_SETTLE_MS; });
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const page = (data: unknown[]): any => ({ data, links: { self: 'https://api.appstoreconnect.apple.com/v1/x' }, meta: { paging: { total: data.length, limit: 200 } } });
|
|
const product = (id: string): Product => ({ id, type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No Ads', description: 'Removes ads' } } });
|
|
// a PNG header of an App Store screenshot size (the size check reads IHDR only)
|
|
const png = (w = 1290, h = 2796) => { const b = Buffer.alloc(33); b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); b.writeUInt32BE(13, 8); b.write('IHDR', 12); b.writeUInt32BE(w, 16); b.writeUInt32BE(h, 20); b[24] = 8; b[25] = 2; return b; };
|
|
const PRO = 'com.example.notes.pro', GEMS = 'com.example.notes.gems';
|
|
const CONFIG = { ...BASE, products: [product(PRO), { ...product(GEMS), type: 'consumable' as const }] };
|
|
|
|
describe('store list lag after create', () => {
|
|
test('missingFromStore keeps config order and ignores duplicates', () => {
|
|
expect(missingFromStore(['a', 'b', 'c', 'b'], ['c'])).toEqual(['a', 'b']);
|
|
expect(missingFromStore(['a'], new Set(['a']))).toEqual([]);
|
|
});
|
|
|
|
test('apple iap-review --dry: a product missing from /inAppPurchasesV2 is named with the lag hint and exits 1', async () => {
|
|
ascEnv();
|
|
const p = project(CONFIG, { [`store/iap-review/${PRO}.png`]: png(), [`store/iap-review/${GEMS}.png`]: png() });
|
|
// as answered a few seconds after `apple iap` created both: only the older product is listed
|
|
const calls = mockFetch([
|
|
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?limit=200/, answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, state: 'READY_TO_SUBMIT' } }]) },
|
|
{ match: /^\/v2\/inAppPurchases\/i-pro\/appStoreReviewScreenshot/, answer: notFound() },
|
|
]);
|
|
const r = await capture(() => iapReview(p.ctx(['--dry'])));
|
|
expect(r.err).toContain(`1 of 2 products not in the store's list: ${GEMS} (${LAG_HINT})`);
|
|
expect(r.out).toMatch(new RegExp(`${GEMS.replace(/\./g, '\\.')}\\s+skip\\s+not in the store's list yet`));
|
|
expect(r.out).toMatch(new RegExp(`${PRO.replace(/\./g, '\\.')}\\s+upload\\s+store/iap-review/${PRO.replace(/\./g, '\\.')}\\.png \\(none in App Store Connect\\)`));
|
|
expect(r.exit).toBe(1);
|
|
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
|
|
});
|
|
|
|
test('apple iap-review --dry: every product listed exits 0', async () => {
|
|
ascEnv();
|
|
const p = project({ ...CONFIG, products: [product(PRO)] }, { [`store/iap-review/${PRO}.png`]: png() });
|
|
mockFetch([
|
|
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?limit=200/, answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, state: 'READY_TO_SUBMIT' } }]) },
|
|
{ match: /^\/v2\/inAppPurchases\/i-pro\/appStoreReviewScreenshot/, answer: notFound() },
|
|
]);
|
|
const r = await capture(() => iapReview(p.ctx(['--dry'])));
|
|
expect(r.err).not.toContain(LAG_HINT);
|
|
expect(r.exit).toBeNull();
|
|
});
|
|
|
|
test('apple iap --dry prints a plan row per product (create with type, locales and prices; update with what is missing)', async () => {
|
|
ascEnv();
|
|
const p = project({ ...CONFIG, products: [product(PRO), { ...product(GEMS), type: 'consumable', overrides: [{ region: 'TR', currency: 'TRY', price: 49.99 }] }] });
|
|
const calls = mockFetch([
|
|
{ match: /^\/v1\/territories/, answer: page([{ type: 'territories', id: 'USA' }, { type: 'territories', id: 'TUR' }]) },
|
|
{ match: new RegExp(`^/v1/apps/6400000101/inAppPurchasesV2\\?filter\\[productId\\]=${PRO}`), answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, state: 'MISSING_METADATA' } }]) },
|
|
{ match: new RegExp(`^/v1/apps/6400000101/inAppPurchasesV2\\?filter\\[productId\\]=${GEMS}`), answer: page([]) },
|
|
{ match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseLocalizations/, answer: page([]) },
|
|
{ match: /^\/v2\/inAppPurchases\/i-pro\/iapPriceSchedule/, answer: notFound() },
|
|
{ match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseAvailability/, answer: { data: { type: 'inAppPurchaseAvailabilities', id: 'av', attributes: { availableInNewTerritories: true } } } },
|
|
{ match: /^\/v1\/inAppPurchaseAvailabilities\/av\/availableTerritories/, answer: page([{ type: 'territories', id: 'USA' }, { type: 'territories', id: 'TUR' }]) },
|
|
// the product as the store has it: the same reference name as the config, so only localizations and prices change
|
|
{ match: /^\/v2\/inAppPurchases\/i-pro(\?|$)/, answer: { data: { type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, name: 'No Ads', familySharable: false, state: 'MISSING_METADATA' } } } },
|
|
]);
|
|
const r = await capture(() => iap(p.ctx(['--dry'])));
|
|
expect(r.out).toContain('dry run: apple iap: 2 change(s); nothing written');
|
|
expect(r.out).toMatch(/com\.example\.notes\.pro\s+update\s+localizations en-US; prices USA 2\.99 \(state MISSING_METADATA\)/);
|
|
expect(r.out).toMatch(/com\.example\.notes\.gems\s+create\s+CONSUMABLE; localizations en-US; prices USA 2\.99, TUR 49\.99; 2 territories/);
|
|
expect(r.out).toContain(`not on App Store Connect: ${GEMS}`);
|
|
expect(calls.filter(c => c.method !== 'GET')).toHaveLength(0);
|
|
});
|
|
|
|
test('apple iap: a product created this run but missing from the list read after it is named', async () => {
|
|
ascEnv();
|
|
process.env.STOREBOUND_LIST_SETTLE_MS = '0';
|
|
const p = project({ ...CONFIG, products: [product(GEMS)] });
|
|
const calls = mockFetch([
|
|
{ match: /^\/v1\/territories/, answer: page([{ type: 'territories', id: 'USA' }]) },
|
|
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?filter/, answer: page([]) },
|
|
{ method: 'POST', match: /^\/v2\/inAppPurchases$/, answer: { data: { type: 'inAppPurchases', id: 'i-gems', attributes: { productId: GEMS } } } },
|
|
{ match: /^\/v2\/inAppPurchases\/i-gems\/inAppPurchaseLocalizations/, answer: page([]) },
|
|
{ method: 'POST', match: /^\/v1\/inAppPurchaseLocalizations$/, answer: { data: { type: 'inAppPurchaseLocalizations', id: 'l1' } } },
|
|
{ match: /^\/v2\/inAppPurchases\/i-gems\/iapPriceSchedule/, answer: notFound() },
|
|
{ match: /^\/v2\/inAppPurchases\/i-gems\/pricePoints\?filter\[territory\]=USA/, answer: page([{ type: 'inAppPurchasePricePoints', id: 'pp-usa-299', attributes: { customerPrice: '2.99' } }]) },
|
|
{ method: 'POST', match: /^\/v1\/inAppPurchasePriceSchedules$/, answer: { data: { type: 'inAppPurchasePriceSchedules', id: 'ps' } } },
|
|
{ match: /^\/v2\/inAppPurchases\/i-gems\/inAppPurchaseAvailability/, answer: notFound() },
|
|
{ method: 'POST', match: /^\/v1\/inAppPurchaseAvailabilities$/, answer: { data: { type: 'inAppPurchaseAvailabilities', id: 'av' } } },
|
|
// the full list read right after the create does not have it yet
|
|
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?limit=200/, answer: page([]) },
|
|
]);
|
|
const r = await capture(() => iap(p.ctx([])));
|
|
expect(calls.filter(c => c.method === 'POST').map(c => c.path)).toEqual(['/v2/inAppPurchases', '/v1/inAppPurchaseLocalizations', '/v1/inAppPurchasePriceSchedules', '/v1/inAppPurchaseAvailabilities']);
|
|
expect(r.err).toContain(`1 of 1 new products not in the store's list: ${GEMS} (${LAG_HINT})`);
|
|
expect(r.out).toContain('wait a minute before apple iap-review');
|
|
expect(r.exit).toBeNull();
|
|
});
|
|
|
|
test('play products: a product created this run but missing from the list read after it is named; --dry shows the plan table', async () => {
|
|
playEnv();
|
|
const p = project({ ...CONFIG, products: [product(GEMS)] });
|
|
const API = '/androidpublisher/v3/applications/com.example.notes';
|
|
const conv = { convertedRegionPrices: { US: { regionCode: 'US', price: { currencyCode: 'USD', units: '2', nanos: 990000000 } } },
|
|
convertedOtherRegionsPrice: { usdPrice: { currencyCode: 'USD', units: '2', nanos: 990000000 }, eurPrice: { currencyCode: 'EUR', units: '2', nanos: 790000000 } }, regionVersion: { version: '2026/01' } };
|
|
const routes = () => mockFetch([
|
|
{ match: new RegExp(`^${API}/oneTimeProducts\\?`), answer: { oneTimeProducts: [] } },
|
|
{ method: 'POST', match: new RegExp(`^${API}/pricing:convertRegionPrices$`), answer: conv },
|
|
{ method: 'PATCH', match: new RegExp(`^${API}/onetimeproducts/`), answer: {} },
|
|
{ method: 'POST', match: /purchaseOptions:batchUpdateStates$/, answer: {} },
|
|
]);
|
|
routes();
|
|
let r = await capture(() => playProducts(p.ctx(['--dry'])));
|
|
expect(r.out).toMatch(/dry run: play products: 1 change\(s\); nothing written\n.*\n.*\ncom\.example\.notes\.gems\s+create\s+1 regions, 1 listings, USD 2\.99/);
|
|
expect(r.out).toContain(`not on Google Play: ${GEMS}`);
|
|
const calls = routes();
|
|
r = await capture(() => playProducts(p.ctx([])));
|
|
expect(calls.filter(c => c.method === 'PATCH')).toHaveLength(1);
|
|
expect(r.err).toContain(`1 of 1 new products not in the store's list: ${GEMS} (${LAG_HINT})`);
|
|
});
|
|
});
|
|
|
|
describe('dry runs show a plan, not a count', () => {
|
|
test('dryPlan: title with counts, kept rows left out of the table', () => {
|
|
expect(dryPlan('apple iap', [{ item: 'a', action: 'keep' }])).toBe('dry run: apple iap: nothing to change, 1 unchanged; nothing written');
|
|
expect(dryPlan('play products', [{ item: 'com.example.a', action: 'create', detail: '174 regions' }, { item: 'b', action: 'keep' }]).split('\n')).toEqual([
|
|
'dry run: play products: 1 change(s), 1 unchanged; nothing written',
|
|
'item action detail',
|
|
'------------- ------ -----------',
|
|
'com.example.a create 174 regions',
|
|
]);
|
|
});
|
|
|
|
test('the scanner flags a --dry exit after a count-only line and accepts a plan', () => {
|
|
const countOnly = ['export async function run(ctx: Ctx): Promise<void> {', ' const listing = read();',
|
|
" log('apple', `listing: ${Object.keys(listing).length} locales (${Object.keys(listing).join(', ')})`);", " if (a.has('dry')) return;", ' await write();', '}'].join('\n');
|
|
expect(countOnlyDryExits(countOnly)).toEqual([4]);
|
|
const counted = ['export async function run(): Promise<void> {', " if (dry) { done(TAG, `dry run: ${reqs.length} change(s)`); return; }", '}'].join('\n');
|
|
expect(countOnlyDryExits(counted)).toEqual([2]);
|
|
const planned = ['export async function run(): Promise<void> {', " console.log(dryPlan('apple listing', rows));", " if (dry) return;", '}'].join('\n');
|
|
expect(countOnlyDryExits(planned)).toEqual([]);
|
|
expect(isPlanLine(" log('apple', `${tag}: would upload ${file}`);")).toBe(true);
|
|
expect(isPlanLine(" log(TAG, 'would POST /v1/analyticsReportRequests {accessType=ONGOING} (dry)');")).toBe(true);
|
|
expect(isPlanLine(" done(TAG, 'dry run: nothing changed');")).toBe(false);
|
|
});
|
|
|
|
// TODO: remove each entry once its branch lands (the test fails on a stale entry).
|
|
const ALLOW: Record<string, string> = {
|
|
// the dry branch returns the .gitignore line it would add (the plan itself); the callers print it
|
|
'cli/lib/git-ignore.ts': 'returns the planned .gitignore line',
|
|
};
|
|
test('no write command in cli/** leaves its --dry branch after printing only a count', () => {
|
|
const root = join(import.meta.dir, '..');
|
|
const flagged: string[] = [];
|
|
for (const f of [...new Glob('cli/**/*.ts').scanSync(root)].sort()) {
|
|
const lines = countOnlyDryExits(readFileSync(join(root, f), 'utf8'));
|
|
if (lines.length) flagged.push(`${f}:${lines.join(',')}`);
|
|
}
|
|
const files = flagged.map(x => x.split(':')[0]!);
|
|
expect(flagged.filter(x => !ALLOW[x.split(':')[0]!])).toEqual([]);
|
|
expect(Object.keys(ALLOW).filter(f => !files.includes(f))).toEqual([]);
|
|
});
|
|
});
|