// The desktop read paths against what the stores really answered in a read-only run (GET only) on live accounts, // 2026-10. Recorded responses are sanitized: ids, product ids and package names are placeholders. // - prices.current: App Store manualPrices / automaticPrices pages (customerPrice "14900.0", territories priced in // USD next to Play's local currency) and a Play oneTimeProduct (Money without nanos). The desktop price sheet // export must import again with no change. // - analytics: a team without the ONGOING report request and an app without play.reportsBucket gave a snapshot of // errors only; `snapshot` wrote it and said done, the Growth chart then showed zero KPIs and "one day of data". // - listings: a contact sheet in the language folder (//sheet.png) was counted as a screenshot. import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pricesCsv } from '../apps/desktop/src/lib/exports'; import { priceStats, regionRows } from '../apps/desktop/src/lib/views'; import { mergeSeries, type Snapshot, snapshotProblem, storeErrors } from '../cli/lib/analytics'; import type { PricesCurrent } from '../cli/lib/desktop-protocol'; import { countImages } from '../cli/lib/project-files'; import { applePriceRows, diffPrices, parsePriceCsv, type PlayProduct, playPriceRows } from '../cli/lib/prices'; import real from './fixtures/real-prices.json'; const ID = 'com.example.game.no_ads'; type Resp = Parameters[1]; /** What `prices export --json` builds from the recorded pages (pointId dropped, as in cli/prices.ts). */ function current(): PricesCurrent { const rows = [...applePriceRows(ID, real.apple.manual as Resp), ...applePriceRows(ID, real.apple.auto as Resp, 'auto'), ...playPriceRows(real.play as PlayProduct)]; return { csv: '/x/store/prices.csv', products: [ID], stores: ['apple', 'play'], missing: [], rows: rows.map(({ pointId: _p, ...r }) => r) }; } describe('prices.current from recorded store answers', () => { test('rows: manual and equalized App Store prices, Play Money with and without nanos', () => { const p = current(); const by = (store: string, region: string) => p.rows.find(r => r.store === store && r.region === region); expect(by('apple', 'TR')).toEqual({ product: ID, store: 'apple', region: 'TR', territory: 'TUR', currency: 'TRY', price: 129.99 }); expect(by('apple', 'CO')).toEqual({ product: ID, store: 'apple', region: 'CO', territory: 'COL', currency: 'COP', price: 14900, kind: 'auto' }); expect(by('play', 'CO')).toEqual({ product: ID, store: 'play', region: 'CO', currency: 'COP', price: 9500 }); expect(by('play', 'TR')?.price).toBe(129.99); }); test('price gaps: same currency compared, Apple USD territories next to Play USD', () => { const rows = regionRows(current(), ID); const g = (r: string) => rows.find(x => x.region === r)!.gapPct; expect(g('US')).toBe(0); expect(g('TR')).toBe(0); expect(g('AM')!).toBeCloseTo(25.06, 1); expect(g('CO')!).toBeCloseTo(36.24, 1); expect(priceStats(current(), 10)).toMatchObject({ products: 1, regions: 4, manual: 6, auto: 2, gaps: 2, missing: 0 }); }); test('the desktop price sheet imports again with no change (round trip)', () => { const p = current(); const sheet = pricesCsv(p); expect(sheet.split('\n')[0]).toBe('product,store,region,currency,price,start_date,kind'); const wanted = parsePriceCsv(sheet, { products: [ID], today: '2026-10-11' }); const cur = [...applePriceRows(ID, real.apple.manual as Resp), ...applePriceRows(ID, real.apple.auto as Resp, 'auto'), ...playPriceRows(real.play as PlayProduct)]; expect(diffPrices(cur, wanted).filter(d => d.change !== 'same')).toEqual([]); // an edited sheet: one price changed, one scheduled App Store price added const edited = sheet.replace(`${ID},apple,TR,TRY,129.99,,manual`, `${ID},apple,TR,TRY,149.99,,manual`) + `${ID},apple,US,USD,3.99,2027-01-15,manual\n`; const d = diffPrices(cur, parsePriceCsv(edited, { products: [ID], today: '2026-10-11' })).filter(x => x.change !== 'same'); expect(d).toEqual([ { product: ID, store: 'apple', region: 'TR', currency: 'TRY', from: 129.99, to: 149.99, change: 'change' }, { product: ID, store: 'apple', region: 'US', startDate: '2027-01-15', currency: 'USD', from: null, to: 3.99, change: 'new' }, ]); }); }); const NO_REQUEST = 'no active ONGOING analytics report request: run storebound analytics setup (Admin key), data starts 1-2 days later'; const NO_BUCKET = 'play.reportsBucket is not set (Play Console > Download reports > Statistics > Copy Cloud Storage URI)'; const errorsOnly: Snapshot = { date: '2026-10-10', window: { start: '2026-09-12', end: '2026-10-09', days: 28 }, apple: { error: NO_REQUEST }, play: { error: NO_BUCKET } }; describe('analytics without store data', () => { test('a snapshot of errors only is a problem (not written), one store with days is fine', () => { expect(snapshotProblem(errorsOnly)).toBe(`apple: ${NO_REQUEST}; play: ${NO_BUCKET}`); const day = { date: '2026-10-09', impressions: 10, uniqueImpressions: 8, pageViews: 3, uniquePageViews: 3, downloads: 1, firstTimeDownloads: 1 }; expect(snapshotProblem({ ...errorsOnly, apple: { daily: [day], totals: { impressions: 10, pageViews: 3, downloads: 1, conversion: 0.1 } } })).toBeNull(); expect(snapshotProblem({ date: '2026-10-10', window: errorsOnly.window })).toMatch(/no store to read/); }); test('compare names why a store has no days (newest snapshot), and nothing for a store with days', () => { expect(mergeSeries([errorsOnly])).toEqual({ apple: [], play: [] }); expect(storeErrors([errorsOnly])).toEqual({ apple: NO_REQUEST, play: NO_BUCKET }); const day = { date: '2026-10-11', impressions: 10, uniqueImpressions: 8, pageViews: 3, uniquePageViews: 3, downloads: 1, firstTimeDownloads: 1 }; const later: Snapshot = { date: '2026-10-12', window: errorsOnly.window, apple: { daily: [day], totals: {} }, play: { error: 'GET b/o -> 403 forbidden' } }; expect(storeErrors([errorsOnly, later])).toEqual({ play: 'GET b/o -> 403 forbidden' }); }); }); describe('listing completeness screenshots', () => { test('images in size folders count, a contact sheet in the language folder does not', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-count-')); for (const s of ['ios69', 'play', 'feature']) mkdirSync(join(dir, 'en', s), { recursive: true }); writeFileSync(join(dir, 'en', 'ios69', '01.png'), 'x'); writeFileSync(join(dir, 'en', 'ios69', '02.jpg'), 'x'); writeFileSync(join(dir, 'en', 'play', '01.png'), 'x'); writeFileSync(join(dir, 'en', 'feature', '1024x500.png'), 'x'); writeFileSync(join(dir, 'en', 'sheet.png'), 'x'); writeFileSync(join(dir, 'en', 'notes.txt'), 'x'); expect(countImages(join(dir, 'en'))).toBe(4); }); });