331 lines
20 KiB
TypeScript
331 lines
20 KiB
TypeScript
// RevenueCat adapter: config -> wanted RevenueCat state, package identifiers, diff, sync plan order, and the command
|
|
// against a mocked fetch (paginated reads, --dry and no-TTY send no POST, the key never shows up). Response shapes are
|
|
// written by hand from https://www.revenuecat.com/docs/api-v2 (placeholder ids, no real project).
|
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import { mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { parseArgs } from '../cli/lib/args';
|
|
import type { Loaded, ShipConfig } from '../cli/lib/config';
|
|
import { outOfSync, RC_PACKAGE, rcDiff, rcPlan, type RcState, resolveApps, wantedRevenueCat } from '../cli/lib/revenuecat';
|
|
import { rcClient, run } from '../cli/revenuecat';
|
|
|
|
const KEY = 'sk_test_placeholder_secret_value';
|
|
const PROJECT = 'proj_example1';
|
|
const APPS = { apple: 'app_apple1', play: 'app_play1' };
|
|
|
|
const listing = (title: string) => ({ en: { title, description: 'd' } });
|
|
function config(over: Partial<ShipConfig> = {}): ShipConfig {
|
|
return {
|
|
app: { name: 'Example', privacyPolicyUrl: 'https://example.com/privacy' },
|
|
languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }],
|
|
apple: { bundleId: 'com.example.app' },
|
|
play: { packageName: 'com.example.app' },
|
|
products: [
|
|
{ id: 'com.example.app.lifetime', type: 'non_consumable', usd: 29.99, listings: listing('Lifetime') },
|
|
{ id: 'com.example.app.coins_100', type: 'consumable', usd: 0.99, listings: listing('100 coins') },
|
|
{ id: 'com.example.app.pass_30d', type: 'non_renewing', usd: 4.99, listings: listing('30-day pass'), platforms: ['ios'] },
|
|
],
|
|
subscriptions: [{ name: 'Pro', listings: { en: { name: 'Pro' } }, subscriptions: [
|
|
{ id: 'com.example.app.pro_monthly', period: 'P1M', level: 2, usd: 4.99, listings: listing('Pro monthly') },
|
|
{ id: 'com.example.app.pro_yearly', period: 'P1Y', level: 1, usd: 39.99, listings: listing('Pro yearly'), basePlanId: 'annual' },
|
|
{ id: 'com.example.app.pro_monthly_b', period: 'P1M', level: 3, usd: 5.99, listings: listing('Pro monthly B'), platforms: ['android'] },
|
|
] }],
|
|
revenuecat: { projectId: PROJECT, apps: APPS },
|
|
...over,
|
|
} as unknown as ShipConfig;
|
|
}
|
|
|
|
describe('wantedRevenueCat', () => {
|
|
const w = wantedRevenueCat(config());
|
|
test('one product per store item; Play subscriptions are productId:basePlanId', () => {
|
|
expect(w.products.map(p => `${p.key} ${p.type}`)).toEqual([
|
|
'apple:com.example.app.lifetime non_consumable', 'play:com.example.app.lifetime non_consumable',
|
|
'apple:com.example.app.coins_100 consumable', 'play:com.example.app.coins_100 consumable',
|
|
'apple:com.example.app.pass_30d non_renewing_subscription',
|
|
'apple:com.example.app.pro_monthly subscription', 'play:com.example.app.pro_monthly:monthly subscription',
|
|
'apple:com.example.app.pro_yearly subscription', 'play:com.example.app.pro_yearly:annual subscription',
|
|
'play:com.example.app.pro_monthly_b:monthly subscription',
|
|
]);
|
|
expect(w.problems).toEqual([]);
|
|
});
|
|
test('default entitlement "pro" = every non-consumable, non-renewing and subscription; no consumables', () => {
|
|
expect(w.entitlements).toHaveLength(1);
|
|
expect(w.entitlements[0]!.lookupKey).toBe('pro');
|
|
expect(w.entitlements[0]!.products.some(k => k.includes('coins'))).toBe(false);
|
|
expect(w.entitlements[0]!.products).toContain('apple:com.example.app.pass_30d');
|
|
expect(w.entitlements[0]!.products).toHaveLength(8);
|
|
});
|
|
test('package identifiers: reserved by period, first wins; lifetime; the rest custom', () => {
|
|
expect(w.offering.lookupKey).toBe('default');
|
|
expect(w.offering.packages.map(p => [p.lookupKey, p.position])).toEqual([
|
|
['$rc_monthly', 1], ['$rc_annual', 2], ['com.example.app.pro_monthly_b', 3],
|
|
['$rc_lifetime', 4], ['com.example.app.coins_100', 5], ['com.example.app.pass_30d', 6],
|
|
]);
|
|
expect(w.offering.packages[0]!.products).toEqual(['apple:com.example.app.pro_monthly', 'play:com.example.app.pro_monthly:monthly']);
|
|
expect(RC_PACKAGE).toEqual({ P1W: '$rc_weekly', P1M: '$rc_monthly', P2M: '$rc_two_month', P3M: '$rc_three_month', P6M: '$rc_six_month', P1Y: '$rc_annual' });
|
|
});
|
|
test('platforms: an android-only app has only Play products; non_renewing is a Play consumable', () => {
|
|
const c = config({ app: { name: 'E', privacyPolicyUrl: 'https://example.com/p', platforms: ['android'] } as ShipConfig['app'] });
|
|
const x = wantedRevenueCat(c);
|
|
expect(x.products.every(p => p.store === 'play')).toBe(true);
|
|
expect(x.offering.packages.find(p => p.configId === 'com.example.app.pass_30d')).toBeUndefined();
|
|
const y = wantedRevenueCat(config({ products: [{ id: 'com.example.app.pass', type: 'non_renewing', usd: 1, listings: listing('Pass') }], subscriptions: [] } as Partial<ShipConfig>));
|
|
expect(y.products.map(p => p.type)).toEqual(['non_renewing_subscription', 'consumable']);
|
|
});
|
|
test('configured entitlements; unknown ids and consumables are problems; offering key', () => {
|
|
const c = config();
|
|
c.revenuecat = { projectId: PROJECT, apps: APPS, offering: 'main', entitlements: { premium: ['com.example.app.pro_yearly', 'com.example.app.coins_100', 'com.example.app.nope'] } };
|
|
const x = wantedRevenueCat(c);
|
|
expect(x.entitlements.map(e => [e.lookupKey, e.products])).toEqual([['premium', ['apple:com.example.app.pro_yearly', 'play:com.example.app.pro_yearly:annual']]]);
|
|
expect(x.problems).toHaveLength(2);
|
|
expect(x.offering.lookupKey).toBe('main');
|
|
// the lifetime product is in no entitlement: a custom package, not $rc_lifetime
|
|
expect(x.offering.packages.map(p => p.lookupKey)).not.toContain('$rc_lifetime');
|
|
});
|
|
});
|
|
|
|
test('resolveApps: config ids, else match by bundle id / package name', () => {
|
|
const c = config();
|
|
c.revenuecat = { projectId: PROJECT };
|
|
const apps = [
|
|
{ id: 'app_a', type: 'app_store', app_store: { bundle_id: 'com.example.app' } },
|
|
{ id: 'app_p', type: 'play_store', play_store: { package_name: 'com.example.other' } },
|
|
];
|
|
const r = resolveApps(c, apps);
|
|
expect(r.ids).toEqual({ apple: 'app_a' });
|
|
expect(r.problems[0]).toContain('no Play Store app for com.example.app');
|
|
});
|
|
|
|
// Full RevenueCat state for config(): everything in place.
|
|
function synced(): RcState {
|
|
const w = wantedRevenueCat(config());
|
|
const products = w.products.map((p, i) => ({ id: `prod_${i}`, store_identifier: p.storeIdentifier, type: p.type, app_id: APPS[p.store], state: 'active' }));
|
|
const id = (k: string) => products[w.products.findIndex(p => p.key === k)]!.id;
|
|
return {
|
|
products,
|
|
entitlements: [{ id: 'entl_1', lookup_key: 'pro', state: 'active', productIds: w.entitlements[0]!.products.map(id) }],
|
|
offerings: [{ id: 'ofrng_1', lookup_key: 'default', is_current: true, state: 'active',
|
|
packages: w.offering.packages.map((p, i) => ({ id: `pkge_${i}`, lookup_key: p.lookupKey, position: p.position, products: p.products.map(k => ({ product_id: id(k), eligibility_criteria: 'all' })) })) }],
|
|
};
|
|
}
|
|
|
|
describe('rcDiff', () => {
|
|
const w = wantedRevenueCat(config());
|
|
test('in sync', () => {
|
|
const rows = rcDiff(w, synced(), APPS);
|
|
expect(outOfSync(rows)).toBe(0);
|
|
expect(rows.every(r => r.status === 'ok')).toBe(true);
|
|
});
|
|
test('missing, extra and mismatched', () => {
|
|
const s = synced();
|
|
s.products = s.products.filter(p => p.store_identifier !== 'com.example.app.pro_yearly');
|
|
s.products.push({ id: 'prod_x', store_identifier: 'com.example.app.old', type: 'consumable', app_id: APPS.apple, state: 'active' });
|
|
s.products.push({ id: 'prod_web', store_identifier: 'prod_stripe', type: 'subscription', app_id: 'app_web', state: 'active' });
|
|
s.products.find(p => p.store_identifier === 'com.example.app.lifetime' && p.app_id === APPS.play)!.type = 'subscription';
|
|
s.entitlements.push({ id: 'entl_2', lookup_key: 'legacy', productIds: [] });
|
|
s.offerings[0]!.packages = s.offerings[0]!.packages.filter(p => p.lookup_key !== '$rc_annual');
|
|
s.offerings[0]!.packages.push({ id: 'pkge_x', lookup_key: 'promo', products: [] });
|
|
s.offerings[0]!.is_current = false;
|
|
const rows = rcDiff(w, s, APPS);
|
|
const find = (status: string, key: string) => rows.find(r => r.status === status && r.key === key);
|
|
expect(find('missing', 'apple:com.example.app.pro_yearly')).toBeTruthy();
|
|
expect(find('missing', 'pro <- apple:com.example.app.pro_yearly')).toBeTruthy();
|
|
expect(find('missing', '$rc_annual')).toBeTruthy();
|
|
expect(find('mismatch', 'play:com.example.app.lifetime')?.msg).toContain('type subscription');
|
|
expect(find('extra', 'apple:com.example.app.old')).toBeTruthy();
|
|
expect(find('extra', 'legacy')).toBeTruthy();
|
|
expect(find('extra', 'promo')).toBeTruthy();
|
|
expect(find('info', 'default')).toBeTruthy();
|
|
expect(rows.some(r => r.key.includes('prod_stripe'))).toBe(false); // other apps are ignored
|
|
expect(outOfSync(rows)).toBe(4);
|
|
expect(rows[0]!.status).toBe('mismatch');
|
|
});
|
|
test('a package holding another product for the same app is a mismatch, not an attach', () => {
|
|
const s = synced();
|
|
s.products.push({ id: 'prod_other', store_identifier: 'com.example.app.other_monthly', type: 'subscription', app_id: APPS.apple, state: 'active' });
|
|
const pk = s.offerings[0]!.packages.find(p => p.lookup_key === '$rc_monthly')!;
|
|
pk.products = pk.products.filter(x => s.products.find(p => p.id === x.product_id)!.app_id !== APPS.apple).concat({ product_id: 'prod_other' });
|
|
const rows = rcDiff(w, s, APPS);
|
|
expect(rows.find(r => r.key === '$rc_monthly <- apple:com.example.app.pro_monthly')?.status).toBe('mismatch');
|
|
expect(rcPlan(w, s, APPS).filter(x => x.op === 'attach-package')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('rcPlan', () => {
|
|
const w = wantedRevenueCat(config());
|
|
test('empty project: creates in dependency order, attaches with chunks, no set-current by default', () => {
|
|
const plan = rcPlan(w, { products: [], entitlements: [], offerings: [] }, APPS);
|
|
const ops = plan.map(s => s.op);
|
|
const first = (op: string) => ops.indexOf(op as never), last = (op: string) => ops.lastIndexOf(op as never);
|
|
expect(last('create-product')).toBeLessThan(first('create-entitlement'));
|
|
expect(first('create-entitlement')).toBeLessThan(first('attach-entitlement'));
|
|
expect(last('attach-entitlement')).toBeLessThan(first('create-offering'));
|
|
expect(first('create-offering')).toBeLessThan(first('create-package'));
|
|
expect(last('create-package')).toBeLessThan(first('attach-package'));
|
|
expect(ops.filter(o => o === 'create-product')).toHaveLength(10);
|
|
expect(ops).not.toContain('set-current');
|
|
expect(rcPlan(w, { products: [], entitlements: [], offerings: [] }, APPS, { current: true }).at(-1)!.op).toBe('set-current');
|
|
});
|
|
test('in sync: empty plan (idempotent); never deletes or detaches', () => {
|
|
expect(rcPlan(w, synced(), APPS)).toEqual([]);
|
|
const s = synced();
|
|
s.products.push({ id: 'prod_x', store_identifier: 'com.example.app.old', type: 'consumable', app_id: APPS.apple });
|
|
expect(rcPlan(w, s, APPS)).toEqual([]);
|
|
});
|
|
test('a store without a resolved app is skipped', () => {
|
|
const plan = rcPlan(w, { products: [], entitlements: [], offerings: [] }, { apple: APPS.apple });
|
|
expect(plan.some(s => JSON.stringify(s).includes('play:'))).toBe(false);
|
|
});
|
|
test('new package positions avoid taken ones', () => {
|
|
const s = synced();
|
|
s.offerings[0]!.packages = [{ id: 'pkge_x', lookup_key: 'promo', position: 1, products: [] }];
|
|
const pos = rcPlan(w, s, APPS).filter(x => x.op === 'create-package').map(x => (x as { body: { position: number } }).body.position);
|
|
expect(new Set(pos).size).toBe(pos.length);
|
|
expect(pos).not.toContain(1);
|
|
});
|
|
});
|
|
|
|
// ---- the command against a mocked fetch ----
|
|
|
|
interface Call { method: string; url: string; auth: string | null; body?: string }
|
|
const realFetch = globalThis.fetch;
|
|
const savedKey = process.env.REVENUECAT_SECRET_KEY;
|
|
let out: string[] = [];
|
|
const saved = { log: console.log, warn: console.warn, error: console.error };
|
|
const tty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
|
|
beforeEach(() => {
|
|
out = [];
|
|
console.log = (...a: unknown[]) => { out.push(a.join(' ')); };
|
|
console.warn = console.log; console.error = console.log;
|
|
process.env.REVENUECAT_SECRET_KEY = KEY;
|
|
});
|
|
afterEach(() => {
|
|
globalThis.fetch = realFetch;
|
|
Object.assign(console, saved);
|
|
if (savedKey === undefined) delete process.env.REVENUECAT_SECRET_KEY; else process.env.REVENUECAT_SECRET_KEY = savedKey;
|
|
if (tty) Object.defineProperty(process.stdin, 'isTTY', tty); else delete (process.stdin as { isTTY?: boolean }).isTTY;
|
|
process.exitCode = 0;
|
|
});
|
|
const json = (o: unknown, status = 200, headers: Record<string, string> = {}) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json', ...headers } });
|
|
const list = (items: unknown[], next: string | null = null) => ({ object: 'list', items, next_page: next, url: '/v2/x' });
|
|
|
|
function fake(route: (c: Call) => Response): Call[] {
|
|
const calls: Call[] = [];
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const c: Call = { method: init?.method ?? 'GET', url: String(input), auth: new Headers(init?.headers).get('authorization'), body: init?.body as string | undefined };
|
|
calls.push(c);
|
|
return route(c);
|
|
}) as typeof fetch;
|
|
return calls;
|
|
}
|
|
const ctx = (argv: string[], cfg = config()) => {
|
|
const root = mkdtempSync(join(tmpdir(), 'sb-rc-'));
|
|
return { loaded: { config: cfg, root, path: join(root, 'storebound.config.ts'), resolve: (p: string) => join(root, p) } as Loaded, args: parseArgs(argv) };
|
|
};
|
|
|
|
/** A RevenueCat project as GET answers it, products split over two pages. */
|
|
function project(s: RcState): (c: Call) => Response {
|
|
const P = `https://api.revenuecat.com/v2/projects/${PROJECT}`;
|
|
return c => {
|
|
if (c.method !== 'GET') return json({ id: `new_${c.url.split('/').at(-1)}` }, 201);
|
|
const u = new URL(c.url);
|
|
const path = u.origin + u.pathname;
|
|
if (path === `${P}/products`) {
|
|
const half = Math.ceil(s.products.length / 2);
|
|
return u.searchParams.get('starting_after') ? json(list(s.products.slice(half))) : json(list(s.products.slice(0, half), `/v2/projects/${PROJECT}/products?limit=100&starting_after=${s.products[half - 1]?.id}`));
|
|
}
|
|
if (path === `${P}/entitlements`) return json(list(s.entitlements.map(e => ({ object: 'entitlement', id: e.id, lookup_key: e.lookup_key, display_name: e.lookup_key, state: 'active' }))));
|
|
const ent = path.match(/entitlements\/([^/]+)\/products$/);
|
|
if (ent) return json(list(s.entitlements.find(e => e.id === ent[1])!.productIds.map(id => s.products.find(p => p.id === id))));
|
|
if (path === `${P}/offerings`) return json(list(s.offerings.map(o => ({ object: 'offering', id: o.id, lookup_key: o.lookup_key, is_current: o.is_current, state: 'active' }))));
|
|
const pk = path.match(/offerings\/([^/]+)\/packages$/);
|
|
if (pk) return json(list(s.offerings.find(o => o.id === pk[1])!.packages.map(p => ({ object: 'package', id: p.id, lookup_key: p.lookup_key, position: p.position }))));
|
|
const pp = path.match(/packages\/([^/]+)\/products$/);
|
|
if (pp) return json(list(s.offerings.flatMap(o => o.packages).find(p => p.id === pp[1])!.products.map(x => ({ product: s.products.find(p => p.id === x.product_id), eligibility_criteria: 'all' }))));
|
|
return json({ type: 'resource_missing', message: 'not found' }, 404);
|
|
};
|
|
}
|
|
|
|
describe('revenuecat command (mocked fetch)', () => {
|
|
test('diff reads every page, sends the Bearer key, in sync -> exit 0 and the scope footer', async () => {
|
|
const calls = fake(project(synced()));
|
|
await run(ctx(['diff']));
|
|
expect(calls.filter(c => c.url.includes('/products?')).length).toBeGreaterThanOrEqual(2);
|
|
expect(calls.some(c => c.url.includes('starting_after=prod_4'))).toBe(true);
|
|
expect(calls.every(c => c.auth === `Bearer ${KEY}` && c.url.startsWith('https://api.revenuecat.com/v2/'))).toBe(true);
|
|
expect(calls.every(c => c.method === 'GET')).toBe(true);
|
|
expect(process.exitCode).toBe(0);
|
|
expect(out.join('\n')).toContain('receipt / purchase token validation');
|
|
expect(out.join('\n')).not.toContain(KEY);
|
|
});
|
|
test('diff --json: stable shape; out of sync -> exit 1', async () => {
|
|
fake(project({ products: [], entitlements: [], offerings: [] }));
|
|
await run(ctx(['diff', '--json']));
|
|
const j = JSON.parse(out.find(l => l.startsWith('{'))!);
|
|
expect(Object.keys(j)).toEqual(['projectId', 'apps', 'outOfSync', 'rows', 'scope']);
|
|
expect(j.outOfSync).toBeGreaterThan(0);
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
test('apps are looked up by bundle id / package name when not configured', async () => {
|
|
const cfg = config(); cfg.revenuecat = { projectId: PROJECT };
|
|
const base = project(synced());
|
|
const calls = fake(c => c.url.includes('/apps?') ? json(list([
|
|
{ object: 'app', id: APPS.apple, type: 'app_store', app_store: { bundle_id: 'com.example.app' } },
|
|
{ object: 'app', id: APPS.play, type: 'play_store', play_store: { package_name: 'com.example.app' } },
|
|
])) : base(c));
|
|
await run(ctx(['diff'], cfg));
|
|
expect(calls[0]!.url).toContain(`/projects/${PROJECT}/apps`);
|
|
expect(process.exitCode).toBe(0);
|
|
});
|
|
test('sync --dry sends no POST', async () => {
|
|
const calls = fake(project({ products: [], entitlements: [], offerings: [] }));
|
|
await run(ctx(['sync', '--dry']));
|
|
expect(calls.some(c => c.method !== 'GET')).toBe(false);
|
|
expect(out.join('\n')).toContain('create product apple:com.example.app.lifetime');
|
|
expect(out.join('\n')).toContain('dry run');
|
|
});
|
|
test('sync without a terminal and without --yes sends no POST', async () => {
|
|
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
|
|
const calls = fake(project({ products: [], entitlements: [], offerings: [] }));
|
|
await run(ctx(['sync']));
|
|
expect(calls.some(c => c.method !== 'GET')).toBe(false);
|
|
expect(out.join('\n')).toContain('re-run with --yes');
|
|
});
|
|
test('sync --yes: POSTs in order with resolved ids and the documented bodies', async () => {
|
|
const calls = fake(project({ products: [], entitlements: [], offerings: [] }));
|
|
await run(ctx(['sync', '--yes']));
|
|
const posts = calls.filter(c => c.method === 'POST').map(c => new URL(c.url).pathname.replace(`/v2/projects/${PROJECT}`, ''));
|
|
expect(posts.slice(0, 10).every(p => p === '/products')).toBe(true);
|
|
expect(posts).toContain('/entitlements');
|
|
expect(posts).toContain('/entitlements/new_entitlements/actions/attach_products');
|
|
expect(posts).toContain('/offerings/new_offerings/packages');
|
|
expect(posts).toContain('/packages/new_packages/actions/attach_products');
|
|
const attach = calls.find(c => c.url.endsWith('/packages/new_packages/actions/attach_products'))!;
|
|
expect(JSON.parse(attach.body!).products[0]).toEqual({ product_id: 'new_products', eligibility_criteria: 'all' });
|
|
const prod = JSON.parse(calls.find(c => c.method === 'POST')!.body!);
|
|
expect(prod).toEqual({ store_identifier: 'com.example.app.lifetime', app_id: APPS.apple, type: 'non_consumable', display_name: 'Lifetime' });
|
|
});
|
|
test('a 401 names the key variable but never contains the key', async () => {
|
|
fake(() => json({ object: 'error', type: 'authentication_error', message: `Invalid API key ${KEY}`, retryable: false }, 401));
|
|
const err = await run(ctx(['diff'])).then(() => null, (e: Error) => e);
|
|
expect(err?.message).toContain('401');
|
|
expect(err?.message).toContain('REVENUECAT_SECRET_KEY');
|
|
expect(err?.message).not.toContain(KEY);
|
|
expect(out.join('\n')).not.toContain(KEY);
|
|
});
|
|
test('429 waits for Retry-After and retries; next_page off api.revenuecat.com is refused', async () => {
|
|
let n = 0;
|
|
fake(() => (n++ === 0 ? json({ object: 'error', type: 'rate_limit_error', backoff_ms: 0 }, 429, { 'retry-after': '0' }) : json(list([{ id: 'a' }]))));
|
|
expect(await rcClient(KEY).all('/projects/p/apps')).toEqual([{ id: 'a' }]);
|
|
expect(n).toBe(2);
|
|
fake(() => json(list([{ id: 'a' }], 'https://elsewhere.example.com/v2/x')));
|
|
await expect(rcClient(KEY).all('/projects/p/apps')).rejects.toThrow('refusing');
|
|
});
|
|
test('missing key: a clear error, no request', async () => {
|
|
delete process.env.REVENUECAT_SECRET_KEY;
|
|
const calls = fake(() => json({}));
|
|
await expect(run(ctx(['diff']))).rejects.toThrow('REVENUECAT_SECRET_KEY is not set');
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
});
|