// 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 } = {}): 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('

Delete your account

email
', { 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 = { 'https://notes.example.org/privacy': { status: 200, text: '

Privacy

Request deletion of your data' }, 'https://notes.example.org/account/remove': { status: 200, text: '

Delete account

' }, }; 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: '

Deleting

' } : { 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('Hesabını silAbout', '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;