// 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 { 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)); 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 = {}) => 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); }); });