storebound/tests/prices.test.ts
CRT 236977940e flows, listing translate, scan 2.2 + --originality, prices export with subscriptions, site sections
- storebound flow list|validate|run: repo-local workflows (store/workflows/*.yml, storebound.workflows.*), writes dry unless --apply, owner steps never run, --from / --resume; init templates for app and game; MCP flow_list + dry flow_run; release-flows skill
- storebound listing translate|check|reviewed: per-language listing drafts with store limits and keyword rules, agent or --translate hook, marked until reviewed; MCP listing_translate / listing_check; listing-localization skill
- scan: Apple 2.2 beta/demo/test wording rule; scan --originality [--against]
- prices export includes subscriptions; prices import skips their rows
- site: rejection scan section, Desktop app section with fixture-mode screenshots, Everything else grid, FAQ 4.3 answer
- README, CHANGELOG, adapters, API tables
2026-10-12 01:24:21 +08:00

169 lines
12 KiB
TypeScript

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<string, { data: unknown }> }; 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']);
});
});