341 lines
32 KiB
TypeScript
341 lines
32 KiB
TypeScript
// 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, reviewVersion, 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>; draft?: boolean; subs?: boolean } = {}): Call[] {
|
|
const P = '/androidpublisher/v3/applications/com.example.notes';
|
|
// with `subs`: one App Store group "Premium" whose monthly (groupLevel 2) is listed before its yearly (groupLevel 1)
|
|
const subPrice = (id: string, pt: string) => ({ type: 'subscriptionPrices', id, attributes: { startDate: null, preserved: false }, relationships: { subscriptionPricePoint: { data: { type: 'subscriptionPricePoints', id: pt } }, territory: { data: { type: 'territories', id: 'USA' } } } });
|
|
const appleSubs = o.subs ? [
|
|
{ match: /^\/v1\/apps\/6400000101\/subscriptionGroups/, answer: page([{ type: 'subscriptionGroups', id: 'g1', attributes: { referenceName: 'Premium' } }]) },
|
|
{ match: /^\/v1\/subscriptionGroups\/g1\/subscriptions/, answer: page([
|
|
{ type: 'subscriptions', id: 's-m', attributes: { productId: 'com.example.notes.premium.monthly', name: 'Premium monthly', state: 'APPROVED', subscriptionPeriod: 'ONE_MONTH', groupLevel: 2, familySharable: false } },
|
|
{ type: 'subscriptions', id: 's-y', attributes: { productId: 'com.example.notes.premium.yearly', name: 'Premium yearly', state: 'APPROVED', subscriptionPeriod: 'ONE_YEAR', groupLevel: 1, familySharable: false } }]) },
|
|
{ match: /^\/v1\/subscriptions\/s-m\/subscriptionLocalizations/, answer: page([{ type: 'subscriptionLocalizations', id: 'sl1', attributes: { locale: 'en-US', name: 'Premium monthly', description: 'Everything, monthly' } }]) },
|
|
{ match: /^\/v1\/subscriptions\/s-y\/subscriptionLocalizations/, answer: page([{ type: 'subscriptionLocalizations', id: 'sl2', attributes: { locale: 'en-US', name: 'Premium yearly', description: 'Everything, yearly' } }]) },
|
|
{ match: /^\/v1\/subscriptions\/s-m\/prices/, answer: page([subPrice('sp1', 'spp-m')], [{ type: 'subscriptionPricePoints', id: 'spp-m', attributes: { customerPrice: '4.99', proceeds: '3.49' } }, terr('USA', 'USD')]) },
|
|
{ match: /^\/v1\/subscriptions\/s-y\/prices/, answer: page([subPrice('sp2', 'spp-y')], [{ type: 'subscriptionPricePoints', id: 'spp-y', attributes: { customerPrice: '39.99', proceeds: '27.99' } }, terr('USA', 'USD')]) },
|
|
{ match: /^\/v1\/subscriptions\/s-[my]\/(introductoryOffers|promotionalOffers|offerCodes)/, answer: page([]) },
|
|
] : [];
|
|
return mockFetch([
|
|
...appleSubs,
|
|
// ---- 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' } } } },
|
|
// with `draft`: a newer 2.4.0 in PREPARE_FOR_SUBMISSION (listed first, as App Store Connect does) whose review detail is empty
|
|
{ match: /^\/v1\/apps\/6400000101\/appStoreVersions\?filter\[platform\]=IOS/, answer: page([
|
|
...(o.draft ? [{ type: 'appStoreVersions', id: 'v2', attributes: { versionString: '2.4.0', appStoreState: 'PREPARE_FOR_SUBMISSION', appVersionState: 'PREPARE_FOR_SUBMISSION', createdDate: '2026-09-20T01:00:00-07:00' } }] : []),
|
|
{ type: 'appStoreVersions', id: 'v1', attributes: { versionString: '2.3.0', appStoreState: 'READY_FOR_SALE', appVersionState: 'READY_FOR_DISTRIBUTION', createdDate: '2026-09-01T01:00:00-07:00' } }]) },
|
|
{ match: /^\/v1\/appStoreVersions\/v2\/appStoreVersionLocalizations/, answer: page([{ type: 'appStoreVersionLocalizations', id: 'vl2', attributes: { locale: 'en-US', supportUrl: 'https://notes.example.org/help', marketingUrl: 'https://notes.example.org' } }]) },
|
|
{ match: /^\/v1\/appStoreVersions\/v2\/appStoreReviewDetail/, answer: { data: { type: 'appStoreReviewDetails', id: 'rd2', attributes: { contactFirstName: 'Ada', contactLastName: 'Example', contactPhone: '+1 555 0100 77', contactEmail: 'review@example.org',
|
|
demoAccountName: null, demoAccountPassword: null, demoAccountRequired: false, notes: null } } } },
|
|
{ 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('review version: the live one, newest first; the newest only when nothing is live', () => {
|
|
const v = (id: string, state: string, createdDate: string) => ({ id, attributes: { appStoreState: state, appVersionState: state === 'READY_FOR_SALE' ? 'READY_FOR_DISTRIBUTION' : state, createdDate } });
|
|
expect(reviewVersion([v('d', 'PREPARE_FOR_SUBMISSION', '2026-09-20'), v('l1', 'READY_FOR_SALE', '2026-08-01'), v('l2', 'READY_FOR_SALE', '2026-09-01')])?.id).toBe('l2');
|
|
expect(reviewVersion([v('a', 'PREPARE_FOR_SUBMISSION', '2026-08-01'), v('b', 'WAITING_FOR_REVIEW', '2026-09-01')])?.id).toBe('b');
|
|
expect(reviewVersion([])).toBeNull();
|
|
});
|
|
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('subscription levels', () => {
|
|
test('App Store groupLevel is imported per subscription (live shape), the Play-only one gets the next free level', async () => {
|
|
ascEnv(); playEnv();
|
|
const p = liveProject();
|
|
liveStores({ subs: true });
|
|
const r = await capture(() => onboard(p.ctx(['--write', '--no-art'], ['write'])));
|
|
expect(r.exit).toBeNull();
|
|
const c = (await loadConfig(p.loaded.path)).config;
|
|
const g = c.subscriptions!.find(x => x.name === 'Premium')!;
|
|
expect(g.subscriptions.map(x => `${x.id} ${x.level}`)).toEqual(['com.example.notes.premium.monthly 2', 'com.example.notes.premium.yearly 1']);
|
|
});
|
|
test('Play-only and tied levels: price per month (higher = level 1), then the longer period; never duplicated, with a note', () => {
|
|
const play: StoreItem[] = [
|
|
{ id: 'a.basic', kind: 'subscription', period: 'P1M', locales: ['en-US'], prices: [{ region: 'US', currency: 'USD', price: 2.99 }] },
|
|
{ id: 'a.pro', kind: 'subscription', period: 'P1M', locales: ['en-US'], prices: [{ region: 'US', currency: 'USD', price: 9.99 }] },
|
|
{ id: 'a.pro.year', kind: 'subscription', period: 'P1Y', locales: ['en-US'], prices: [{ region: 'US', currency: 'USD', price: 119.88 }] },
|
|
];
|
|
const playTexts = Object.fromEntries(play.map(x => [x.id, { 'en-US': { title: x.id, description: 'd' } }]));
|
|
const cfg = { ...BASE, apple: undefined, subscriptions: [{ name: 'Subscriptions', listings: { en: { name: 'Subscriptions' } }, subscriptions: [
|
|
{ id: 'a.basic', period: 'P1M' as const, level: 1, usd: 2.99, listings: { en: { title: 'a.basic', description: 'd' } } },
|
|
{ id: 'a.pro', period: 'P1M' as const, level: 1, usd: 9.99, listings: { en: { title: 'a.pro', description: 'd' } } }] }] };
|
|
const r = importCatalog(cfg, { apple: null, play, playTexts });
|
|
expect(r.subscriptions[0]!.subscriptions.map(x => `${x.id} ${x.level}`).sort()).toEqual(['a.basic 3', 'a.pro 2', 'a.pro.year 1']);
|
|
expect(r.notes.filter(n => n.msg.includes('ordered by price per month')).length).toBe(3);
|
|
// ties on the App Store are broken the same way
|
|
const apple: StoreItem[] = play.map(x => ({ ...x, level: 1, group: 'Subscriptions' }));
|
|
const t = importCatalog({ ...cfg, apple: BASE.apple, play: undefined }, { apple, play: null, appleTexts: playTexts });
|
|
expect(t.subscriptions[0]!.subscriptions.map(x => `${x.id} ${x.level}`).sort()).toEqual(['a.basic 3', 'a.pro 2', 'a.pro.year 1']);
|
|
expect(t.notes.some(n => n.msg.includes('groupLevel 1 is shared'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
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', '--no-art'], ['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('a newer draft with an empty review detail: the live version demo account is imported and sign-in is required', async () => {
|
|
ascEnv(); playEnv();
|
|
const p = liveProject();
|
|
liveStores({ draft: true });
|
|
const r = await capture(() => onboard(p.ctx(['--write', '--no-art'], ['write'])));
|
|
expect(r.out + r.err).not.toContain('demo-secret-sentinel');
|
|
expect(r.out + r.err).toContain('sign-in for review: required (App Store review detail: demo account required (a demo account is set there))');
|
|
const creds = JSON.parse(readFileSync(join(p.root, '.storebound', 'credentials.json'), 'utf8'));
|
|
expect(creds.reviewDemoAccount).toEqual({ username: 'demo-reviewer', password: 'demo-secret-sentinel' });
|
|
expect((await loadConfig(p.loaded.path)).config.reviewAccess).toEqual({ signInRequired: true });
|
|
});
|
|
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(['--no-art'])));
|
|
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;
|