Onboarding live apps without owner questions for what the stores and the code prove: storebound onboard imports the live products and subscriptions with USD / base prices (catalog diff clean), contact values, the App Review contact and demo account (credentials.json, masked), sign-in for review from Apple's review detail and auth code, and the account deletion page from the app's site; App Store keywords count characters (live fields up to 265 bytes accepted); validate accepts the 6.5-inch iPhone set and Play's default-language graphics fallback; Play Reporting API off gives the project's enable link and apps list falls back to local apps; accounts default <profile>; store-only apps (apps add --store --app-id) shown read-only in the desktop (pitfalls 43-52)

This commit is contained in:
CRTkafa 2026-10-13 07:25:01 +08:00
parent d2ce185441
commit e5f148e534
55 changed files with 1648 additions and 158 deletions

View file

@ -153,20 +153,21 @@ describe('candidates and the 100-byte field', () => {
expect(c.find(x => x.term === 'shopping')).toMatchObject({ popularity: 64, popularityFrom: 'exact', sources: ['competitors', 'apple-ads'] });
expect(c.find(x => x.term === 'aisle')!.popularity).toBeNull();
});
test('fit: <= 100 bytes, current terms kept, indexed and duplicate words dropped', () => {
test('fit: <= 100 characters, current terms kept, indexed and duplicate words dropped', () => {
const ranked = candidates({ ...base, ads: [] });
const p = fitKeywordField({ locale: 'en-US', current: base.keywords, name: base.name, subtitle: base.subtitle, ranked });
expect(p.kept).toEqual(['todo', 'checklist', 'family']);
expect(p.dropped).toEqual([{ term: 'list', why: 'already in the name / subtitle (indexed)' }]);
expect(p.added).toContain('packing');
expect(p.after).not.toMatch(/,\s|\s,/);
expect(p.bytes).toBeLessThanOrEqual(100);
expect(p.chars).toBeLessThanOrEqual(100);
expect(new Set(p.after.split(',')).size).toBe(p.after.split(',').length);
// bytes, not characters: Turkish letters take two
// characters, not bytes: Turkish letters take two bytes and still count one (pitfall 43)
const many = Array.from({ length: 30 }, (_, i) => ({ term: `şğü${i}`, kind: 'word' as const, sources: ['description' as const], ours: 2, relevance: 1, inNames: 0, ofApps: 0, popularity: null, popularityFrom: null, status: 'candidate' as const }));
const t = fitKeywordField({ locale: 'tr', current: '', name: 'X', subtitle: '', ranked: many });
expect(t.bytes).toBeLessThanOrEqual(100);
expect(Buffer.byteLength(t.after)).toBe(t.bytes);
expect(t.chars).toBeLessThanOrEqual(100);
expect([...t.after].length).toBe(t.chars);
expect(Buffer.byteLength(t.after)).toBeGreaterThan(t.chars);
expect(fitKeywordField({ locale: 'en-US', current: 'todo', name: 'X', subtitle: '', ranked, fresh: true }).kept).toEqual([]);
});
test('write: keywords block replaced, draft line above it, found again, then removable', () => {
@ -202,7 +203,7 @@ describe('audit parts', () => {
expect(u.stuffed).toContain('grocery');
});
test('audit: next 3 actions, PPO cannot test the subtitle, Play experiments are a guide', () => {
const a = buildAudit({ today: '2026-10-12', storefront: 'US', findings: [{ level: 'warn', where: 'apple en-US', msg: 'keywords: 40/100 bytes used; fill at least 90 (60 bytes free)' }],
const a = buildAudit({ today: '2026-10-12', storefront: 'US', findings: [{ level: 'warn', where: 'apple en-US', msg: 'keywords: 40/100 characters used; fill at least 90 (60 characters free)' }],
drafts: [], appName: 'Example Notes', ranks: [], competitors: null, analytics: [], variants: ['b'],
apple: { 'en-US': { subtitle: 'x', keywords: 'a' } }, play: [{ lang: 'en-US', title: 'T', short: 'Short', full: 'Full text.', whatsNew: '' }] });
expect(a.next).toHaveLength(3);
@ -242,7 +243,7 @@ describe('commands', () => {
const p = project(BASE, files);
mockFetch([]);
let r = await capture(() => aso(p.ctx(['suggest', '--locale', 'en-US', '--json'])));
const j = jsonOut(r.out) as { written: boolean; results: { ads: string; proposal: { before: string; after: string; bytes: number }; candidates: { popularity: number | null }[] }[] };
const j = jsonOut(r.out) as { written: boolean; results: { ads: string; proposal: { before: string; after: string; chars: number }; candidates: { popularity: number | null }[] }[] };
expect(j.written).toBe(false);
expect(j.results[0]!.ads).toMatch(/no Apple Ads credentials.*popularity unknown/);
expect(j.results[0]!.candidates.every(c => c.popularity === null)).toBe(true);

View file

@ -22,8 +22,10 @@ describe('rules', () => {
expect(long.text.length).toBeLessThanOrEqual(100);
expect(long.text.endsWith(',')).toBe(false);
expect(long.dropped.at(-1)!.why).toBe('over 100');
// multi-byte: 100 bytes, not characters
expect(Buffer.byteLength(normalizeKeywords(Array.from({ length: 20 }, (_, i) => `日本語${i}`).join(',')).text)).toBeLessThanOrEqual(100);
// multi-byte: 100 characters, not bytes (App Store Connect counts characters; pitfall 43)
const ja = normalizeKeywords(Array.from({ length: 30 }, (_, i) => `日本語${i}`).join(',')).text;
expect([...ja].length).toBeLessThanOrEqual(100);
expect(Buffer.byteLength(ja)).toBeGreaterThan(100);
});
test('limits per store field, keyword rules, untranslated and identical text', () => {
const c = checkFields('apple', { subtitle: 'x'.repeat(31), keywords: 'a, b,a', description: 'Same long description text' }, { description: 'Same long description text' }, 'draft');
@ -98,11 +100,21 @@ describe('listing commands', () => {
});
});
test('listing check counts App Store keywords in UTF-8 bytes, the other fields in characters', () => {
test('listing check counts App Store keywords in characters, like App Store Connect (pitfall 43)', () => {
const ru = 'игра,бег,прыжки,монеты,уровни,рекорд,аркада,быстро'; // 50 characters, 93 bytes
const kw = checkFields('apple', { keywords: ru, subtitle: 'Быстрая аркада' }).find(c => c.field === 'keywords')!;
expect(kw).toMatchObject({ chars: 93, unit: 'bytes', problems: [] });
expect(checkFields('apple', { keywords: ru + ',звук' }).find(c => c.field === 'keywords')!.problems).toContain('102 > 100 bytes');
expect(kw).toMatchObject({ chars: 50, unit: 'chars', problems: [] });
// live shape: a Thai field of 95 characters / 265 bytes, READY_FOR_SALE on the App Store
const th = [...'ปลา,ตกปลา,เกมปลา,ทะเล,มหาสมุทร,ตู้ปลา,สัตว์น้ำ,ผ่อนคลาย,สะสม,ฉลาม,ปลาวาฬ,ปะการัง,ดำน้ำ,ฟาร์มปลา,เลี้ยงปลา'].slice(0, 95).join('');
expect([...th].length).toBe(95);
expect(Buffer.byteLength(th)).toBeGreaterThan(100);
expect(checkFields('apple', { keywords: th }).find(c => c.field === 'keywords')!.problems).toEqual([]);
// Turkish 100 characters / 105 bytes (live) passes; 101 characters does not
const tr = 'balık,akvaryum,okyanus,deniz,köpekbalığı,mercan,dalış,sakin,rahatlatıcı,koleksiyon,çiftlik,yüzme,su'.slice(0, 100).padEnd(100, 'a');
expect([...tr].length).toBe(100);
expect(Buffer.byteLength(tr)).toBeGreaterThan(100);
expect(checkFields('apple', { keywords: tr }).find(c => c.field === 'keywords')!.problems).toEqual([]);
expect(checkFields('apple', { keywords: tr + 'b' }).find(c => c.field === 'keywords')!.problems).toContain('101 > 100 characters');
expect(checkFields('apple', { subtitle: 'Быстрая аркада' })[0]).toMatchObject({ chars: 14, unit: 'chars' });
});

View file

@ -5,7 +5,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { applyImport, planImport, useProfile } from '../cli/lib/accounts';
import { activeProfile, androidSigning, ascKey, defaultProfile, playServiceAccount, reviewContact, setConfigAccount } from '../cli/lib/credentials';
import { activeProfile, androidSigning, ascKey, defaultPointer, defaultProfile, playServiceAccount, reviewContact, setConfigAccount, setDefaultPointer } from '../cli/lib/credentials';
import { compareVersions, consent, decide, installPlan, isRunning, question, runningCheck } from '../cli/lib/desktop-install';
import { addFolder, folderId, parseRegistry, readRegistry, removeFolder, withFolder, withoutFolder } from '../cli/lib/registry';
import { desktopProjectId } from '../cli/lib/workspace';
@ -46,6 +46,43 @@ function project(base: string, name: string, o: { keyId?: string; email?: string
}
describe('credentials: one machine, many projects', () => {
test('accounts default <name>: a pointer file; env > project-named > project file > pointer > single profile', () => machine(({ creds, base }) => {
mkdirSync(creds, { recursive: true });
for (const n of ['teamA', 'teamB']) writeFileSync(join(creds, `${n}.json`), JSON.stringify({ ASC_KEY_ID: `${n.toUpperCase()}00000`.slice(0, 10), ASC_ISSUER_ID: 'iss', ASC_P8: PEM('x') }));
const bare = join(base, 'bare');
mkdirSync(bare);
// two profiles, none named default, no pointer: no default
expect(defaultProfile()).toBeNull();
expect(activeProfile(bare)).toBeNull();
expect(() => setDefaultPointer('nope')).toThrow(/no profile "nope"/);
setDefaultPointer('teamB');
expect(readFileSync(join(creds, 'default-profile'), 'utf8')).toBe('teamB\n');
expect(statSync(join(creds, 'default-profile')).mode & 0o777).toBe(0o600);
expect(defaultPointer()).toBe('teamB');
expect(defaultProfile()).toBe('teamB');
expect(activeProfile(bare)).toEqual({ name: 'teamB', from: 'default' });
expect(ascKey(bare).keyId).toBe('TEAMB00000');
// a pointer beats a profile named "default" and the single-profile rule
writeFileSync(join(creds, 'default.json'), JSON.stringify({ ASC_KEY_ID: 'DEFAULT000', ASC_ISSUER_ID: 'iss', ASC_P8: PEM('x') }));
expect(defaultProfile()).toBe('teamB');
// the project's own keys beat the pointer; a profile the project names beats both; env beats all
const a = project(base, 'Star Dash');
expect(ascKey(a).keyId).toBe('ABCDE12345');
const file = join(a, '.storebound', 'credentials.json');
writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file, 'utf8')), account: 'teamA' }));
expect(ascKey(a).keyId).toBe('TEAMA00000');
process.env.ASC_KEY_ID = 'ENV0000000';
expect(ascKey(a).keyId).toBe('ENV0000000');
delete process.env.ASC_KEY_ID;
// a pointer to a profile that is gone falls back to the old rule
writeFileSync(join(creds, 'default-profile'), 'gone\n');
expect(defaultProfile()).toBe('default');
setDefaultPointer(null);
expect(existsSync(join(creds, 'default-profile'))).toBe(false);
// the pointer file is not a profile
expect(readdirSync(creds).filter(n => n.endsWith('.json')).sort()).toEqual(['default.json', 'teamA.json', 'teamB.json']);
}));
test('project-only values from the project, store keys from the profile, env over everything', () => machine(({ creds, base }) => {
const a = project(base, 'Star Dash');
// the project's own keys work without any profile

272
tests/onboard.test.ts Normal file
View file

@ -0,0 +1,272 @@
// Onboarding a live app without owner questions for what the stores and the code prove (storebound onboard): the live
// catalog with its USD / base prices (catalog diff clean afterwards), contact and URLs, the App Review contact and demo
// account (into .storebound/credentials.json, never printed), sign-in for review, the account deletion page; plus the
// Play Reporting API fallback of `apps list` and store-only app entries. Recorded shapes; every id is a placeholder.
import { afterEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { catalogDiff, type StoreItem, wantedCatalog } from '../cli/lib/catalog';
import { loadConfig, type ShipConfig } from '../cli/lib/config';
import { contactProposal, deletionLinks, findDeletionUrl, importCatalog, isTemplateValue, maskContact, signInDecision } from '../cli/lib/onboard';
import { toTs } from '../cli/lib/scan/config-edit';
import { run as onboard } from '../cli/onboard';
import { run as catalog } from '../cli/catalog';
import { parseArgs } from '../cli/lib/args';
import type { Ctx } from '../cli/lib/ctx';
import { ascEnv, BASE, capture, type Call, json, mockFetch, notFound, playEnv, project, restore } from './helpers/store-mock';
afterEach(restore);
const PRO = 'com.example.notes.pro', TIPS = 'com.example.notes.tips', PLUS = 'com.example.notes.plus_monthly';
const page = (data: unknown[], included: unknown[] = []) => ({ data, included, links: { self: 'https://api.appstoreconnect.apple.com/v1/x' }, meta: { paging: { total: data.length, limit: 200 } } });
const price = (id: string, territory: string, point: string) => ({ type: 'inAppPurchasePrices', id, attributes: { startDate: null }, relationships: { inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: point } }, territory: { data: { type: 'territories', id: territory } } } });
const point = (id: string, customerPrice: string) => ({ type: 'inAppPurchasePricePoints', id, attributes: { customerPrice, proceeds: '0' } });
const terr = (id: string, currency: string) => ({ type: 'territories', id, attributes: { currency } });
const money = (currencyCode: string, units: string, nanos = 0) => ({ currencyCode, units, nanos });
/** App Store Connect + Google Play as they answered for a live app (sanitized): Pro has its price schedule's base
* territory in Turkey, Tips is on the App Store only, Plus monthly is a Play-only subscription. */
function liveStores(o: { review?: Record<string, unknown> } = {}): Call[] {
const P = '/androidpublisher/v3/applications/com.example.notes';
return mockFetch([
// ---- App Store Connect: catalog
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?limit=200/, answer: page([
{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, name: 'Pro', inAppPurchaseType: 'NON_CONSUMABLE', state: 'APPROVED' } },
{ type: 'inAppPurchases', id: 'i-tips', attributes: { productId: TIPS, name: 'Tip', inAppPurchaseType: 'CONSUMABLE', state: 'APPROVED' } }]) },
{ match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseLocalizations/, answer: page([
{ type: 'inAppPurchaseLocalizations', id: 'l1', attributes: { locale: 'en-US', name: 'Notes Pro', description: 'Unlimited notebooks' } },
{ type: 'inAppPurchaseLocalizations', id: 'l2', attributes: { locale: 'tr', name: 'Notlar Pro', description: 'Sınırsız defter' } }]) },
{ match: /^\/v2\/inAppPurchases\/i-tips\/inAppPurchaseLocalizations/, answer: page([{ type: 'inAppPurchaseLocalizations', id: 'l3', attributes: { locale: 'en-US', name: 'Small tip', description: 'Thanks!' } }]) },
{ match: /^\/v2\/inAppPurchases\/i-(pro|tips)\/iapPriceSchedule$/, answer: c => ({ data: { type: 'inAppPurchasePriceSchedules', id: c.path.split('/')[3] }, links: {} }) },
{ match: /^\/v1\/inAppPurchasePriceSchedules\/i-pro\/manualPrices/, answer: page([price('m1', 'TUR', 'pp-tr')], [point('pp-tr', '99.99'), terr('TUR', 'TRY')]) },
{ match: /^\/v1\/inAppPurchasePriceSchedules\/i-pro\/automaticPrices/, answer: page([price('a1', 'USA', 'pp-us'), price('a2', 'DEU', 'pp-de')], [point('pp-us', '2.99'), point('pp-de', '2.99'), terr('USA', 'USD'), terr('DEU', 'EUR')]) },
{ match: /^\/v1\/inAppPurchasePriceSchedules\/i-pro\/baseTerritory/, answer: { data: terr('TUR', 'TRY') } },
{ match: /^\/v1\/inAppPurchasePriceSchedules\/i-tips\/manualPrices/, answer: page([price('m2', 'USA', 'pp-us1')], [point('pp-us1', '0.99'), terr('USA', 'USD')]) },
{ match: /^\/v1\/inAppPurchasePriceSchedules\/i-tips\/automaticPrices/, answer: page([]) },
{ match: /^\/v1\/inAppPurchasePriceSchedules\/i-tips\/baseTerritory/, answer: { data: terr('USA', 'USD') } },
{ match: /^\/v1\/apps\/6400000101\/subscriptionGroups/, answer: page([]) },
// ---- App Store Connect: app details and review detail
{ match: /^\/v1\/apps\/6400000101$/, answer: { data: { type: 'apps', id: '6400000101', attributes: { bundleId: 'com.example.notes', primaryLocale: 'en-US' } } } },
{ match: /^\/v1\/apps\/6400000101\/appStoreVersions\?filter\[platform\]=IOS/, answer: page([{ type: 'appStoreVersions', id: 'v1', attributes: { versionString: '2.3.0', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION' } }]) },
{ match: /^\/v1\/appStoreVersions\/v1\/appStoreVersionLocalizations/, answer: page([{ type: 'appStoreVersionLocalizations', id: 'vl1', attributes: { locale: 'en-US', supportUrl: 'https://notes.example.org/help', marketingUrl: 'https://notes.example.org' } }]) },
{ match: /^\/v1\/appStoreVersions\/v1\/appStoreReviewDetail/, answer: { data: { type: 'appStoreReviewDetails', id: 'rd1', attributes: { contactFirstName: 'Ada', contactLastName: 'Example', contactPhone: '+1 555 0100 77', contactEmail: 'review@example.org',
demoAccountName: 'demo-reviewer', demoAccountPassword: 'demo-secret-sentinel', demoAccountRequired: true, notes: 'Sign in with the demo account.', ...o.review } } } },
{ match: /^\/v1\/apps\/6400000101\/appInfos/, answer: page([{ type: 'appInfos', id: 'ai1', attributes: { state: 'READY_FOR_DISTRIBUTION' } }]) },
{ match: /^\/v1\/appInfos\/ai1\/appInfoLocalizations/, answer: page([{ type: 'appInfoLocalizations', id: 'ail1', attributes: { locale: 'en-US', name: 'Example Notes', privacyPolicyUrl: 'https://notes.example.org/privacy' } }]) },
// ---- Google Play
{ match: new RegExp(`^${P}/oneTimeProducts\\?pageSize=100`), answer: { oneTimeProducts: [{ productId: PRO }] } },
{ match: new RegExp(`^${P}/oneTimeProducts/${PRO.replace(/\./g, '\\.')}$`), answer: { productId: PRO, listings: [{ languageCode: 'en-US', title: 'Notes Pro', description: 'Unlimited notebooks' }],
purchaseOptions: [{ purchaseOptionId: 'buy', state: 'ACTIVE', regionalPricingAndAvailabilityConfigs: [{ regionCode: 'US', price: money('USD', '2', 990000000), availability: 'AVAILABLE' }, { regionCode: 'TR', price: money('TRY', '99', 990000000), availability: 'AVAILABLE' }],
newRegionsConfig: { usdPrice: money('USD', '2', 990000000), eurPrice: money('EUR', '2', 990000000), availability: 'AVAILABLE' } }] } },
{ match: new RegExp(`^${P}/subscriptions\\?pageSize=100`), answer: { subscriptions: [{ productId: PLUS, listings: [{ languageCode: 'en-US', title: 'Plus', description: 'Sync across devices', benefits: ['Sync'] }],
basePlans: [{ basePlanId: 'monthly', state: 'ACTIVE', autoRenewingBasePlanType: { billingPeriodDuration: 'P1M' }, regionalConfigs: [{ regionCode: 'US', price: money('USD', '4', 990000000) }], otherRegionsConfig: { usdPrice: money('USD', '4', 990000000) } }] }] } },
{ match: new RegExp(`^${P}/subscriptions/${PLUS.replace(/\./g, '\\.')}/basePlans/monthly/offers`), answer: { subscriptionOffers: [] } },
{ method: 'POST', match: new RegExp(`^${P}/edits$`), answer: { id: 'e1' } },
{ match: new RegExp(`^${P}/edits/e1/details$`), answer: { defaultLanguage: 'en-US', contactEmail: 'hello@example.org', contactWebsite: 'https://notes.example.org', contactPhone: '+1 555 0100 88' } },
{ method: 'DELETE', match: new RegExp(`^${P}/edits/e1$`), answer: {} },
// ---- the app's own website
{ match: /^\/delete-account$/, answer: () => new Response('<html><body><h1>Delete your account</h1><form>email</form></body></html>', { status: 200, headers: { 'content-type': 'text/html' } }) },
]);
}
/** A project whose storebound.config.ts holds `config` (as init + listing pull leave it) and code with a sign-in. */
function liveProject(config: ShipConfig = BASE) {
const p = project(config, { 'src/auth.ts': 'export const login = (auth, email, password) => signInWithEmailAndPassword(auth, email, password);\n', 'package.json': '{"name":"notes","dependencies":{}}' });
writeFileSync(p.loaded.path, `import type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = ${toTs(config)};\n\nexport default config;\n`);
return p;
}
describe('importCatalog (pure)', () => {
const apple: StoreItem[] = [
{ id: PRO, kind: 'non_consumable', locales: ['en-US', 'tr'], base: { region: 'TR', currency: 'TRY', price: 99.99 }, prices: [{ region: 'TR', currency: 'TRY', price: 99.99 }, { region: 'US', currency: 'USD', price: 2.99 }] },
{ id: TIPS, kind: 'consumable', locales: ['en-US'], prices: [{ region: 'US', currency: 'USD', price: 0.99 }] },
];
const play: StoreItem[] = [
{ id: PRO, kind: 'one_time', locales: ['en-US'], prices: [{ region: 'US', currency: 'USD', price: 2.99 }, { region: 'TR', currency: 'TRY', price: 99.99 }] },
{ id: PLUS, kind: 'subscription', period: 'P1M', locales: ['en-US'], prices: [{ region: 'DE', currency: 'EUR', price: 4.99 }], usd: 4.99 },
];
const texts = { apple: { [PRO]: { 'en-US': { title: 'Notes Pro', description: 'Unlimited notebooks' } }, [TIPS]: { 'en-US': { title: 'Small tip', description: 'Thanks!' } } },
play: { [PRO]: { 'en-US': { title: 'Notes Pro', description: 'Unlimited notebooks' } }, [PLUS]: { 'en-US': { title: 'Plus', description: 'Sync' }, 'xx-XX': { title: 'X', description: 'Y' } } } };
test('USD from the US price, Apple base territory as an override, one-store items marked, catalog diff clean', () => {
const r = importCatalog(BASE, { apple, play, appleTexts: texts.apple, playTexts: texts.play });
expect(r.skipped).toEqual([]);
expect(r.products.find(p => p.id === PRO)).toMatchObject({ type: 'non_consumable', usd: 2.99, overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }] });
expect(r.products.find(p => p.id === PRO)!.platforms).toBeUndefined();
expect(r.products.find(p => p.id === TIPS)).toMatchObject({ type: 'consumable', usd: 0.99, platforms: ['ios'] });
// a Play subscription without a US region: the store's own USD price for other regions
expect(r.subscriptions[0]!.subscriptions[0]).toMatchObject({ id: PLUS, period: 'P1M', usd: 4.99, platforms: ['android'] });
expect(r.notes.some(n => n.msg.includes('xx-XX is not in languages'))).toBe(true);
const cfg = { ...BASE, products: r.products, subscriptions: r.subscriptions };
const { rows } = catalogDiff(wantedCatalog(cfg), { apple, play });
expect(rows.filter(x => x.level === 'error')).toEqual([]);
});
test('no USD price on any store: skipped with the reason, never a made-up price', () => {
const r = importCatalog(BASE, { apple: [{ id: PRO, kind: 'non_consumable', locales: [], base: { region: 'TR', currency: 'TRY', price: 99.99 }, prices: [{ region: 'TR', currency: 'TRY', price: 99.99 }] }], play: null, appleTexts: texts.apple });
expect(r.products).toEqual([]);
expect(r.skipped[0]!.why).toContain('no USD price on the App Store (base TR 99.99 TRY)');
});
test('the config\'s own fields stay; a Play-only one-time product is non_consumable with a note', () => {
const cfg = { ...BASE, products: [{ id: PRO, type: 'non_consumable' as const, usd: 1.99, listings: { en: { title: 'Old', description: 'Old' } }, reviewScreenshot: 'store/iap-review/pro.png' }] };
const r = importCatalog(cfg, { apple: null, play: [play[0]!, { id: 'com.example.notes.gems', kind: 'one_time', locales: [], prices: [{ region: 'US', currency: 'USD', price: 1.99 }] }], playTexts: { 'com.example.notes.gems': { 'en-US': { title: 'Gems', description: '100 gems' } }, ...texts.play } });
expect(r.products.find(p => p.id === PRO)).toMatchObject({ usd: 2.99, reviewScreenshot: 'store/iap-review/pro.png', listings: { en: { title: 'Notes Pro' } } });
expect(r.updated).toContain(PRO);
expect(r.notes.filter(n => n.id === 'com.example.notes.gems').map(n => n.msg).join(' | ')).toContain('does not say whether a one-time product is consumable');
});
});
describe('contact, sign-in, deletion page (pure)', () => {
test('store contact replaces template values only', () => {
const c = contactProposal({ ...BASE.app, supportUrl: 'https://help.example.net' }, { play: { contactEmail: 'hello@example.org', contactWebsite: 'notes.example.org' }, apple: { supportUrl: 'https://notes.example.org/help', privacyPolicyUrl: 'https://notes.example.org/privacy' } });
expect(c.filter(x => x.replace).map(x => `${x.field}=${x.to}`)).toEqual(['contactEmail=hello@example.org', 'website=https://notes.example.org', 'privacyPolicyUrl=https://notes.example.org/privacy']);
expect(c.find(x => x.field === 'supportUrl')).toMatchObject({ replace: false, from: 'https://help.example.net' });
expect(isTemplateValue('support@example.com')).toBe(true);
expect(isTemplateValue('https://example.com/privacy')).toBe(true);
expect(isTemplateValue('https://notes.example.org')).toBe(false);
expect(maskContact({ firstName: 'Ada', lastName: 'Example', phone: '+1 555 0100 77', email: 'review@example.org' })).toBe('A. E. r***@example.org, phone ***77');
});
test('sign-in: Apple review detail first, then the code, never a default yes', () => {
expect(signInDecision({ auth: [], apple: null })).toMatchObject({ signInRequired: false, question: null });
expect(signInDecision({ auth: ['src/auth.ts:1'], apple: null })).toMatchObject({ signInRequired: null });
expect(signInDecision({ auth: ['src/auth.ts:1'], apple: null }).question).toContain('without signing in');
expect(signInDecision({ auth: ['src/auth.ts:1'], apple: { demoAccountRequired: false } })).toMatchObject({ signInRequired: false, question: null });
const d = signInDecision({ auth: [], apple: { demoAccountRequired: true, demoAccountName: 'demo', demoAccountPassword: 'pw' } });
expect(d).toMatchObject({ signInRequired: true, demoAccount: { username: 'demo', password: 'pw' } });
expect(d.because).not.toContain('pw');
});
test('deletion page: candidates, then privacy policy links; not found stays a gap', async () => {
const pages: Record<string, { status: number; text: string }> = {
'https://notes.example.org/privacy': { status: 200, text: '<p>Privacy</p><a href="/account/remove">Request deletion of your data</a>' },
'https://notes.example.org/account/remove': { status: 200, text: '<h1>Delete account</h1>' },
};
const get = async (u: string) => pages[u] ?? { status: 404, text: 'not found' };
const r = await findDeletionUrl({ website: 'https://notes.example.org', privacyUrl: 'https://notes.example.org/privacy' }, get);
expect(r).toMatchObject({ url: 'https://notes.example.org/account/remove', how: 'linked from the privacy policy as account / data deletion' });
expect(r.tried.slice(0, 2)).toEqual(['https://notes.example.org/delete-account', 'https://notes.example.org/account/delete']);
const anchor = await findDeletionUrl({ privacyUrl: 'https://notes.example.org/p' }, async u => (u === 'https://notes.example.org/p' ? { status: 200, text: '<h2 id="delete-your-data">Deleting</h2>' } : { status: 404, text: '' }));
expect(anchor.url).toBe('https://notes.example.org/p#delete-your-data');
const none = await findDeletionUrl({ website: 'https://notes.example.org' }, async () => ({ status: 404, text: '' }));
expect(none).toMatchObject({ url: null, how: 'not found: a real gap, Play requires a web page to request account deletion (account-deletion skill)' });
expect(await findDeletionUrl({ website: 'https://example.com' }, async () => ({ status: 200, text: 'delete' }))).toMatchObject({ url: null, tried: [] });
expect(deletionLinks('<a href="https://x.example.org/hesap">Hesabını sil</a><a href="/about">About</a>', 'https://x.example.org/')).toEqual(['https://x.example.org/hesap']);
});
});
describe('storebound onboard (recorded live shapes)', () => {
test('--write: catalog with prices, contact, review contact and demo account (never printed), sign-in, deletion page; catalog diff clean', async () => {
ascEnv(); playEnv();
const p = liveProject();
const calls = liveStores();
const r = await capture(() => onboard(p.ctx(['--write'], ['write'])));
const all = r.out + r.err;
// personal data and the demo password never reach the output
expect(all).not.toContain('demo-secret-sentinel');
expect(all).not.toContain('demo-reviewer');
expect(all).not.toContain('555 0100');
expect(all).not.toContain('review@example.org');
expect(all).toContain('r***@example.org, phone ***77');
expect(all).toContain('sign-in for review: required (App Store review detail: demo account required (a demo account is set there))');
expect(all).toContain('account deletion page: https://notes.example.org/delete-account');
// read-only on the stores: only the throwaway Play edit is created and deleted
expect(calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path.replace(/^.*\/applications\/[^/]+/, '')}`)).toEqual(['POST /edits', 'DELETE /edits/e1']);
const creds = JSON.parse(readFileSync(join(p.root, '.storebound', 'credentials.json'), 'utf8'));
expect(creds.reviewDemoAccount).toEqual({ username: 'demo-reviewer', password: 'demo-secret-sentinel' });
expect(creds.reviewContact).toEqual({ firstName: 'Ada', lastName: 'Example', phone: '+1 555 0100 77', email: 'review@example.org' });
const loaded = await loadConfig(p.loaded.path);
const c = loaded.config;
expect(c.app).toMatchObject({ contactEmail: 'hello@example.org', website: 'https://notes.example.org', supportUrl: 'https://notes.example.org/help', privacyPolicyUrl: 'https://notes.example.org/privacy' });
expect(c.reviewAccess).toEqual({ signInRequired: true });
expect(c.accounts).toEqual({ enabled: true, deletionUrl: 'https://notes.example.org/delete-account' });
expect(c.products!.map(x => `${x.id} ${x.type} ${x.usd} ${(x.platforms ?? []).join()} ${(x.overrides ?? []).map(o => `${o.region}:${o.price}`).join()}`)).toEqual([
`${PRO} non_consumable 2.99 TR:99.99`, `${TIPS} consumable 0.99 ios `]);
expect(c.subscriptions![0]!.subscriptions[0]).toMatchObject({ id: PLUS, period: 'P1M', usd: 4.99, platforms: ['android'], listings: { en: { title: 'Plus', benefits: ['Sync'] } } });
// the same stores, read by catalog diff against the written config: no mismatch
liveStores();
const ctx: Ctx = { loaded: { ...loaded }, args: parseArgs(['diff', '--json'], ['json']) };
const d = await capture(() => catalog(ctx));
expect(d.exit).toBeNull();
expect(d.err + d.out).toContain('"errors": 0');
});
test('without --write nothing is written; sign-in unproven becomes the one owner question', async () => {
ascEnv(); playEnv();
const p = liveProject();
liveStores({ review: { demoAccountRequired: null, demoAccountName: null, demoAccountPassword: null } });
const before = readFileSync(p.loaded.path, 'utf8');
const r = await capture(() => onboard(p.ctx([])));
expect(readFileSync(p.loaded.path, 'utf8')).toBe(before);
expect(r.out).toContain('owner questions');
expect(r.out).toContain('Can a reviewer use every main feature without signing in?');
expect(r.err + r.out).toContain('(run with --write)');
});
});
describe('Play Reporting API off', () => {
const SERVICE_DISABLED = JSON.stringify({ error: { code: 403, message: 'Google Play Developer Reporting API has not been used in project 123456789000 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/playdeveloperreporting.googleapis.com/overview?project=123456789000 then retry.', status: 'PERMISSION_DENIED',
details: [{ '@type': 'type.googleapis.com/google.rpc.ErrorInfo', reason: 'SERVICE_DISABLED', domain: 'googleapis.com', metadata: { service: 'playdeveloperreporting.googleapis.com', consumer: 'projects/123456789000' } }] } });
test('the exact link for the project; apps list falls back to the folder list and store-only entries', async () => {
const { reportingEnableUrl, playCheck } = await import('../cli/lib/store-apps');
expect(reportingEnableUrl(SERVICE_DISABLED, null)).toEqual({ project: '123456789000', url: 'https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=123456789000' });
expect(reportingEnableUrl('403', ['sb', 'example-project-01.iam.gserviceaccount.com'].join('@')).project).toBe('example-project-01');
const c = playCheck('sb@example.com', { error: new Error(`GET /apps:search -> 403 ${SERVICE_DISABLED}`) });
expect(c.status).toBe('api-disabled');
expect(c.fix).toContain('https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=123456789000');
const { healthFindings } = await import('../cli/lib/health');
expect(healthFindings({ date: '2026-10-13', play: { error: `-> 403 ${SERVICE_DISABLED}` } })[0]!.fix).toContain('?project=123456789000');
playEnv();
const saved = process.env.STOREBOUND_REGISTRY;
const base = mkdtempSync(join(tmpdir(), 'sb-reg-'));
process.env.STOREBOUND_REGISTRY = join(base, 'projects.json');
try {
const folder = liveProject();
writeFileSync(process.env.STOREBOUND_REGISTRY, JSON.stringify({ version: 1, projects: [{ id: 'aaaaaaaaaaaaaaaa', path: folder.root, name: 'Example Notes', added: '' }],
storeApps: [{ store: 'play', appId: 'com.example.legacy', name: 'Legacy', account: 'teamA' }, { store: 'apple', appId: '6400000999', name: 'iOS only' }] }));
mockFetch([{ match: /^\/v1beta1\/apps:search/, answer: () => new Response(SERVICE_DISABLED, { status: 403, headers: { 'content-type': 'application/json' } }) }]);
const { listApps } = await import('../cli/apps');
const r = await listApps('play', base, false);
expect(r.apps.map(a => `${a.id} ${a.name}`)).toEqual(['com.example.notes Example Notes', 'com.example.legacy Legacy']);
expect(r.warnings[0]).toContain('Reporting API is off');
expect(r.warnings[0]).toContain('?project=123456789000');
expect(r.warnings[1]).toContain('listed 2 Play app(s)');
} finally {
if (saved === undefined) delete process.env.STOREBOUND_REGISTRY; else process.env.STOREBOUND_REGISTRY = saved;
}
});
});
describe('store-only apps in the machine list', () => {
test('apps add --store apple --app-id --account: an entry with no folder; folders keep working; remove by store:id', async () => {
const { readRegistry, withStoreApp, withoutStoreApp, parseRegistry, storeAppId, withFolder } = await import('../cli/lib/registry');
const saved = process.env.STOREBOUND_REGISTRY;
const base = mkdtempSync(join(tmpdir(), 'sb-reg-'));
process.env.STOREBOUND_REGISTRY = join(base, 'projects.json');
try {
const { run: apps } = await import('../cli/apps');
const r = await capture(() => apps(parseArgs(['add', '--store', 'apple', '--app-id', '6400000999', '--account', 'teamA', '--name', 'Demo App'], [])));
expect(r.err + r.out).toContain('added apple app 6400000999 (Demo App) with no local project, profile teamA');
const reg = readRegistry();
expect(reg.storeApps).toEqual([{ id: storeAppId('apple', '6400000999'), store: 'apple', appId: '6400000999', account: 'teamA', name: 'Demo App', bundleId: null, added: reg.storeApps![0]!.added }]);
expect(reg.projects).toEqual([]);
// a folder added later keeps the store-only entry
const f = withFolder(reg, '/w/Star Dash', undefined, 't').registry;
expect(f.storeApps).toHaveLength(1);
expect(parseRegistry(JSON.stringify(f)).storeApps).toHaveLength(1);
const again = await capture(() => apps(parseArgs(['add', '--store', 'apple', '--app-id', '6400000999', '--account', 'teamA', '--name', 'Demo App'], [])));
expect(again.err + again.out).toContain('already in the list');
const list = await capture(() => apps(parseArgs(['folders'], [])));
expect(list.out).toContain('no local project');
expect(() => withStoreApp(reg, { store: 'apple', appId: 'com.example.x' }, 't')).toThrow(/numeric Apple ID/);
expect(() => withStoreApp(reg, { store: 'play', appId: '123' }, 't')).toThrow(/package name/);
expect(withoutStoreApp(reg, 'apple:6400000999').removed?.name).toBe('Demo App');
const rm = await capture(() => apps(parseArgs(['remove', 'apple:6400000999'], [])));
expect(rm.err + rm.out).toContain('removed apple app 6400000999');
expect(readRegistry().storeApps).toBeUndefined();
expect(JSON.parse(readFileSync(process.env.STOREBOUND_REGISTRY, 'utf8'))).toEqual({ version: 1, projects: [] });
} finally {
if (saved === undefined) delete process.env.STOREBOUND_REGISTRY; else process.env.STOREBOUND_REGISTRY = saved;
}
});
});
// keep the json helper referenced for recorded answers built inline
void json; void notFound;

View file

@ -60,11 +60,12 @@ describe('copy parsing', () => {
expect(Object.keys(c)).toEqual(['en-US', 'tr']);
expect(c['en-US']!.subtitle).toBe('Short hook under the name');
});
test('appstore: missing field, keyword spaces, byte limit', () => {
test('appstore: missing field, keyword spaces, character limit (pitfall 43)', () => {
const sec = (kw: string) => `## de-DE\n### subtitle\n\`\`\`\nA\n\`\`\`\n### promotionalText\n\`\`\`\nB\n\`\`\`\n### keywords\n\`\`\`\n${kw}\n\`\`\`\n### description\n\`\`\`\nD\n\`\`\`\n`;
expect(parseAppStoreListing(sec('a,b'))['de-DE']!.keywords).toBe('a,b');
expect(() => parseAppStoreListing(sec('a, b'))).toThrow('no spaces after commas');
expect(() => parseAppStoreListing(sec('ö'.repeat(60)))).toThrow('bytes > 100');
expect(parseAppStoreListing(sec('ö'.repeat(100)))['de-DE']!.keywords).toHaveLength(100);
expect(() => parseAppStoreListing(sec('ö'.repeat(101)))).toThrow('keywords is 101 > 100');
expect(() => parseAppStoreListing('## fr-FR\n### subtitle\n```\nx\n```\n')).toThrow('fr-FR: missing promotionalText');
});
});

View file

@ -100,7 +100,7 @@ describe('Play account check with the real 403', () => {
const after = new Error(`GET /apps:search?pageSize=1000 -> 403 ${errorBody(SERVICE_DISABLED_BODY, 800)}`);
expect(playCheck('sb@example.com', { error: after }).status).toBe('api-disabled');
});
test('the CLI: check says api-disabled; list fails with the reason and the fix on one line', async () => {
test('the CLI: check says api-disabled with the project\'s link; list does not fail: the finding and the local apps (pitfall 46)', async () => {
playKey();
globalThis.fetch = (async (input: string | URL | Request) => {
const url = String(input instanceof Request ? input.url : input);
@ -110,17 +110,24 @@ describe('Play account check with the real 403', () => {
}) as typeof fetch;
const root = mkdtempSync(join(tmpdir(), 'sb-real-'));
const c = await checkAccount('play', root);
expect(c).toMatchObject({ status: 'api-disabled', principal: 'sb@example.com' });
await expect(listApps('play', root, false)).rejects.toThrow(/-> 403 \{"error":/);
expect(c).toMatchObject({ status: 'api-disabled', principal: 'sb@example.com', docs: 'https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=123456789000' });
const savedReg = process.env.STOREBOUND_REGISTRY;
process.env.STOREBOUND_REGISTRY = join(root, 'projects.json');
const cwd = process.cwd();
process.chdir(root);
try {
let msg = '';
try { await appsRun(parseArgs(['list', '--store', 'play', '--json'], ['json', 'icons'])); } catch (e) { msg = (e as Error).message; }
expect(msg).not.toContain('\n');
expect(msg).toContain('Reporting API is off');
expect(msg).toContain('Fix: Google Cloud console');
} finally { process.chdir(cwd); }
const r = await listApps('play', root, false);
expect(r.apps).toEqual([]);
expect(r.warnings[0]).not.toContain('\n');
expect(r.warnings[0]).toContain('Reporting API is off');
expect(r.warnings[0]).toContain('Fix: Enable "Google Play Developer Reporting API" for that project: https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=123456789000');
let failed = false;
try { await appsRun(parseArgs(['list', '--store', 'play', '--json'], ['json', 'icons'])); } catch { failed = true; }
expect(failed).toBe(false);
} finally {
process.chdir(cwd);
if (savedReg === undefined) delete process.env.STOREBOUND_REGISTRY; else process.env.STOREBOUND_REGISTRY = savedReg;
}
});
});

View file

@ -85,7 +85,7 @@ describe('mcp tools', () => {
}
}
// the local-write exception covers only the pull tools, and only with dry_run false
expect(Object.keys(LOCAL_WRITE_TOOLS).sort()).toEqual(['apple_listing_pull', 'catalog_pull', 'play_listing_pull']);
expect(Object.keys(LOCAL_WRITE_TOOLS).sort()).toEqual(['apple_listing_pull', 'catalog_pull', 'onboard', 'play_listing_pull']);
for (const n of Object.keys(LOCAL_WRITE_TOOLS)) expect(toArgv(TOOLS.find(t => t.name === n)!, {}).argv).not.toContain('--write');
const phased = TOOLS.find(t => t.name === 'apple_phased_release')!;
expect(phased.props!.action!.enum).toEqual(['on', 'pause']);
@ -148,18 +148,20 @@ describe('mcp: screenshot and video tools', () => {
});
describe('aso', () => {
test('App Store keywords count bytes, not characters (limit and fill hint)', () => {
const msgs = (kw: string) => auditAppleKeywords('ru', { keywords: kw } as never, 'X').map(f => f.msg);
const short = 'игра,бег,прыжки,монеты,уровни,рекорд,аркада'; // 43 characters, 80 bytes
expect(msgs(short).some(m => m.includes('80/100 bytes') && m.includes('fill at least 90'))).toBe(true);
const full = short + ',быстро'; // 50 characters but 93 bytes: full enough
expect(msgs(full).some(m => m.includes('fill at least 90'))).toBe(false);
expect(msgs(full + ',звук').some(m => m.includes('102 bytes > 100'))).toBe(true);
test('App Store keywords count characters, not bytes (limit and fill hint; pitfall 43)', () => {
const msgs = (kw: string) => auditAppleKeywords('pl', { keywords: kw } as never, 'X').map(f => f.msg);
// live shape: a Polish field of 99 characters / 105 bytes, READY_FOR_SALE
const pl = 'ryby,akwarium,ocean,morze,rekin,koralowiec,nurkowanie,relaks,kolekcja,hodowla,pływanie,łowienie,żółw';
expect([...pl].length).toBeLessThanOrEqual(100);
expect(Buffer.byteLength(pl)).toBeGreaterThan(100);
expect(msgs(pl).filter(m => /> 100/.test(m))).toEqual([]);
expect(msgs('ryby,akwarium').some(m => m.includes('13/100 characters used') && m.includes('fill at least 90'))).toBe(true);
expect(msgs('ż'.repeat(101)).some(m => m.includes('101 characters > 100'))).toBe(true);
});
test('keyword field rules', () => {
const f = auditAppleKeywords('en-US', { subtitle: 'Relaxing puzzle', keywords: 'match,puzzle,match,free game' }, 'Tile Match');
const text = f.map(x => `${x.level}:${x.msg}`).join('\n');
expect(text).toContain('warn:keywords: 28/100 bytes used');
expect(text).toContain("warn:keywords: 28/100 characters used");
expect(text).toContain('"match" listed twice');
expect(text).toContain('repeat words from the name / subtitle (already indexed): match, puzzle');
expect(text).toContain('filler words that rarely help: free, game');
@ -324,6 +326,35 @@ describe('validate rules', () => {
h.tracks = [{ track: 'production', releases: [{ status: 'halted', versionCodes: ['8'], userFraction: 0.1 }] }];
expect(playRules(h).find(x => x.code === 'tracks')!.level).toBe('warn');
});
test('play: a non-default language without graphics uses the default language\'s (pitfall 45)', () => {
// live shape: 18 listings, only the default language (tr-TR) has an icon and a feature graphic
const s = playOk();
const extra = ['en-US', 'de-DE', 'fr-FR', 'es-ES', 'it-IT', 'pt-BR', 'ru-RU', 'ja-JP', 'ko-KR', 'zh-CN', 'ar', 'hi-IN', 'id', 'pl-PL', 'nl-NL', 'th', 'vi'];
s.defaultLanguage = 'tr-TR';
s.expectedLanguages = ['tr-TR', ...extra];
s.listings = [{ language: 'tr-TR', title: 't', shortDescription: 's', fullDescription: 'f', images: { icon: 1, featureGraphic: 1, phoneScreenshots: 4 } },
...extra.map(language => ({ language, title: 't', shortDescription: 's', fullDescription: 'f', images: { icon: 0, featureGraphic: 0, phoneScreenshots: 4 } }))];
const f = playRules(s);
expect(f.filter(x => x.level === 'blocker')).toEqual([]);
expect(f.filter(x => x.code === 'images-fallback').map(x => x.msg)).toEqual([
"icon: 17 language(s) without their own use the default language's (tr-TR) graphics: en-US, de-DE, fr-FR, es-ES, it-IT, pt-BR ...",
"featureGraphic: 17 language(s) without their own use the default language's (tr-TR) graphics: en-US, de-DE, fr-FR, es-ES, it-IT, pt-BR ...",
]);
// the default language's own missing graphics still block
s.listings[0]!.images.featureGraphic = 0;
expect(playRules(s).filter(x => x.level === 'blocker').map(x => x.msg)).toEqual(expect.arrayContaining(['tr-TR: featureGraphic 0/1']));
expect(playRules(s).filter(x => x.level === 'blocker').length).toBe(18);
});
test('apple: a live app with only the 6.5-inch set passes; 6.9-inch is a recommendation (pitfall 44)', () => {
const s = appleOk();
// live shape: GET /v1/appStoreVersionLocalizations/{id}/appScreenshotSets answered one APP_IPHONE_65 set per locale
s.locales = s.locales.map(l => ({ ...l, screenshots: { APP_IPHONE_65: 6 } }));
const f = appleRules(s);
expect(f.filter(x => x.level === 'blocker')).toEqual([]);
expect(f.filter(x => x.code === 'screenshots-size').map(x => `${x.level}:${x.msg}`)[0]).toBe('warn:en-US: no APP_IPHONE_67 set; Apple accepts the APP_IPHONE_65 set (6 screenshots) and scales it for the other sizes. Recommended, not required: add APP_IPHONE_67');
s.locales[0]!.screenshots = { APP_IPHONE_61: 3 };
expect(appleRules(s).filter(x => x.level === 'blocker').map(x => x.msg)).toEqual(['en-US: no APP_IPHONE_67 screenshots (or APP_IPHONE_65)']);
});
});
describe('release helpers', () => {