409 lines
37 KiB
TypeScript
409 lines
37 KiB
TypeScript
// 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> = {}): ShipConfig => ({ app: { ...APP }, languages: LANGS, apple: { bundleId: 'com.example.app' }, play: { packageName: 'com.example.app' }, ...o });
|
|
|
|
const sub = (o: Partial<Subscription> = {}): 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');
|
|
});
|
|
});
|