storebound/tests/desktop-real-reads.test.ts

100 lines
6.9 KiB
TypeScript

// 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 (<renders>/<lang>/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<typeof applePriceRows>[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);
});
});