storebound/tests/subscriptions-drift.test.ts

148 lines
9.3 KiB
TypeScript

// Existing subscription items that differ from the config are shown in the dry run and updated (text) or held behind the
// price gate (money). Pure helpers of cli/lib/subscriptions.ts with neutral data.
import { describe, expect, test } from 'bun:test';
import type { Subscription } from '../cli/lib/config';
import { toMoney } from '../cli/lib/money';
import {
appleAvailabilityDiff, appleGroupLocDiff, appleIntroDiff, appleIntroOffers, appleLocDiff, appleSubAttrDiff, appleSubscriptionBody,
type PlayOfferLike, playOfferBody, playOfferDiff, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions, playSubscriptionBody, playSubTextChanges,
} from '../cli/lib/subscriptions';
const LANGS = [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'de', apple: 'de-DE', play: 'de-DE' }] as never;
const s: Subscription = {
id: 'com.example.app.pro_monthly', period: 'P1M', level: 1, usd: 4.99, gracePeriod: 'P7D', familySharable: false, reviewNote: 'Sign in with the demo account',
listings: { en: { title: 'Pro', description: 'All features', benefits: ['A'] }, de: { title: 'Pro', description: 'Alle Funktionen' } },
intro: { type: 'free', duration: 'P1W' },
};
const conv = { US: { regionCode: 'US', price: toMoney('USD', 4.99) }, TR: { regionCode: 'TR', price: toMoney('TRY', 99.99) } };
const other = { usdPrice: toMoney('USD', 4.99), eurPrice: toMoney('EUR', 4.99) };
const want = (): PlaySubscription => playSubscriptionBody('com.example.app', s, LANGS, playSubRegions(s, conv), other);
const live = (): PlaySubscription => { const c = JSON.parse(JSON.stringify(want())) as PlaySubscription; c.basePlans![0]!.state = 'ACTIVE'; return c; };
describe('apple subscription attributes', () => {
const ok = { name: 'Pro', subscriptionPeriod: 'ONE_MONTH', familySharable: false, reviewNote: 'Sign in with the demo account', groupLevel: 1 };
test('equal: nothing', () => {
expect(appleSubAttrDiff(s, ok)).toEqual({ patch: {}, text: [], report: [] });
expect(appleSubAttrDiff(s, {})).toEqual({ patch: {}, text: [], report: [] });
});
test('name, review note and level are patched; period and family sharing only reported', () => {
const d = appleSubAttrDiff(s, { ...ok, name: 'Old', reviewNote: null, groupLevel: 2, subscriptionPeriod: 'ONE_YEAR', familySharable: true });
expect(d.patch).toEqual({ name: 'Pro', reviewNote: 'Sign in with the demo account', groupLevel: 1 });
expect(d.report.length).toBe(2);
expect(d.report[0]).toContain('ONE_YEAR');
expect(d.report[1]).toContain('familySharable');
expect(Object.keys(appleSubscriptionBody('g', s).data.attributes)).toContain('reviewNote');
});
test('a config without referenceName compares with the first listing title', () => {
expect(appleSubAttrDiff(s, { name: 'Pro' }).patch).toEqual({});
expect(appleSubAttrDiff({ ...s, referenceName: 'Pro monthly' }, { name: 'Pro' }).patch).toEqual({ name: 'Pro monthly' });
});
});
describe('apple localizations', () => {
test('subscription text', () => {
expect(appleLocDiff({ title: 'Pro', description: 'All features' }, { name: 'Pro', description: 'All features' })).toBeNull();
expect(appleLocDiff({ title: 'Pro', description: 'All features' }, { name: 'Pro', description: 'Old' })).toEqual({ description: 'All features' });
expect(appleLocDiff({ title: 'Pro', description: 'x' }, {})).toBeNull();
});
test('group name and customAppName (only when configured)', () => {
expect(appleGroupLocDiff({ name: 'Pro' }, { name: 'Pro', customAppName: 'Anything' })).toBeNull();
expect(appleGroupLocDiff({ name: 'Pro', customAppName: 'Example' }, { name: 'Old', customAppName: null })).toEqual({ name: 'Pro', customAppName: 'Example' });
});
});
describe('apple availability', () => {
test('missing territories and new territories off', () => {
expect(appleAvailabilityDiff({ territories: ['USA', 'TUR'], availableInNewTerritories: true }, ['USA', 'TUR'])).toBeNull();
expect(appleAvailabilityDiff({ territories: ['USA'], availableInNewTerritories: true }, ['USA', 'TUR', 'DEU'])).toEqual({ missing: ['TUR', 'DEU'], newTerritories: false });
expect(appleAvailabilityDiff({ territories: ['USA'], availableInNewTerritories: false }, ['USA'])).toEqual({ missing: [], newTerritories: true });
});
});
describe('apple intro offers', () => {
const resp = (mode: string, duration: string, periods: number, price?: string) => ({
data: [{ id: 'i1', attributes: { offerMode: mode, duration, numberOfPeriods: periods },
relationships: { territory: { data: { id: 'USA' } }, ...(price ? { subscriptionPricePoint: { data: { id: 'pp1' } } } : {}) } }],
included: price ? [{ id: 'pp1', type: 'subscriptionPricePoints', attributes: { customerPrice: price } }] : [],
});
test('equal or no offer yet: nothing', () => {
expect(appleIntroDiff(s, appleIntroOffers(resp('FREE_TRIAL', 'ONE_WEEK', 1)))).toEqual([]);
expect(appleIntroDiff(s, [])).toEqual([]);
});
test('differences are reported as "intro differs"', () => {
const d = appleIntroDiff(s, appleIntroOffers(resp('PAY_UP_FRONT', 'ONE_MONTH', 1)));
expect(d.length).toBe(2);
expect(d.every(x => x.startsWith('intro differs: '))).toBe(true);
const paid: Subscription = { ...s, intro: { type: 'pay-as-you-go', periods: 3, usd: 1.99 } };
expect(appleIntroDiff(paid, appleIntroOffers(resp('PAY_AS_YOU_GO', 'ONE_MONTH', 3, '0.99')))).toEqual([expect.stringContaining('price USA 0.99')]);
expect(appleIntroDiff({ ...s, intro: undefined }, appleIntroOffers(resp('FREE_TRIAL', 'ONE_WEEK', 1)))[0]).toContain('config has none');
});
});
describe('play subscription diff', () => {
test('equal: the old shape, no extra fields', () => {
expect(playSubDiff(live(), want())).toEqual({ create: false, listings: [], basePlan: 'same', priceChanges: [], activate: false });
});
test('other regions USD / EUR price is a gated price change', () => {
const cur = live();
cur.basePlans![0]!.otherRegionsConfig = { usdPrice: toMoney('USD', 3.99), eurPrice: toMoney('EUR', 4.99) };
const d = playSubDiff(cur, want());
expect(d.basePlan).toBe('changed');
expect(d.priceChanges).toEqual([{ regionCode: 'other regions', from: 'USD 399 / EUR 499', to: 'USD 499 / EUR 499' }]);
});
test('a region sold on Play but not in the config is an owner-gated "not sold" row', () => {
const cur = live();
cur.basePlans![0]!.regionalConfigs!.push({ regionCode: 'JP', newSubscriberAvailability: true, price: toMoney('JPY', 750) });
const d = playSubDiff(cur, want());
expect(d.droppedRegions).toEqual(['JP']);
expect(d.priceChanges).toEqual([{ regionCode: 'JP', from: 'JPY 75000', to: 'not sold' }]);
expect(d.basePlan).toBe('changed');
});
test('grace period is its own line and needs no price gate', () => {
const cur = live();
cur.basePlans![0]!.autoRenewingBasePlanType!.gracePeriodDuration = 'P3D';
const d = playSubDiff(cur, want());
expect(d.grace).toEqual({ from: 'P3D', to: 'P7D' });
expect(d.priceChanges).toEqual([]);
expect(playSubTextChanges(d)).toEqual(['grace P3D -> P7D']);
// a config without a grace period leaves Play's alone
const noGrace = want();
delete noGrace.basePlans![0]!.autoRenewingBasePlanType!.gracePeriodDuration;
expect(playSubDiff(cur, noGrace).grace).toBeUndefined();
});
test('languages on Play but not in the config are reported and kept unless pruned', () => {
const cur = live();
cur.listings!.push({ languageCode: 'fr-FR', title: 'Pro', description: 'Tout' });
const d = playSubDiff(cur, want());
expect(d.listingsRemoved).toEqual(['fr-FR']);
expect(d.listings).toEqual([]);
expect(playSubTextChanges(d, true)).toEqual(['listings removed: fr-FR']);
expect(playSubTextChanges(d)[0]).toContain('kept on Play');
expect(playPatchSubscription(cur, want()).listings!.map(l => l.languageCode)).toContain('fr-FR');
expect(playPatchSubscription(cur, want(), { pruneLanguages: true }).listings!.map(l => l.languageCode)).not.toContain('fr-FR');
});
});
describe('play offers that exist', () => {
const regions = [{ regionCode: 'US', price: toMoney('USD', 4.99) }, { regionCode: 'TR', price: toMoney('TRY', 99.99) }];
const body = (sub: Subscription) => playOfferBody('com.example.app', sub, regions) as unknown as PlayOfferLike;
test('equal: nothing', () => {
expect(playOfferDiff(JSON.parse(JSON.stringify(body(s))), body(s))).toEqual({ phase: [], prices: [] });
});
test('duration and periods are phase (text) changes', () => {
const cur = body({ ...s, intro: { type: 'free', duration: 'P1M' } });
const d = playOfferDiff(cur, body(s));
expect(d.phase).toEqual(['duration P1M -> P1W']);
expect(d.prices).toEqual([]);
});
test('free to discounted is a phase change, a new discount percentage is money', () => {
const payg: Subscription = { ...s, intro: { type: 'pay-as-you-go', periods: 2, usd: 2.49 } };
const cur = body(s);
const d = playOfferDiff(cur, body(payg));
expect(d.phase.some(x => x.includes('free -> -50%'))).toBe(true);
expect(d.phase.some(x => x.startsWith('periods 1 -> 2'))).toBe(true);
const cheaper = playOfferDiff(body(payg), body({ ...payg, intro: { type: 'pay-as-you-go', periods: 2, usd: 1.99 } }));
expect(cheaper.phase).toEqual([]);
expect(cheaper.prices.map(p => `${p.region} ${p.from} -> ${p.to}`)).toContain('US -50% -> -60%');
});
});