storebound/tests/analytics-collector.test.ts

264 lines
20 KiB
TypeScript

// What an external collector needs from `analytics snapshot` and `reports`: a store-only config (apple.appId without a
// bundle id, no play.reportsBucket), --json on stdout, App Usage and Play install stock numbers per day, and per-day
// sales JSON for a day range of both stores. Fixtures are made-up report files; nothing reaches the network.
import { afterEach, describe, expect, test } from 'bun:test';
import { createHash } from 'node:crypto';
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { deflateRawSync } from 'node:zlib';
import { run as analytics } from '../cli/analytics';
import { appleDaily, appleTotals, mergeAppleUsage, parseReport, playDaily, playTotals } from '../cli/lib/analytics';
import { type ShipConfig, validateConfig } from '../cli/lib/config';
import { playReportsBucket, profileEnv } from '../cli/lib/credentials';
import { dateRange, parsePlayCsv, parseTsv, playSalesTotals, rangeReport, reportRange, summarizeAppleSales, summarizePlaySalesByDay, totalsByCurrency } from '../cli/lib/reports';
import { run as reports } from '../cli/reports';
import { ascEnv, BASE, capture, jsonOut, mockFetch, playEnv, project, restore } from './helpers/store-mock';
const BUCKET = 'pubsite_prod_rev_01234567890123456789';
afterEach(() => { restore(); delete process.env.PLAY_REPORTS_BUCKET; delete process.env.STOREBOUND_CREDENTIALS_DIR; delete process.env.STOREBOUND_PROFILE; });
const iso = (offset: number) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10);
type Req = { path: string };
function zip(files: Record<string, string>): Uint8Array {
const parts: Uint8Array[] = [], central: Uint8Array[] = [];
let off = 0;
for (const [name, text] of Object.entries(files)) {
const raw = new TextEncoder().encode(text), data = deflateRawSync(raw), n = new TextEncoder().encode(name);
const h = new DataView(new ArrayBuffer(30));
h.setUint32(0, 0x04034b50, true); h.setUint16(8, 8, true); h.setUint32(18, data.length, true); h.setUint32(22, raw.length, true); h.setUint16(26, n.length, true);
const c = new DataView(new ArrayBuffer(46));
c.setUint32(0, 0x02014b50, true); c.setUint16(10, 8, true); c.setUint32(20, data.length, true); c.setUint32(24, raw.length, true); c.setUint16(28, n.length, true); c.setUint32(42, off, true);
parts.push(new Uint8Array(h.buffer), n, data);
central.push(new Uint8Array(c.buffer), n);
off += 30 + n.length + data.length;
}
const cdSize = central.reduce((s, x) => s + x.length, 0);
const e = new DataView(new ArrayBuffer(22));
e.setUint32(0, 0x06054b50, true); e.setUint16(8, Object.keys(files).length, true); e.setUint16(10, Object.keys(files).length, true); e.setUint32(12, cdSize, true); e.setUint32(16, off, true);
const all = [...parts, ...central, new Uint8Array(e.buffer)];
const out = new Uint8Array(all.reduce((s, x) => s + x.length, 0));
let p = 0;
for (const x of all) { out.set(x, p); p += x.length; }
return out;
}
// ---- pure parts ----
describe('App Usage and Play install stock numbers', () => {
const SESSIONS = 'Date\tApp Name\tApp Apple Identifier\tApp Version\tDevice\tPlatform Version\tSource Type\tSource Info\tTerritory\tSessions\tTotal Session Duration\tUnique Devices\n' +
'2026-10-01\tExample\t1\t1.0\tiPhone\tiOS 26\t\t\tUS\t100\t3000\t40\n2026-10-01\tExample\t1\t1.0\tiPad\tiPadOS 26\t\t\tTR\t20\t600\t8\n2026-10-02\tExample\t1\t1.0\tiPhone\tiOS 26\t\t\tUS\t50\t1000\t30\n';
const CRASHES = 'Date\tApp Name\tApp Apple Identifier\tApp Version\tDevice\tPlatform Version\tCrashes\tUnique Devices\n2026-10-01\tExample\t1\t1.0\tiPhone\tiOS 26\t4\t3\n2026-10-03\tExample\t1\t1.0\tiPhone\tiOS 26\t1\t1\n';
test('sessions, active devices, session time and crashes per day, merged into the engagement days', () => {
const base = appleDaily(parseReport('Date\tEvent\tCounts\tUnique Counts\n2026-10-01\tImpression\t10\t8\n'), []);
const d = mergeAppleUsage(base, parseReport(SESSIONS), parseReport(CRASHES));
expect(d.map(x => x.date)).toEqual(['2026-10-01', '2026-10-02', '2026-10-03']);
expect(d[0]).toMatchObject({ impressions: 10, sessions: 120, activeDevices: 48, sessionSeconds: 3600, crashes: 4 });
expect(d[1]).toMatchObject({ impressions: 0, sessions: 50, activeDevices: 30 });
expect(d[1]!.crashes).toBeUndefined();
expect(d[2]).toMatchObject({ crashes: 1 });
expect(appleTotals(d)).toMatchObject({ sessions: 170, crashes: 5, activeDevicesAvg: 39 });
expect(appleTotals(base).sessions).toBeUndefined();
});
test('Play: stock columns from the overview file, else summed over the country rows; totals take the newest day', () => {
const country = parseReport('Date,Package Name,Country,Daily Device Installs,Daily User Installs,Total User Installs,Active Device Installs\n' +
'2026-10-01,p,US,5,4,100,60\n2026-10-01,p,TR,2,2,50,30\n2026-10-02,p,US,1,1,101,61\n');
expect(playDaily([], country)).toEqual([
{ date: '2026-10-01', visitors: 0, acquisitions: 0, installs: 6, activeDeviceInstalls: 90, totalUserInstalls: 150 },
{ date: '2026-10-02', visitors: 0, acquisitions: 0, installs: 1, activeDeviceInstalls: 61, totalUserInstalls: 101 }]);
const overview = parseReport('Date,Package Name,Total User Installs,Active Device Installs\n2026-10-01,p,160,95\n2026-10-02,p,162,96\n');
const d = playDaily([], country, overview);
expect(d[0]).toMatchObject({ installs: 6, activeDeviceInstalls: 95, totalUserInstalls: 160 });
expect(playTotals(d)).toMatchObject({ installs: 7, activeDeviceInstalls: 96, totalUserInstalls: 162 });
expect(playDaily([], parseReport('Date,Daily User Installs\n2026-10-01,3\n'))[0]!.activeDeviceInstalls).toBeUndefined();
});
});
describe('store-only config and bucket fallback', () => {
const virtual = (apple: Record<string, unknown>): unknown => ({ ...BASE, apple, play: { packageName: 'com.example.notes' } });
test('apple.appId alone is enough; a missing id is still an error', () => {
expect(validateConfig(virtual({ bundleId: null, appId: '6400000101' }))).toEqual([]);
expect(validateConfig(virtual({ appId: '6400000101' }))).toEqual([]);
expect(validateConfig(virtual({ bundleId: null }))[0]).toContain('apple.bundleId');
expect(validateConfig(virtual({ bundleId: 'nope' }))[0]).toContain('apple.bundleId');
expect(validateConfig(virtual({ bundleId: null, appId: 'abc' }))[0]).toContain('apple.bundleId');
});
test('reports bucket: config, then env PLAY_REPORTS_BUCKET, then the profile field reportsBucket', () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-prof-'));
writeFileSync(join(dir, 'acme.json'), JSON.stringify({ reportsBucket: `gs://${BUCKET}/` }));
process.env.STOREBOUND_CREDENTIALS_DIR = dir;
process.env.STOREBOUND_PROFILE = 'acme';
const root = mkdtempSync(join(tmpdir(), 'sb-root-'));
expect(profileEnv('acme').PLAY_REPORTS_BUCKET).toBe(`gs://${BUCKET}/`);
expect(playReportsBucket(root)).toBe(`gs://${BUCKET}/`);
process.env.PLAY_REPORTS_BUCKET = 'pubsite_prod_rev_999';
expect(playReportsBucket(root)).toBe('pubsite_prod_rev_999');
expect(playReportsBucket(root, BUCKET)).toBe(BUCKET);
process.env.PLAY_REPORTS_BUCKET = 'my-bucket';
expect(() => playReportsBucket(root)).toThrow('PLAY_REPORTS_BUCKET');
delete process.env.PLAY_REPORTS_BUCKET;
process.env.STOREBOUND_PROFILE = '';
expect(playReportsBucket(root)).toBeNull();
});
});
describe('day ranges and daily sales', () => {
test('reportRange: --days ends yesterday, --from/--to inclusive, checks', () => {
const now = new Date('2026-10-26T10:00:00Z');
expect(reportRange({}, now)).toBeNull();
expect(reportRange({ days: '3' }, now)).toEqual({ from: '2026-10-23', to: '2026-10-25', dates: ['2026-10-23', '2026-10-24', '2026-10-25'] });
expect(reportRange({ from: '2026-10-24' }, now)!.dates).toEqual(['2026-10-24', '2026-10-25']);
expect(reportRange({ from: '2026-10-24', to: '2026-10-24' }, now)!.dates).toEqual(['2026-10-24']);
expect(() => reportRange({ days: '0' }, now)).toThrow('--days');
expect(() => reportRange({ days: '2', from: '2026-10-01' }, now)).toThrow('not both');
expect(() => reportRange({ from: '2026-10-03', to: '2026-10-01' }, now)).toThrow('after');
expect(() => reportRange({ from: '10/03/2026' }, now)).toThrow('YYYY-MM-DD');
expect(() => reportRange({ to: '2026-10-03' }, now)).toThrow('--from');
expect(dateRange('2026-02-27', '2026-03-02')).toHaveLength(4);
});
test('rangeReport: totals per currency over the days, the dates without a report listed', () => {
const tsv = (units: number) => ['Provider\tSKU\tTitle\tUnits\tDeveloper Proceeds\tCurrency of Proceeds', `APPLE\tcom.example.pro\tPro\t${units}\t2.5\tUSD`, 'APPLE\tcom.example.pro\tPro\t1\t3\tEUR', ''].join('\n');
const day = (date: string, units: number) => { const summary = summarizeAppleSales(parseTsv(tsv(units))); return { date, rows: 2, summary, totals: totalsByCurrency(summary) }; };
const r = rangeReport([day('2026-10-02', 4), day('2026-10-01', 2)], ['2026-10-01', '2026-10-02', '2026-10-03']);
expect(r.days.map(d => d.date)).toEqual(['2026-10-01', '2026-10-02']);
expect(r.totals).toEqual([{ currency: 'EUR', units: 2, amount: 6 }, { currency: 'USD', units: 6, amount: 15 }]);
expect(r.missing).toEqual(['2026-10-03']);
});
test('Play sales per order-charged date and currency: charged, refunds (negative), net', () => {
const csv = 'Order Number,Order Charged Date,Financial Status,SKU ID,Currency of Sale,Item Price,Taxes Collected,Charged Amount\n' +
'A1,2026-10-01,Charged,pro,USD,4.99,0.00,4.99\nA2,2026-10-01,Charged,pro,USD,4.00,0.50,4.50\nA3,2026-10-01,Charged,pro,EUR,5.00,1.00,6.00\n' +
'A4,2026-10-02,Refund,pro,USD,4.99,0.00,4.99\nA5,2026-10-02,Refund,pro,USD,-1.00,0.00,-1.00\nA6,2026-09-30,Charged,pro,USD,9,0,9\nA7,2026-10-02,Other,pro,USD,9,0,9\n';
const d = summarizePlaySalesByDay(parsePlayCsv(csv), '2026-10-01', '2026-10-03');
expect(d).toEqual([
{ date: '2026-10-01', currency: 'EUR', orders: 1, refunds: 0, itemPrice: 5, taxes: 1, charged: 6, refunded: 0, net: 6 },
{ date: '2026-10-01', currency: 'USD', orders: 2, refunds: 0, itemPrice: 8.99, taxes: 0.5, charged: 9.49, refunded: 0, net: 9.49 },
{ date: '2026-10-02', currency: 'USD', orders: 0, refunds: 2, itemPrice: 0, taxes: 0, charged: 0, refunded: -5.99, net: -5.99 }]);
expect(playSalesTotals(d)).toEqual([{ currency: 'EUR', orders: 1, refunds: 0, charged: 6, refunded: 0, net: 6 }, { currency: 'USD', orders: 2, refunds: 2, charged: 9.49, refunded: -5.99, net: 3.5 }]);
});
});
// ---- the commands, as the collector calls them ----
const md5 = (s: string) => createHash('md5').update(s).digest('hex');
describe('analytics snapshot on a store-only config', () => {
const d1 = iso(-1), d2 = iso(-2);
const ENG = `Date\tEvent\tCounts\tUnique Counts\n${d1}\tImpression\t100\t80\n${d1}\tPage view\t30\t25\n${d2}\tImpression\t60\t50\n`;
const DL = `Date,Download Type,Counts\n${d1},First-time Download,10\n${d2},Redownload,2\n`;
const SES = `Date\tSessions\tTotal Session Duration\tUnique Devices\n${d1}\t200\t6000\t90\n${d2}\t150\t4000\t70\n`;
const CRA = `Date\tCrashes\tUnique Devices\n${d1}\t3\t2\n`;
const files: Record<string, string> = { eng: ENG, dl: DL, ses: SES, cra: CRA };
const reportsList = [
{ id: 'r-eng', attributes: { category: 'APP_STORE_ENGAGEMENT', name: 'App Store Discovery and Engagement Standard' } },
{ id: 'r-dl', attributes: { category: 'COMMERCE', name: 'App Store Downloads Standard' } },
{ id: 'r-ses', attributes: { category: 'APP_USAGE', name: 'App Sessions Standard' } },
{ id: 'r-cra', attributes: { category: 'APP_USAGE', name: 'App Crashes Standard' } },
];
const appleRoutes = (list = reportsList) => [
{ match: /\/v1\/apps\/6400000101\/analyticsReportRequests\?/, answer: { data: [{ id: 'req1', attributes: {} }] } },
{ match: /\/v1\/analyticsReportRequests\/req1\/reports\?/, answer: { data: list } },
{ match: /\/v1\/analyticsReports\/r-(\w+)\/instances\?/, answer: (c: Req) => { const k = /r-(\w+)\//.exec(decodeURIComponent(c.path))![1]!; return { data: [{ id: `i-${k}`, attributes: { processingDate: iso(0) } }] }; } },
{ match: /\/v1\/analyticsReportInstances\/i-(\w+)\/segments\?/, answer: (c: Req) => { const k = /i-(\w+)\//.exec(decodeURIComponent(c.path))![1]!; return { data: [{ id: `s-${k}`, attributes: { url: `https://segments.example.com/seg/${k}`, checksum: md5(files[k]!) } }] }; } },
{ match: /^\/seg\/(\w+)$/, answer: (c: Req) => new Response(files[c.path.slice(5)]!) },
];
const virtualConfig = (): ShipConfig => ({ ...BASE, apple: { bundleId: null as unknown as string, appId: '6400000101' }, play: { packageName: 'com.example.notes' } });
test('apple: appId only, App Usage per day, --json on stdout, --no-write leaves no file', async () => {
ascEnv();
const p = project(virtualConfig());
const calls = mockFetch(appleRoutes());
const r = await capture(() => analytics(p.ctx(['snapshot', '--store', 'apple', '--days', '3', '--json', '--no-write'])));
const snap = jsonOut(r.out) as { apple: { daily: Record<string, string | number>[]; totals: Record<string, number> } };
expect(snap.apple.daily.find(x => x.date === d1)).toMatchObject({ impressions: 100, pageViews: 30, downloads: 10, sessions: 200, activeDevices: 90, sessionSeconds: 6000, crashes: 3 });
expect(snap.apple.daily.find(x => x.date === d2)).toMatchObject({ sessions: 150, activeDevices: 70 });
expect(snap.apple.totals).toMatchObject({ sessions: 350, crashes: 3, activeDevicesAvg: 80 });
expect(calls.some(c => /filter\[bundleId\]/.test(decodeURIComponent(c.path)))).toBe(false);
expect(existsSync(join(p.root, '.storebound/analytics'))).toBe(false);
});
test('apple: without the usage reports the rest still comes, with a note', async () => {
ascEnv();
const p = project(virtualConfig());
mockFetch(appleRoutes(reportsList.slice(0, 2)));
const r = await capture(() => analytics(p.ctx(['snapshot', '--store', 'apple', '--days', '3', '--json'])));
const snap = jsonOut(r.out) as { apple: { daily: Record<string, string | number>[]; notes: string[] } };
expect(snap.apple.daily.find(x => x.date === d1)!.impressions).toBe(100);
expect(snap.apple.daily[0]!.sessions).toBeUndefined();
expect(snap.apple.notes[0]).toContain('App Usage');
expect(existsSync(join(p.root, '.storebound/analytics', `${iso(0)}.json`))).toBe(true);
});
test('play: the bucket from env, install stock numbers, daily user installs', async () => {
playEnv();
process.env.PLAY_REPORTS_BUCKET = BUCKET;
const p = project(virtualConfig());
const month = (d: string) => d.slice(0, 7).replace('-', '');
const inMonth = (m: string) => [d2, d1].filter(d => month(d) === m);
const mOf = (c: Req) => /_(\d{6})_/.exec(decodeURIComponent(c.path))![1]!;
mockFetch([
{ match: /store_performance_com\.example\.notes_(\d{6})_country\.csv\?alt=media$/, answer: (c: Req) => new Response(`Date,Package name,Country,Store listing acquisitions,Store listing visitors\n${inMonth(mOf(c)).map(d => `${d},com.example.notes,US,5,50`).join('\n')}\n`) },
{ match: /installs_com\.example\.notes_(\d{6})_country\.csv\?alt=media$/, answer: (c: Req) => new Response(`Date,Package Name,Country,Daily User Installs,Total User Installs,Active Device Installs\n${inMonth(mOf(c)).map(d => `${d},com.example.notes,US,4,100,60`).join('\n')}\n`) },
{ match: /installs_com\.example\.notes_(\d{6})_overview\.csv\?alt=media$/, answer: (c: Req) => new Response(`Date,Package Name,Total User Installs,Active Device Installs\n${inMonth(mOf(c)).map(d => `${d},com.example.notes,${d === d1 ? 120 : 110},${d === d1 ? 70 : 65}`).join('\n')}\n`) },
]);
const r = await capture(() => analytics(p.ctx(['snapshot', '--store', 'play', '--days', '3', '--json', '--dry'])));
const snap = jsonOut(r.out) as { play: { daily: Record<string, string | number>[]; totals: Record<string, number> } };
expect(snap.play.daily.find(x => x.date === d1)).toMatchObject({ visitors: 50, acquisitions: 5, installs: 4, activeDeviceInstalls: 70, totalUserInstalls: 120 });
expect(snap.play.totals).toMatchObject({ installs: 8, activeDeviceInstalls: 70, totalUserInstalls: 120 });
});
test('play without any bucket: the command fails (nothing to write)', async () => {
playEnv();
const p = project(virtualConfig());
mockFetch([]);
await expect(capture(() => analytics(p.ctx(['snapshot', '--store', 'play', '--days', '3', '--json'])))).rejects.toThrow('nothing written');
});
});
describe('reports over a day range', () => {
test('apple-sales --days: one JSON with the days, totals per currency, the missing day listed', async () => {
ascEnv();
process.env.ASC_VENDOR_NUMBER = '12345678';
const p = project();
const yesterday = iso(-1), before = iso(-2);
const tsv = (units: number, proceeds: string, cur: string) => ['Provider\tSKU\tTitle\tUnits\tDeveloper Proceeds\tCurrency of Proceeds', `APPLE\tcom.example.notes.pro\tPro\t${units}\t${proceeds}\t${cur}`, ''].join('\n');
const calls = mockFetch([{ match: /^\/v1\/salesReports\?/, answer: (c: Req) => {
const date = /reportDate\]=(\d{4}-\d{2}-\d{2})/.exec(decodeURIComponent(c.path))![1]!;
return date === yesterday ? new Response('{"errors":[{"status":"404","code":"NOT_FOUND"}]}', { status: 404 }) : new Response(Bun.gzipSync(new TextEncoder().encode(tsv(2, '3.49', date === before ? 'USD' : 'EUR'))));
} }]);
const r = await capture(() => reports(p.ctx(['apple-sales', '--days', '3', '--json'])));
const j = jsonOut(r.out) as { from: string; to: string; days: { date: string; totals: unknown[] }[]; totals: unknown[]; missing: string[]; rows: number };
expect(calls).toHaveLength(3);
expect(j.to).toBe(yesterday);
expect(j.days.map(d => d.date)).toEqual([iso(-3), before]);
expect(j.missing).toEqual([yesterday]);
expect(j.totals).toEqual([{ currency: 'EUR', units: 2, amount: 6.98 }, { currency: 'USD', units: 2, amount: 6.98 }]);
expect(j.rows).toBe(2);
expect(existsSync(join(p.root, `.storebound/reports/apple-sales-daily-${before}.tsv`))).toBe(true);
expect(readFileSync(join(p.root, `.storebound/reports/apple-sales-daily-${iso(-3)}_${yesterday}.summary.csv`), 'utf8')).toContain('date,key,title,currency,units,amount,rows');
});
test('apple-sales with a --type other than SALES, or --days on finance, is refused', async () => {
ascEnv();
const p = project();
await expect(capture(() => reports(p.ctx(['apple-sales', '--days', '3', '--type', 'SUBSCRIPTION', '--vendor', '12345678'])))).rejects.toThrow('SALES');
await expect(capture(() => reports(p.ctx(['apple-finance', '--days', '3', '--month', '2026-09', '--vendor', '12345678'])))).rejects.toThrow('monthly');
});
test('play-sales --days: the daily revenue per currency from the monthly sales zips; play-earnings --days is refused', async () => {
playEnv();
process.env.PLAY_REPORTS_BUCKET = BUCKET;
const p = project({ ...BASE, play: { packageName: 'com.example.notes' } });
const d1 = iso(-1), d2 = iso(-2);
const months = new Set([d1, d2].map(d => d.slice(0, 7).replace('-', '')));
const csv = `Order Number,Order Charged Date,Financial Status,SKU ID,Currency of Sale,Item Price,Taxes Collected,Charged Amount\nA1,${d1},Charged,pro,USD,4.99,0,4.99\nA2,${d2},Charged,pro,USD,4.99,0,4.99\nA3,${d2},Refund,pro,USD,4.99,0,4.99\n`;
mockFetch([
{ match: /\/o\?prefix=sales\/salesreport_(\d{6})$/, answer: (c: Req) => { const m = /salesreport_(\d{6})/.exec(decodeURIComponent(c.path))![1]!; return { items: months.has(m) ? [{ name: `sales/salesreport_${m}.zip` }] : [] }; } },
{ match: /\/o\/sales\/salesreport_\d{6}\.zip\?alt=media$/, answer: () => new Response(zip({ 'salesreport.csv': csv }) as Uint8Array<ArrayBuffer>) },
]);
const r = await capture(() => reports(p.ctx(['play-sales', '--days', '2', '--json'])));
const j = jsonOut(r.out) as { days: { date: string; net: number }[]; totals: unknown[]; emptyDays: string[] };
// one zip per month the window touches: a window across a month end reads the same fixture twice, so compare per day
const once = months.size === 1;
expect(j.days).toMatchObject([{ date: d2, currency: 'USD', orders: once ? 1 : 2, refunds: once ? 1 : 2 }, { date: d1, orders: once ? 1 : 2 }]);
if (once) expect(j.totals).toEqual([{ currency: 'USD', orders: 2, refunds: 1, charged: 9.98, refunded: -4.99, net: 4.99 }]);
expect(j.emptyDays).toEqual([]);
expect(existsSync(join(p.root, `.storebound/reports/play-sales-daily-${d2}_${d1}.summary.csv`))).toBe(true);
await expect(capture(() => reports(p.ctx(['play-earnings', '--days', '2'])))).rejects.toThrow('monthly');
});
});