// 0.5.0: app profiles, init --profile, review access / demo account, account deletion and profile checks, app // screenshot style, subscriptions (Apple + Play request builders, plans, diffs), offers and offer codes, catalog diff. import { describe, expect, test } from 'bun:test'; import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parseArgs } from '../cli/lib/args'; import { loadConfig, platformsOf, profileOf, sellsOn, type ShipConfig, shotsStyle, type Subscription, validateConfig } from '../cli/lib/config'; import { maskSecret, maskUser, reviewDemoAccount } from '../cli/lib/credentials'; import { dataSafetyRows } from '../cli/lib/data-safety'; import { profileIssues } from '../cli/lib/profile'; import { NOTES_MAX_BYTES, reviewDetailPlan, reviewNotes } from '../cli/lib/review-details'; import { appleAvailabilityBody, appleCustomCodeBody, appleGroupBody, appleGroupLocBody, appleIntroPatch, appleOfferCodeBody, applePoints, applePriceChanges, applePricesPatch, applePromoOfferBody, appleSubLocBody, appleSubPrices, appleSubscriptionBody, appleSubscriptionPlan, appleWantedPrices, basePlanId, pickPoint, playOfferBody, playPatchSubscription, playSubDiff, playSubRegions, playSubscriptionBody, validateSubscriptions, } from '../cli/lib/subscriptions'; import { appleSubscriptionRules, playRules, type PlaySnapshot, type ProfileFacts, profileRules } from '../cli/lib/validate-rules'; import { catalogDiff, fromApplePeriod, fromAppleType, regionOf, type StoreItem, wantedCatalog } from '../cli/lib/catalog'; import { APP_PALETTES, palette, shotFonts } from '../cli/shots/lib'; import { FILES, run as init, templateFor } from '../cli/init'; import { APPLE_IAP_TYPE } from '../cli/apple/iap'; import { listTools, TOOLS, toArgv } from '../cli/mcp-tools'; import { toMoney } from '../cli/lib/money'; import { readSkills } from '../tools/gen-adapters'; const ROOT = join(import.meta.dir, '..'); const tmp = () => mkdtempSync(join(tmpdir(), 'storebound-apps-')); const APP = { name: 'X', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'a@example.com' }; const LANGS = [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'tr', apple: 'tr', play: 'tr-TR' }]; const base = (o: Partial = {}): ShipConfig => ({ app: { ...APP }, languages: LANGS, apple: { bundleId: 'com.example.app' }, play: { packageName: 'com.example.app' }, ...o }); const sub = (o: Partial = {}): Subscription => ({ id: 'com.example.app.pro_monthly', period: 'P1M', level: 1, usd: 3.99, listings: { en: { title: 'Pro monthly', description: 'Unlimited lists.', benefits: ['Unlimited lists'] }, tr: { title: 'Pro aylık', description: 'Sınırsız liste.' } }, ...o, }); const group = (subs: Subscription[]) => ({ name: 'Pro', listings: { en: { name: 'Pro' } }, subscriptions: subs }); describe('profiles and app identity', () => { test('profile, platforms and style defaults keep old configs as games', () => { expect(profileOf(base())).toBe('game'); expect(shotsStyle(base())).toBe('game'); expect(shotsStyle(base({ app: { ...APP, profile: 'app' } }))).toBe('app'); expect(shotsStyle(base({ app: { ...APP, profile: 'subscription-app' }, shots: { style: 'game' } as ShipConfig['shots'] }))).toBe('game'); expect(platformsOf(base())).toEqual(['ios', 'android']); expect(platformsOf(base({ play: undefined }))).toEqual(['ios']); const c = base({ app: { ...APP, platforms: ['android'] } }); expect(sellsOn(c, {}, 'apple')).toBe(false); expect(sellsOn(c, {}, 'play')).toBe(true); expect(sellsOn(base(), { platforms: ['ios'] }, 'play')).toBe(false); }); test('validateConfig: profile, terms, accounts, review access, platforms, non_renewing', () => { expect(validateConfig(base({ app: { ...APP, profile: 'app', termsUrl: 'https://example.com/t', platforms: ['ios', 'android'] }, accounts: { enabled: true, deletionUrl: 'https://example.com/d' }, reviewAccess: { signInRequired: true }, products: [{ id: 'com.example.app.pass', type: 'non_renewing', usd: 9.99, listings: { en: { title: 'Pass', description: '30 days' } }, platforms: ['ios'] }] }))).toEqual([]); const e = validateConfig({ ...base({ play: undefined }), app: { ...APP, profile: 'tool', termsUrl: 'http://x', platforms: ['android'] }, accounts: { enabled: 'yes', deletionUrl: 'ftp://x' }, reviewAccess: { signInRequired: 1 } } as unknown); for (const m of ['app.profile', 'app.termsUrl', 'app.platforms: android needs a play section', 'accounts.enabled', 'accounts.deletionUrl', 'reviewAccess.signInRequired']) expect(e.join('\n')).toContain(m); }); test('Apple IAP types include non-renewing', () => { expect(APPLE_IAP_TYPE).toEqual({ consumable: 'CONSUMABLE', non_consumable: 'NON_CONSUMABLE', non_renewing: 'NON_RENEWING_SUBSCRIPTION' }); }); }); describe('init --profile', () => { test('every profile template exists and the configs load', async () => { for (const p of ['app', 'game', 'subscription-app'] as const) for (const [f] of FILES) expect(existsSync(templateFor(p, f))).toBe(true); expect(templateFor('game', 'storebound.config.ts')).toBe(join(ROOT, 'templates', 'storebound.config.ts')); expect(templateFor('subscription-app', 'privacy-policy.html')).toBe(join(ROOT, 'templates', 'profiles', 'app', 'privacy-policy.html')); const app = (await loadConfig(templateFor('app', 'storebound.config.ts'))).config; expect(app.app.profile).toBe('app'); expect(app.accounts?.enabled).toBe(true); expect(app.shots?.style).toBe('app'); expect(app.products).toBeUndefined(); const subs = (await loadConfig(templateFor('subscription-app', 'storebound.config.ts'))).config; expect(subs.app.profile).toBe('subscription-app'); expect(subs.app.termsUrl).toMatch(/^https:/); expect(subs.subscriptions?.[0]?.subscriptions.length).toBe(2); expect(Object.keys(subs.dataSafety!.data)).toContain('PSL_PURCHASE_HISTORY'); const game = (await loadConfig(templateFor('game', 'storebound.config.ts'))).config; expect(game.app.profile).toBe('game'); expect(game.ads).toBeUndefined(); expect(Object.keys(game.dataSafety!.data)).toContain('PSL_DEVICE_ID'); }); test('templates say they are starting points and the subscription copy discloses renewal and terms', () => { for (const p of ['app', 'subscription-app'] as const) { expect(readFileSync(templateFor(p, 'declarations.md'), 'utf8')).toContain('Edit every line'); expect(readFileSync(templateFor(p, 'app-privacy.md'), 'utf8')).toContain('Edit to match your app'); } for (const f of ['listing.md', 'appstore.md']) { const t = readFileSync(templateFor('subscription-app', f), 'utf8'); expect(t).toContain('renews automatically'); expect(t).toContain('https://example.com/terms'); } }); test('default profile is app; --profile game writes the game templates; unknown profile fails', async () => { const a = tmp(); await init(parseArgs(['--dir', a])); expect(readFileSync(join(a, 'storebound.config.ts'), 'utf8')).toContain("profile: 'app'"); expect(readFileSync(join(a, 'store/declarations.md'), 'utf8')).toContain('utility / productivity app'); expect(readFileSync(join(a, '.gitignore'), 'utf8')).toContain('.storebound/'); const g = tmp(); await init(parseArgs(['--dir', g, '--profile', 'game'])); expect(readFileSync(join(g, 'storebound.config.ts'), 'utf8')).toContain("profile: 'game'"); expect(readFileSync(join(g, 'store/declarations.md'), 'utf8')).toContain('free-to-play game'); await expect(init(parseArgs(['--dir', tmp(), '--profile', 'tool']))).rejects.toThrow('--profile tool'); }); }); describe('review access and demo account', () => { test('demo account from env or .storebound, masked', () => { const d = tmp(); expect(reviewDemoAccount(d)).toBeNull(); mkdirSync(join(d, '.storebound')); writeFileSync(join(d, '.storebound', 'credentials.json'), JSON.stringify({ reviewDemoAccount: { username: 'demo@example.com', password: 'pw-123' } })); expect(reviewDemoAccount(d)).toEqual({ username: 'demo@example.com', password: 'pw-123' }); process.env.REVIEW_DEMO_USER = 'env-user'; process.env.REVIEW_DEMO_PASSWORD = 'env-pw'; try { expect(reviewDemoAccount(d)).toEqual({ username: 'env-user', password: 'env-pw' }); } finally { delete process.env.REVIEW_DEMO_USER; delete process.env.REVIEW_DEMO_PASSWORD; } expect(maskUser('demo@example.com')).toBe('d***@example.com'); expect(maskUser('reviewer')).toBe('r***'); expect(maskSecret('pw-123')).toBe('********'); }); test('review detail attributes: required demo account, notes with sign-in and deletion, never a secret in the summary', () => { const contact = { contactFirstName: 'A', contactLastName: 'B', contactPhone: '+1 555 0100', contactEmail: 'r@example.com' }; const cfg = { apple: { bundleId: 'com.example.app', info: { reviewNotes: 'Lists app.' } }, reviewAccess: { signInRequired: true, instructions: 'Use the demo account.' }, accounts: { enabled: true, inApp: 'Settings > Account > Delete account' } }; expect(() => reviewDetailPlan(cfg, contact, null)).toThrow('reviewDemoAccount'); const p = reviewDetailPlan(cfg, contact, { username: 'demo@example.com', password: 'secret-pw' }); expect(p.attributes).toMatchObject({ demoAccountRequired: true, demoAccountName: 'demo@example.com', demoAccountPassword: 'secret-pw', contactEmail: 'r@example.com' }); expect(p.attributes.notes).toBe('Lists app.\n\nSign-in: Use the demo account.\n\nAccount deletion: Settings > Account > Delete account'); expect(p.summary).not.toContain('secret-pw'); expect(p.summary).not.toContain('demo@example.com'); const none = reviewDetailPlan({ apple: cfg.apple }, contact, { username: 'x', password: 'y' }); expect(none.attributes).toMatchObject({ demoAccountRequired: false, demoAccountName: null, demoAccountPassword: null }); expect(reviewNotes({ apple: { bundleId: 'a.b', info: { reviewNotes: 'x' } } })).toBe('x'); expect(() => reviewDetailPlan({ apple: { bundleId: 'a.b', info: { reviewNotes: 'é'.repeat(NOTES_MAX_BYTES / 2 + 1) } } }, contact, null)).toThrow('bytes'); }); }); describe('profile checks (offline)', () => { test('accounts need a deletion URL and consistent Data safety; sign-in needs a demo account; subscriptions need terms', () => { const ds = { collectsData: true, encryptedInTransit: true, data: {} }; const msgs = profileIssues(base({ accounts: { enabled: true }, reviewAccess: { signInRequired: true }, dataSafety: ds, subscriptions: [group([sub()])] }), { demoAccount: false }); const text = msgs.map(m => `${m.level} ${m.msg}`).join('\n'); for (const m of ['error accounts.enabled: set accounts.deletionUrl', 'warn accounts.enabled: set accounts.inApp', 'PSL_ACM_NONE', 'error App Review needs a demo account', 'error subscriptions: set app.termsUrl', 'without reviewScreenshot']) expect(text).toContain(m); expect(profileIssues(base({ app: { ...APP, termsUrl: 'https://example.com/t' }, accounts: { enabled: true, deletionUrl: 'https://example.com/d', inApp: 'Settings' }, reviewAccess: { signInRequired: true, instructions: 'x' }, subscriptions: [group([sub({ reviewScreenshot: 'p.png' })])], dataSafety: { ...ds, accountCreation: ['PSL_ACM_USER_ID_PASSWORD'] } }), { demoAccount: true })).toEqual([]); }); test('profileRules (validate): Play needs a reachable delete-account page, Apple needs the demo account on App Review, terms linked', () => { const ok = { url: 'https://example.com/d', status: 200 }; const facts: ProfileFacts = { profile: 'subscription-app', accounts: true, inAppDeletion: false, deletionUrl: null, signInRequired: true, demoAccountLocal: false, subscriptions: true, termsUrl: null }; const play = profileRules('play', facts, {}); expect(play.filter(f => f.level === 'blocker').map(f => f.code)).toEqual(['account-deletion', 'demo-account', 'terms-url']); const apple = profileRules('apple', { ...facts, demoAccountLocal: true, termsUrl: { url: 'https://example.com/t', status: 200 } }, { reviewDetail: { demoAccountRequired: false }, descriptions: [{ locale: 'en-US', text: 'Terms of Use: https://example.com/t' }, { locale: 'tr', text: 'Koşullar' }] }); expect(apple.map(f => `${f.level}:${f.code}`)).toEqual(['warn:account-deletion', 'blocker:demo-account', 'ok:terms-url', 'warn:terms-url']); expect(profileRules('play', { ...facts, deletionUrl: { url: ok.url, status: 404 } }, {})[0]!.msg).toContain('HTTP 404'); expect(profileRules('play', { ...facts, signInRequired: false, subscriptions: false, deletionUrl: ok }, {}).map(f => f.level)).toEqual(['ok']); }); }); describe('subscriptions: config', () => { test('a valid group passes; every rule reports', () => { const langs = new Set(['en', 'tr']); expect(validateSubscriptions([group([sub({ intro: { type: 'free', duration: 'P1W' }, offers: [{ id: 'winback', type: 'pay-as-you-go', periods: 3, usd: 1.99 }], offerCodes: [{ name: 'Spring', eligibility: ['NEW', 'EXPIRED'], type: 'free', duration: 'P1M', customCodes: [{ code: 'SPRING26', count: 500, expires: '2027-05-01' }] }] })])], langs, new Set())).toEqual([]); const e = validateSubscriptions([{ name: 'G', listings: { de: { name: '' } }, subscriptions: [ sub({ id: 'Bad Id', period: 'P5M' as 'P1M', level: 0, usd: -1, basePlanId: 'Monthly', gracePeriod: 'P1D' }), sub({ id: 'com.example.app.coins', listings: { en: { title: 'x'.repeat(31), description: 'y'.repeat(46), benefits: ['a', 'b', 'c', 'd', 'e'] } }, intro: { type: 'pay-as-you-go', periods: 0, usd: 5 }, offers: [{ id: 'Intro', type: 'free' }], offerCodes: [{ name: 'c', eligibility: ['ALL' as 'NEW'], type: 'free', duration: 'P1W', customCodes: [{ code: 'lower', count: 0 }] }] }), ] }], langs, new Set(['com.example.app.coins'])); const t = e.join('\n'); for (const m of ['listings.de: not in languages', 'listings.de.name: missing', '.id: 1-40 lowercase', '.period: one of', '.level: integer from 1', '.usd: positive', '.basePlanId:', '.gracePeriod:', 'already a one-time product', 'title: over 30', 'description: over 45', 'benefits: at most 4', 'intro.periods: 1-12', 'intro.usd: must be below', 'offers[0].id:', 'offers[0].duration', 'offerCodes[0].eligibility', 'must be upper-case', 'count must be a positive integer']) expect(t).toContain(m); expect(validateConfig(base({ subscriptions: [group([sub(), sub({ level: 1 })])] })).join()).toContain('level: 1 used twice'); }); }); describe('subscriptions: App Store Connect', () => { const s = sub({ intro: { type: 'pay-as-you-go', periods: 3, usd: 0.99 }, reviewNote: 'Paywall in Settings', overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }] }); test('request bodies follow the documented types and relationships', () => { expect(appleGroupBody('app1', group([s]))).toEqual({ data: { type: 'subscriptionGroups', attributes: { referenceName: 'Pro' }, relationships: { app: { data: { type: 'apps', id: 'app1' } } } } }); expect(appleGroupLocBody('g1', 'tr', { name: 'Pro', customAppName: 'Liste' }).data.attributes).toEqual({ locale: 'tr', name: 'Pro', customAppName: 'Liste' }); expect(appleSubscriptionBody('g1', s)).toEqual({ data: { type: 'subscriptions', attributes: { name: 'Pro monthly', productId: s.id, subscriptionPeriod: 'ONE_MONTH', groupLevel: 1, familySharable: false, reviewNote: 'Paywall in Settings' }, relationships: { group: { data: { type: 'subscriptionGroups', id: 'g1' } } } } }); expect(appleSubLocBody('s1', 'en-US', s.listings.en!).data).toMatchObject({ type: 'subscriptionLocalizations', attributes: { locale: 'en-US', name: 'Pro monthly', description: 'Unlimited lists.' }, relationships: { subscription: { data: { type: 'subscriptions', id: 's1' } } } }); expect(appleAvailabilityBody('s1', ['USA', 'TUR']).data.relationships.availableTerritories.data).toEqual([{ type: 'territories', id: 'USA' }, { type: 'territories', id: 'TUR' }]); const pp = applePricesPatch('s1', [{ territory: 'USA', pointId: 'p1' }, { territory: 'TUR', pointId: 'p2', startDate: '2027-01-01' }]); expect(pp.data).toEqual({ type: 'subscriptions', id: 's1', relationships: { prices: { data: [{ type: 'subscriptionPrices', id: '${price-0}' }, { type: 'subscriptionPrices', id: '${price-1}' }] } } }); expect(pp.included[1]).toEqual({ type: 'subscriptionPrices', id: '${price-1}', attributes: { startDate: '2027-01-01', preserveCurrentPrice: true }, relationships: { subscriptionPricePoint: { data: { type: 'subscriptionPricePoints', id: 'p2' } }, territory: { data: { type: 'territories', id: 'TUR' } } } }); const ip = appleIntroPatch('s1', s, [{ territory: 'USA', pointId: 'i1' }]); expect(ip.included[0]!.attributes).toEqual({ duration: 'ONE_MONTH', offerMode: 'PAY_AS_YOU_GO', numberOfPeriods: 3, startDate: null, endDate: null }); expect(() => appleIntroPatch('s1', s, [{ territory: 'USA' }])).toThrow('needs a price point'); const free = appleIntroPatch('s1', sub({ intro: { type: 'free', duration: 'P2W' } }), [{ territory: 'USA' }]); expect(free.included[0]).toMatchObject({ attributes: { duration: 'TWO_WEEKS', offerMode: 'FREE_TRIAL', numberOfPeriods: 1 }, relationships: { territory: { data: { id: 'USA' } } } }); expect('subscriptionPricePoint' in free.included[0]!.relationships).toBe(false); }); test('promotional offers, offer codes and custom codes', () => { const promo = applePromoOfferBody('s1', s, { id: 'winback', type: 'pay-up-front', duration: 'P3M', usd: 5.99 }, [{ territory: 'USA', pointId: 'x' }]); expect(promo.data.attributes).toEqual({ name: 'winback', offerCode: 'winback', duration: 'THREE_MONTHS', offerMode: 'PAY_UP_FRONT', numberOfPeriods: 1 }); expect(promo.data.relationships.prices.data).toEqual([{ type: 'subscriptionPromotionalOfferPrices', id: '${promo-0}' }]); expect(promo.included[0]!.relationships).toEqual({ territory: { data: { type: 'territories', id: 'USA' } }, subscriptionPricePoint: { data: { type: 'subscriptionPricePoints', id: 'x' } } }); const code = appleOfferCodeBody('s1', s, { name: 'Spring', eligibility: ['NEW'], type: 'free', duration: 'P1M' }, [{ territory: 'USA' }, { territory: 'TUR' }]); expect(code.data.attributes).toEqual({ name: 'Spring', customerEligibilities: ['NEW'], offerEligibility: 'REPLACE_INTRO_OFFERS', duration: 'ONE_MONTH', offerMode: 'FREE_TRIAL', numberOfPeriods: 1 }); expect(code.included.map(x => x.type)).toEqual(['subscriptionOfferCodePrices', 'subscriptionOfferCodePrices']); expect(appleCustomCodeBody('oc1', { code: 'SPRING26', count: 100 }).data).toEqual({ type: 'subscriptionOfferCodeCustomCodes', attributes: { customCode: 'SPRING26', numberOfCodes: 100 }, relationships: { offerCode: { data: { type: 'subscriptionOfferCodes', id: 'oc1' } } } }); }); test('prices: wanted per territory, only changes, scheduled rows; reading responses', () => { const eq = [{ id: 'e1', territory: 'TUR', price: 149.99 }, { id: 'e2', territory: 'DEU', price: 4.49 }]; const wanted = appleWantedPrices(s, eq, [{ product: s.id, store: 'apple', region: 'DE', territory: 'DEU', currency: 'EUR', price: 3.99 }]); expect([...wanted]).toEqual([['USA', 3.99], ['TUR', 99.99], ['DEU', 3.99]]); expect(applePriceChanges([], wanted).map(c => c.territory)).toEqual(['USA', 'DEU', 'TUR']); const cur = [{ territory: 'USA', price: 3.99, startDate: null }, { territory: 'TUR', price: 89.99, startDate: null }, { territory: 'DEU', price: 3.99, startDate: null }]; expect(applePriceChanges(cur, wanted)).toEqual([{ territory: 'TUR', from: 89.99, to: 99.99 }]); expect(applePriceChanges(cur, new Map([['USA', 3.99]]), [{ product: s.id, store: 'apple', region: 'US', territory: 'USA', currency: 'USD', price: 4.99, startDate: '2027-02-01' }])) .toEqual([{ territory: 'USA', from: 3.99, to: 4.99, startDate: '2027-02-01' }]); const pts = applePoints({ data: [{ id: 'p1', attributes: { customerPrice: '3.99' }, relationships: { territory: { data: { id: 'USA' } } } }, { id: 'bad', attributes: {} }] }); expect(pts).toEqual([{ id: 'p1', territory: 'USA', price: 3.99 }]); expect(pickPoint(pts, 'USA', 3.99).id).toBe('p1'); expect(() => pickPoint(pts, 'USA', 4)).toThrow('nearest 3.99'); const resp = { data: [ { id: 'a', attributes: { startDate: null }, relationships: { territory: { data: { id: 'USA' } }, subscriptionPricePoint: { data: { id: 'p1' } } } }, { id: 'b', attributes: { startDate: '2026-05-01' }, relationships: { territory: { data: { id: 'USA' } }, subscriptionPricePoint: { data: { id: 'p2' } } } }, { id: 'c', attributes: { startDate: '2027-05-01' }, relationships: { territory: { data: { id: 'USA' } }, subscriptionPricePoint: { data: { id: 'p3' } } } }, ], included: [{ id: 'p1', type: 'subscriptionPricePoints', attributes: { customerPrice: '2.99' } }, { id: 'p2', type: 'subscriptionPricePoints', attributes: { customerPrice: '3.99' } }, { id: 'p3', type: 'subscriptionPricePoints', attributes: { customerPrice: '4.99' } }, { id: 'USA', type: 'territories', attributes: { currency: 'USD' } }] }; expect(appleSubPrices(resp, '2026-10-11')).toEqual([{ territory: 'USA', price: 3.99, pointId: 'p2', startDate: null, currency: 'USD' }, { territory: 'USA', price: 4.99, pointId: 'p3', startDate: '2027-05-01', currency: 'USD' }]); }); test('plan: only what is missing, in order; a subscription in another group is refused', () => { const g = { ...group([s, sub({ id: 'com.example.app.pro_yearly', period: 'P1Y', level: 2, reviewScreenshot: 'p.png', offers: [{ id: 'winback', type: 'free', duration: 'P1W' }], offerCodes: [{ name: 'Spring', eligibility: ['NEW'], type: 'free', duration: 'P1M', customCodes: [{ code: 'A1', count: 1 }] }] })]), listings: { en: { name: 'Pro' }, tr: { name: 'Pro' } } }; const empty = appleSubscriptionPlan([g], LANGS, { groups: [], subscriptions: [] }); expect(empty.map(x => x.kind)).toEqual(['group', 'group-loc', 'group-loc', 'sub', 'sub-loc', 'sub-loc', 'availability', 'prices', 'intro', 'sub', 'sub-loc', 'sub-loc', 'availability', 'prices', 'promo', 'offer-code', 'review-shot']); expect(empty.find(x => x.kind === 'offer-code')!.what).toContain('with --codes'); const done = appleSubscriptionPlan([g], LANGS, { groups: [{ id: 'g1', referenceName: 'Pro', locales: ['en-US', 'tr'] }], subscriptions: [ { id: 's1', productId: s.id, groupId: 'g1', locales: ['en-US', 'tr'], hasPrices: true, introTerritories: 175, availability: true, reviewScreenshot: false }, { id: 's2', productId: 'com.example.app.pro_yearly', groupId: 'g1', locales: ['en-US', 'tr'], hasPrices: true, introTerritories: 0, availability: true, reviewScreenshot: true, promoOffers: ['winback'], offerCodes: ['Spring'] }] }); expect(done.map(x => x.kind)).toEqual(['prices', 'prices']); expect(() => appleSubscriptionPlan([g], LANGS, { groups: [{ id: 'g1', referenceName: 'Pro', locales: [] }], subscriptions: [ { id: 's1', productId: s.id, groupId: 'other', locales: [], hasPrices: false, introTerritories: 0, availability: false, reviewScreenshot: false }] })).toThrow('another subscription group'); }); }); describe('subscriptions: Google Play', () => { const conv = { US: { regionCode: 'US', price: toMoney('USD', 3.99) }, TR: { regionCode: 'TR', price: toMoney('TRY', 129.99) }, DE: { regionCode: 'DE', price: toMoney('EUR', 4.49) } }; const other = { usdPrice: toMoney('USD', 3.99), eurPrice: toMoney('EUR', 3.99) }; const s = sub({ overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }], gracePeriod: 'P7D', intro: { type: 'free', duration: 'P1W' } }); test('regions: converted, overrides, CSV; errors for unknown regions and wrong currencies', () => { const r = playSubRegions(s, conv, [{ product: s.id, store: 'play', region: 'DE', currency: 'EUR', price: 3.99 }]); expect(r).toEqual([{ regionCode: 'DE', price: toMoney('EUR', 3.99) }, { regionCode: 'TR', price: toMoney('TRY', 99.99) }, { regionCode: 'US', price: toMoney('USD', 3.99) }]); expect(() => playSubRegions(sub({ overrides: [{ region: 'XX', currency: 'USD', price: 1 }] }), conv)).toThrow('does not sell in region XX'); expect(() => playSubRegions(s, conv, [{ product: s.id, store: 'play', region: 'DE', currency: 'USD', price: 1 }])).toThrow('in EUR'); }); test('subscription body: listings per Play code with benefits, one auto-renewing base plan', () => { const b = playSubscriptionBody('com.example.app', s, LANGS, playSubRegions(s, conv), other); expect(b.listings).toEqual([{ languageCode: 'en-US', title: 'Pro monthly', description: 'Unlimited lists.', benefits: ['Unlimited lists'] }, { languageCode: 'tr-TR', title: 'Pro aylık', description: 'Sınırsız liste.' }]); expect(b.basePlans![0]).toMatchObject({ basePlanId: 'monthly', autoRenewingBasePlanType: { billingPeriodDuration: 'P1M', gracePeriodDuration: 'P7D', resubscribeState: 'RESUBSCRIBE_STATE_ACTIVE', legacyCompatible: true }, otherRegionsConfig: { usdPrice: other.usdPrice, eurPrice: other.eurPrice, newSubscriberAvailability: true } }); expect(b.basePlans![0]!.regionalConfigs![0]).toEqual({ regionCode: 'DE', newSubscriberAvailability: true, price: toMoney('EUR', 4.49) }); expect(basePlanId(sub({ period: 'P1Y' }))).toBe('yearly'); expect(() => playSubscriptionBody('p', sub({ listings: { xx: { title: 't', description: 'd' } } }), LANGS, [], other)).toThrow('no listing language'); }); test('offers: free trial for new subscribers, relative discount, pay up front, developer-chosen offers with a tag', () => { const regions = playSubRegions(s, conv); const free = playOfferBody('p', s, regions); expect(free).toMatchObject({ offerId: 'intro', basePlanId: 'monthly', targeting: { acquisitionRule: { scope: { thisSubscription: {} } } }, phases: [{ recurrenceCount: 1, duration: 'P1W', otherRegionsConfig: { free: {} } }], otherRegionsConfig: { otherRegionsNewSubscriberAvailability: true } }); expect(free.phases[0]!.regionalConfigs[0]).toEqual({ regionCode: 'DE', free: {} }); expect(free.regionalConfigs).toEqual(regions.map(r => ({ regionCode: r.regionCode, newSubscriberAvailability: true }))); const payg = playOfferBody('p', sub({ usd: 4, intro: { type: 'pay-as-you-go', periods: 3, usd: 1 } }), regions); expect(payg.phases[0]).toMatchObject({ recurrenceCount: 3, duration: 'P1M', otherRegionsConfig: { relativeDiscount: 0.75 } }); expect(() => playOfferBody('p', sub({ intro: { type: 'pay-up-front', duration: 'P3M', usd: 5 } }), regions)).toThrow('converted offer prices'); const up = playOfferBody('p', sub({ intro: { type: 'pay-up-front', duration: 'P3M', usd: 5 } }), regions, { regions: [{ regionCode: 'US', price: toMoney('USD', 5) }], other }); expect(up.phases[0]).toMatchObject({ duration: 'P3M', regionalConfigs: [{ regionCode: 'US', price: toMoney('USD', 5) }], otherRegionsConfig: { otherRegionsPrices: other } }); const extra = playOfferBody('p', s, regions, undefined, { id: 'winback', type: 'free', duration: 'P2W' }); expect(extra).toMatchObject({ offerId: 'winback', offerTags: [{ tag: 'winback' }], phases: [{ duration: 'P2W' }] }); expect('targeting' in extra).toBe(false); }); test('diff and patch: create, up to date, price change, new listing, billing period cannot change, other base plans kept', () => { const want = playSubscriptionBody('p', s, LANGS, playSubRegions(s, conv), other); expect(playSubDiff(null, want)).toMatchObject({ create: true, basePlan: 'new', activate: true }); const cur = JSON.parse(JSON.stringify(want)); cur.basePlans[0].state = 'ACTIVE'; expect(playSubDiff(cur, want)).toEqual({ create: false, listings: [], basePlan: 'same', priceChanges: [], activate: false }); cur.basePlans[0].regionalConfigs[1].price = toMoney('TRY', 89.99); cur.listings[1].title = 'Eski'; const d = playSubDiff(cur, want); expect(d).toMatchObject({ basePlan: 'changed', listings: ['tr-TR'], priceChanges: [{ regionCode: 'TR', from: 'TRY 8999', to: 'TRY 9999' }] }); cur.basePlans[0].autoRenewingBasePlanType.billingPeriodDuration = 'P1Y'; expect(() => playSubDiff(cur, want)).toThrow('cannot change'); const legacy = { productId: s.id, basePlans: [{ basePlanId: 'old-plan', state: 'ACTIVE', autoRenewingBasePlanType: { billingPeriodDuration: 'P1W' } }, { basePlanId: 'monthly', state: 'ACTIVE', offerTags: [{ tag: 'x' }] }] }; const patch = playPatchSubscription(legacy, want); expect(patch.basePlans!.map(b => b.basePlanId)).toEqual(['old-plan', 'monthly']); expect(patch.basePlans!.some(b => 'state' in b)).toBe(false); expect(patch.basePlans![1]!.offerTags).toEqual([{ tag: 'x' }]); }); }); describe('validate: subscriptions', () => { test('App Store states, localizations, group names, review screenshots', () => { const f = appleSubscriptionRules({ expectedSubscriptions: [{ id: 'a', group: 'Pro', locales: ['en-US', 'tr'] }, { id: 'b', group: 'Pro', locales: ['en-US'] }, { id: 'c', group: 'Pro', locales: [] }], subscriptions: [ { productId: 'a', group: 'Pro', state: 'MISSING_METADATA', locales: [{ locale: 'en-US', state: 'REJECTED' }], groupLocales: [], reviewScreenshot: false }, { productId: 'b', group: 'Pro', state: 'READY_TO_SUBMIT', locales: [{ locale: 'en-US' }], groupLocales: [], reviewScreenshot: true }, ] }); const t = f.map(x => `${x.level} ${x.msg}`).join('\n'); for (const m of ['group "Pro": no localization', 'a: MISSING_METADATA', 'a: no tr localization', 'a en-US: localization rejected', 'a: no review screenshot', 'c: subscription not on App Store Connect']) expect(t).toContain(m); expect(f.every(x => x.level === 'blocker')).toBe(true); expect(appleSubscriptionRules({ expectedSubscriptions: [{ id: 'b', group: 'Pro', locales: ['en-US'] }], subscriptions: [ { productId: 'b', group: 'Pro', state: 'APPROVED', locales: [{ locale: 'en-US', state: 'APPROVED' }], groupLocales: [{ locale: 'en-US' }], reviewScreenshot: false }] })[0]!.level).toBe('ok'); }); test('Play: subscription present with an active base plan', () => { const snap: PlaySnapshot = { defaultLanguage: 'en-US', expectedLanguages: [], listings: [], privacyUrl: { url: 'https://example.com/p', status: 200 }, website: null, appAds: null, appAdsLines: [], adsLikely: false, tracks: [], products: [], expectedProducts: [], subscriptions: [{ productId: 'a', basePlans: [{ basePlanId: 'monthly', state: 'DRAFT' }] }], expectedSubscriptions: [{ id: 'a', basePlanId: 'monthly' }, { id: 'b', basePlanId: 'yearly' }, { id: 'a', basePlanId: 'yearly' }] }; const msgs = playRules(snap).filter(f => f.code === 'subscriptions').map(f => f.msg); expect(msgs).toEqual(['a: base plan monthly is DRAFT', 'b: subscription not on Play', 'a: no base plan yearly']); }); }); describe('catalog diff', () => { const cfg = base({ app: { ...APP, platforms: ['ios', 'android'] }, products: [ { id: 'com.example.app.pro', type: 'non_consumable', usd: 4.99, overrides: [{ region: 'TR', currency: 'TRY', price: 149.99 }], listings: { en: { title: 'Pro', description: 'x' } } }, { id: 'com.example.app.pass', type: 'non_renewing', usd: 9.99, listings: { en: { title: 'Pass', description: 'x' } }, platforms: ['ios'] }, ], subscriptions: [group([sub({ intro: { type: 'free', duration: 'P1W' }, offerCodes: [{ name: 'Spring', eligibility: ['NEW'], type: 'free', duration: 'P1M' }] })])], }); const want = wantedCatalog(cfg); test('wanted catalog: kinds, stores, locales, offers', () => { expect(want.map(w => [w.id, w.kind, w.stores.join('+')])).toEqual([['com.example.app.pro', 'non_consumable', 'apple+play'], ['com.example.app.pass', 'non_renewing', 'apple'], ['com.example.app.pro_monthly', 'subscription', 'apple+play']]); expect(want[2]!.locales).toEqual({ apple: ['en-US', 'tr'], play: ['en-US', 'tr-TR'] }); expect(want[2]!.offers).toEqual({ apple: ['intro', 'code:Spring'], play: ['intro'] }); expect(fromApplePeriod('SIX_MONTHS')).toBe('P6M'); expect(fromAppleType('NON_RENEWING_SUBSCRIPTION')).toBe('non_renewing'); expect(regionOf('TUR')).toBe('TR'); }); test('missing, extra, kind, period, prices, locales, offers and cross-store price differences', () => { const p = (region: string, currency: string, price: number) => ({ region, currency, price }); const apple: StoreItem[] = [ { id: 'com.example.app.pro', kind: 'non_consumable', state: 'APPROVED', locales: ['en-US'], prices: [p('US', 'USD', 4.99), p('TR', 'TRY', 149.99), p('DE', 'EUR', 5.99)] }, { id: 'com.example.app.pro_monthly', kind: 'subscription', period: 'P1M', level: 2, locales: ['en-US'], prices: [p('US', 'USD', 3.99)], offers: ['intro'] }, { id: 'com.example.app.old', kind: 'consumable', state: 'REMOVED_FROM_SALE', locales: [], prices: [] }, ]; const play: StoreItem[] = [ { id: 'com.example.app.pro', kind: 'one_time', state: 'ACTIVE', locales: ['en-US'], prices: [p('US', 'USD', 4.99), p('TR', 'TRY', 129.99), p('DE', 'EUR', 4.99)] }, { id: 'com.example.app.pro_monthly', kind: 'subscription', period: 'P1Y', locales: ['en-US', 'tr-TR'], prices: [p('US', 'USD', 4.99)], offers: ['intro'] }, ]; const { rows, parity } = catalogDiff(want, { apple, play }); const t = rows.map(r => `${r.level} ${r.store} ${r.id}: ${r.msg}`); for (const m of ['error apple com.example.app.pass: not on App Store Connect', 'error play com.example.app.pro: TR: 129.99 TRY on the store, 149.99 TRY in the config', 'error play com.example.app.pro_monthly: bills every P1Y on the store, P1M in the config', 'error play com.example.app.pro_monthly: US 4.99 USD on the store, 3.99 in the config', 'warn apple com.example.app.pro_monthly: group level 2 on the store, 1 in the config', 'warn apple com.example.app.pro_monthly: no localization for tr', 'warn apple com.example.app.pro_monthly: offers missing: code:Spring', 'warn apple com.example.app.old: on the store but not in the config', 'ok apple com.example.app.pro:']) expect(t.some(x => x.startsWith(m))).toBe(true); expect(t.some(x => x.includes('play com.example.app.pass'))).toBe(false); expect(rows[0]!.level).toBe('error'); expect(parity).toEqual([{ id: 'com.example.app.pro_monthly', region: 'US', currency: 'USD', apple: 3.99, play: 4.99, diffPct: 20 }, { id: 'com.example.app.pro', region: 'DE', currency: 'EUR', apple: 5.99, play: 4.99, diffPct: 16.7 }, { id: 'com.example.app.pro', region: 'TR', currency: 'TRY', apple: 149.99, play: 129.99, diffPct: 13.3 }]); expect(catalogDiff(want, { apple, play }, { threshold: 0.15 }).parity.map(x => x.region)).toEqual(['US', 'DE']); expect(catalogDiff(want, { apple: null, play: [{ id: 'com.example.app.pro', kind: 'subscription', locales: [], prices: [] }] }).rows[0]).toMatchObject({ level: 'error', code: 'kind' }); }); }); describe('app screenshot style, data safety extras, tools, skills', () => { test('app palettes and fonts', () => { expect(palette(undefined, 'app')).toBe(APP_PALETTES.mist!); expect(palette('ocean', 'app').ink).toBe('#ffffff'); expect(palette('fire', 'app').bg[0]).toBe('#ff3d2e'); expect(palette('mist')).toBe(APP_PALETTES.mist!); expect(() => palette('nope', 'app')).toThrow('mist'); expect(shotFonts(undefined, 'app')).toEqual({ title: 'Inter', body: 'Inter', titleVariable: true }); expect(shotFonts({ fonts: { title: 'Lilita One' } }, 'game')).toEqual({ title: 'Lilita One', body: 'Montserrat', titleVariable: false }); for (const f of ['template-app.html', 'feature-app.html']) expect(readFileSync(join(ROOT, 'cli', 'shots', f), 'utf8')).toContain("document.body.dataset.ready = '1'"); expect(readFileSync(join(ROOT, 'cli', 'shots', 'template-app.html'), 'utf8')).not.toContain('class="spark"'); }); test('Data safety extra rows are appended as given', () => { const rows = dataSafetyRows({ collectsData: true, encryptedInTransit: true, accountCreation: ['PSL_ACM_USER_ID_PASSWORD'], data: {}, extra: [{ question: 'PSL_EXAMPLE_URL', value: 'https://example.com/delete-account' }] }); expect(rows.at(-1)).toEqual(['PSL_EXAMPLE_URL', '', 'https://example.com/delete-account', 'MAYBE_REQUIRED', 'PSL_EXAMPLE_URL']); expect(validateConfig(base({ dataSafety: { collectsData: true, encryptedInTransit: true, data: {}, extra: [{ question: 'bad id', value: 'x' }] } })).join()).toContain('dataSafety.extra[0]'); }); test('MCP: subscriptions are dry-run write tools, catalog diff is read-only', () => { const t = (n: string) => TOOLS.find(x => x.name === n)!; expect(toArgv(t('apple_subscriptions'), { only: 'a', csv: 'store/prices.csv' }).argv).toEqual(['apple', 'subscriptions', '--only', 'a', '--csv', 'store/prices.csv', '--dry']); expect(toArgv(t('play_subscriptions'), { dry_run: false }).argv).toEqual(['play', 'subscriptions']); expect(toArgv(t('catalog_diff'), { store: 'play', threshold: 5, json: true })).toEqual({ argv: ['catalog', 'diff', '--store', 'play', '--threshold', '5', '--json'], dryRun: false }); expect(listTools().find(x => x.name === 'catalog_diff')!.annotations).toMatchObject({ readOnlyHint: true }); for (const x of TOOLS) expect(toArgv(x, { dry_run: false }).argv).not.toContain('--codes'); }); test('new skills exist and the old ads skill name still resolves', () => { const names = readSkills(ROOT).map(s => s.name); for (const n of ['account-deletion', 'review-access', 'store-subscriptions', 'ads-mediation-checks', 'ads-levelplay-checks']) expect(names).toContain(n); expect(readFileSync(join(ROOT, 'skills', 'ads-levelplay-checks', 'SKILL.md'), 'utf8')).toContain('ads-mediation-checks'); }); });