import { describe, expect, test } from 'bun:test'; import { alpha2, appleSchedule, appleScheduleBody, applePriceRows, diffPrices, diffTable, parsePriceCsv, playPatchBody, playPriceRows, playRegions, pointKey, type PriceRow, pricesCsv, sameSchedule, } from '../cli/lib/prices'; import { TOOLS, toArgv } from '../cli/mcp-tools'; import { appleSubPriceRows, playSubPriceRows } from '../cli/lib/subscriptions'; const P = 'com.example.game.remove_ads'; const csv = `product,store,region,currency,price,start_date ${P},both,US,USD,2.99, ${P},apple,TR,TRY,99.99, ${P},apple,TR,TRY,129.99,2026-12-01 ${P},play,TR,TRY,109.99, `; describe('price csv', () => { test('parse: both expands, apple territory, scheduled change', () => { const rows = parsePriceCsv(csv, { products: [P], today: '2026-10-10' }); expect(rows.map(r => `${r.store} ${r.region} ${r.territory ?? '-'} ${r.price} ${r.startDate ?? ''}`)).toEqual([ 'apple US USA 2.99 ', 'play US - 2.99 ', 'apple TR TUR 99.99 ', 'apple TR TUR 129.99 2026-12-01', 'play TR - 109.99 ']); }); test('parse errors are all reported', () => { const bad = `product,store,region,currency,price,start_date nope id,apple,US,USD,1, ${P},web,USA,usd,-1, ${P},play,TR,TRY,10,2026-12-01 ${P},apple,TR,TRY,10,2026-01-01 ${P},apple,US,USD,1, ${P},apple,US,USD,1, x.y,apple,ZZ,USD,1, `; try { parsePriceCsv(bad, { products: [P], today: '2026-10-10' }); throw new Error('no'); } catch (e) { const m = String(e); for (const s of ['row 2: product "nope id"', 'store must be apple, play or both', 'must be ISO alpha-2', 'price must be a positive number', 'App Store only (Play has no scheduled prices)', 'is not in the future', 'listed twice', 'x.y is not in products[]', 'no App Store territory known for ZZ']) expect(m).toContain(s); } }); test('csv round trip', () => { const rows = parsePriceCsv(csv, { today: '2026-10-10' }); const out = pricesCsv(rows); expect(out.split('\n')[0]).toBe('product,store,region,currency,price,start_date,kind'); expect(out).toContain(`${P},apple,TR,TRY,129.99,2026-12-01,manual`); expect(parsePriceCsv(out, { today: '2026-10-10' })).toHaveLength(rows.length); expect(alpha2('TUR')).toBe('TR'); }); }); describe('store state -> rows', () => { test('apple manual prices with included price points and territories', () => { const rows = applePriceRows(P, { data: [{ attributes: { startDate: null }, relationships: { inAppPurchasePricePoint: { data: { id: 'pp-us' } }, territory: { data: { id: 'USA' } } } }, { attributes: { startDate: '2026-12-01' }, relationships: { inAppPurchasePricePoint: { data: { id: 'pp-tr' } }, territory: { data: { id: 'TUR' } } } }], included: [{ type: 'inAppPurchasePricePoints', id: 'pp-us', attributes: { customerPrice: '2.99' } }, { type: 'inAppPurchasePricePoints', id: 'pp-tr', attributes: { customerPrice: '129.99' } }, { type: 'territories', id: 'USA', attributes: { currency: 'USD' } }, { type: 'territories', id: 'TUR', attributes: { currency: 'TRY' } }], }); expect(rows).toEqual([ { product: P, store: 'apple', region: 'US', territory: 'USA', currency: 'USD', price: 2.99, pointId: 'pp-us' }, { product: P, store: 'apple', region: 'TR', territory: 'TUR', currency: 'TRY', price: 129.99, pointId: 'pp-tr', startDate: '2026-12-01' }]); }); test('play regional configs', () => { expect(playPriceRows({ productId: P, purchaseOptions: [{ purchaseOptionId: 'buy', regionalPricingAndAvailabilityConfigs: [ { regionCode: 'US', price: { currencyCode: 'USD', units: '2', nanos: 990000000 }, availability: 'AVAILABLE' }, { regionCode: 'RU', price: { currencyCode: 'RUB', units: '100' }, availability: 'NO_LONGER_AVAILABLE' }] }] })) .toEqual([{ product: P, store: 'play', region: 'US', currency: 'USD', price: 2.99 }]); }); }); describe('subscription rows (prices export)', () => { test('apple: price in effect today plus scheduled ones; play: base plan regional prices', () => { const resp = { data: [ { id: 'p1', type: 'subscriptionPrices', attributes: { startDate: null }, relationships: { subscriptionPricePoint: { data: { id: 'pp1' } }, territory: { data: { id: 'USA' } } } }, { id: 'p2', type: 'subscriptionPrices', attributes: { startDate: '2030-01-01' }, relationships: { subscriptionPricePoint: { data: { id: 'pp2' } }, territory: { data: { id: 'USA' } } } }, { id: 'p3', type: 'subscriptionPrices', attributes: { startDate: '2024-01-01' }, relationships: { subscriptionPricePoint: { data: { id: 'pp3' } }, territory: { data: { id: 'DEU' } } } }, ], included: [ { type: 'subscriptionPricePoints', id: 'pp1', attributes: { customerPrice: '4.99' } }, { type: 'subscriptionPricePoints', id: 'pp2', attributes: { customerPrice: '5.99' } }, { type: 'subscriptionPricePoints', id: 'pp3', attributes: { customerPrice: '4.99' } }, { type: 'territories', id: 'USA', attributes: { currency: 'USD' } }, { type: 'territories', id: 'DEU', attributes: { currency: 'EUR' } }, ], }; expect(appleSubPriceRows('pro.monthly', resp, '2026-10-12')).toEqual([ { product: 'pro.monthly', store: 'apple', region: 'US', territory: 'USA', currency: 'USD', price: 4.99 }, { product: 'pro.monthly', store: 'apple', region: 'DE', territory: 'DEU', currency: 'EUR', price: 4.99 }, { product: 'pro.monthly', store: 'apple', region: 'US', territory: 'USA', currency: 'USD', price: 5.99, startDate: '2030-01-01' }, ]); const play = { productId: 'pro.monthly', basePlans: [{ basePlanId: 'monthly', regionalConfigs: [{ regionCode: 'US', price: { currencyCode: 'USD', units: '4', nanos: 990000000 } }, { regionCode: 'TR', price: { currencyCode: 'TRY', units: '99' } }] }] }; expect(playSubPriceRows(play, 'monthly')).toEqual([ { product: 'pro.monthly', store: 'play', region: 'US', currency: 'USD', price: 4.99 }, { product: 'pro.monthly', store: 'play', region: 'TR', currency: 'TRY', price: 99 }]); // the rows round-trip through the sheet that apple / play subscriptions --csv read const csv = pricesCsv([...appleSubPriceRows('pro.monthly', resp, '2026-10-12').filter(r => !r.startDate), ...playSubPriceRows(play, 'monthly')]); expect(parsePriceCsv(csv, { products: ['pro.monthly'] }).length).toBe(4); }); }); describe('diff', () => { const current: PriceRow[] = [ { product: P, store: 'apple', region: 'US', territory: 'USA', currency: 'USD', price: 2.99 }, { product: P, store: 'apple', region: 'DE', territory: 'DEU', currency: 'EUR', price: 2.99, kind: 'auto' }, { product: P, store: 'play', region: 'TR', currency: 'TRY', price: 99.99 }, ]; test('same / change / new (auto prices count as new manual ones)', () => { const wanted = parsePriceCsv(`product,store,region,currency,price\n${P},apple,US,USD,2.99\n${P},apple,DE,EUR,3.49\n${P},play,TR,TRY,109.99\n${P},play,JP,JPY,450\n`); expect(diffPrices(current, wanted).map(d => `${d.store} ${d.region} ${d.from} -> ${d.to} ${d.change}`)).toEqual([ 'apple US 2.99 -> 2.99 same', 'apple DE 2.99 -> 3.49 new', 'play TR 99.99 -> 109.99 change', 'play JP null -> 450 new']); const t = diffTable(diffPrices(current, wanted)); expect(t).toContain('+10.0%'); expect(t).not.toContain(' US '); expect(diffTable([])).toBe('no price changes'); }); }); describe('play regions', () => { const conv = { US: { regionCode: 'US', price: { currencyCode: 'USD', units: '2', nanos: 990000000 } }, TR: { regionCode: 'TR', price: { currencyCode: 'TRY', units: '89', nanos: 990000000 } }, JP: { regionCode: 'JP', price: { currencyCode: 'JPY', units: '450' } } }; const cur = [{ regionCode: 'US', price: { currencyCode: 'USD', units: '2', nanos: 990000000 } }, { regionCode: 'TR', price: { currencyCode: 'TRY', units: '99', nanos: 990000000 } }]; test('csv over current, converted fills new regions, reconvert resets unlisted', () => { const want = parsePriceCsv(`product,store,region,currency,price\n${P},play,TR,TRY,109.99\n`); const r = playRegions(cur, want, conv); expect(r.map(x => `${x.regionCode} ${x.price.units}.${(x.price.nanos ?? 0) / 1e7}`)).toEqual(['JP 450.0', 'TR 109.99', 'US 2.99']); const r2 = playRegions([{ regionCode: 'JP', price: { currencyCode: 'JPY', units: '600' } }], [], conv, { reconvert: true }); expect(r2.find(x => x.regionCode === 'JP')!.price.units).toBe('450'); expect(() => playRegions(cur, parsePriceCsv(`product,store,region,currency,price\n${P},play,TR,USD,3\n`), conv)).toThrow('prices this region in TRY'); expect(() => playRegions(cur, parsePriceCsv(`product,store,region,currency,price\n${P},play,KP,KPW,3\n`), conv)).toThrow('does not sell in region KP'); }); test('patch body replaces only the purchase option prices and drops output-only state', () => { const body = playPatchBody({ productId: P, listings: [{ languageCode: 'en-US' }], purchaseOptions: [{ purchaseOptionId: 'buy', state: 'ACTIVE', buyOption: { legacyCompatible: true }, regionalPricingAndAvailabilityConfigs: [] }] }, [{ regionCode: 'US', availability: 'AVAILABLE', price: { currencyCode: 'USD', units: '1' } }]); expect(body.purchaseOptions![0]).toEqual({ purchaseOptionId: 'buy', buyOption: { legacyCompatible: true }, regionalPricingAndAvailabilityConfigs: [{ regionCode: 'US', availability: 'AVAILABLE', price: { currencyCode: 'USD', units: '1' } }] }); expect(body.listings).toEqual([{ languageCode: 'en-US' }]); }); }); describe('apple schedule', () => { const cur: PriceRow[] = [ { product: P, store: 'apple', region: 'US', territory: 'USA', currency: 'USD', price: 2.99, pointId: 'pp-us' }, { product: P, store: 'apple', region: 'TR', territory: 'TUR', currency: 'TRY', price: 99.99, pointId: 'pp-tr' }, { product: P, store: 'apple', region: 'JP', territory: 'JPN', currency: 'JPY', price: 400, pointId: 'pp-jp' }, ]; test('csv territories replaced, dated rows keep the current price, others kept, base first', () => { const want = parsePriceCsv(`product,store,region,currency,price,start_date\n${P},apple,TR,TRY,129.99,2026-12-01\n${P},apple,JP,JPY,500,\n`, { today: '2026-10-10' }); const s = appleSchedule(cur, want, 'USA'); expect(s).toEqual([ { territory: 'USA', price: 2.99, startDate: null }, { territory: 'JPN', price: 500, startDate: null }, { territory: 'TUR', price: 99.99, startDate: null }, { territory: 'TUR', price: 129.99, startDate: '2026-12-01' }]); expect(sameSchedule(s, s.slice().reverse())).toBe(true); expect(sameSchedule(s, s.slice(1))).toBe(false); expect(() => appleSchedule([], want, 'USA')).toThrow('base territory USA needs a current price'); }); test('schedule body with local ids and price point ids', () => { const entries = [{ territory: 'USA', price: 2.99, startDate: null }, { territory: 'TUR', price: 129.99, startDate: '2026-12-01' }]; const points = new Map([[pointKey('USA', 2.99), 'pp-us'], [pointKey('TUR', '129.99'), 'pp-tr2']]); const body = appleScheduleBody('iap1', 'USA', entries, points) as { data: { relationships: Record }; included: unknown[] }; expect(body.data.relationships.baseTerritory!.data).toEqual({ type: 'territories', id: 'USA' }); expect(body.data.relationships.manualPrices!.data).toEqual([{ type: 'inAppPurchasePrices', id: '${p0}' }, { type: 'inAppPurchasePrices', id: '${p1}' }]); expect(body.included[1]).toEqual({ type: 'inAppPurchasePrices', id: '${p1}', attributes: { startDate: '2026-12-01' }, relationships: { inAppPurchaseV2: { data: { type: 'inAppPurchases', id: 'iap1' } }, inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: 'pp-tr2' } } } }); expect(() => appleScheduleBody('iap1', 'USA', [{ territory: 'GBR', price: 1, startDate: null }], points)).toThrow('no price point id for GBR 1'); }); test('mcp: diff is read-only with --dry, export is a dry-by-default local write', () => { const t = (n: string) => TOOLS.find(x => x.name === n)!; expect(t('prices_diff').write).toBe(false); expect(toArgv(t('prices_diff'), { store: 'apple' }).argv).toEqual(['prices', 'import', '--store', 'apple', '--dry']); expect(toArgv(t('prices_export'), {}).argv).toEqual(['prices', 'export', '--dry']); }); });