// 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): 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): 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 = { 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[]; totals: Record } }; 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[]; 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[]; totals: Record } }; 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) }, ]); 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'); }); });