// Apple Ads: client secret, token request, headers, report bodies and rows, CSV, and the write guards. No network: // fetch is replaced per test and restored after. Response shapes follow Apple's documented examples (placeholder ids). import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { generateKeyPairSync, verify } from 'node:crypto'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { adsClient, retryWait } from '../cli/ads/client'; import { run } from '../cli/ads/apple'; import { aclRows, adsHeaders, campaignRow, changeLine, clientSecret, CSV_HEAD, flattenRows, nextOffset, parseAmount, reportBody, reportCsv, reportTable, reportWindow, tokenForm, totals, } from '../cli/lib/apple-ads'; import { parseArgs } from '../cli/lib/args'; import { parseCsv } from '../cli/lib/csv'; import type { Ctx } from '../cli/lib/ctx'; const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); const PEM = privateKey.export({ type: 'sec1', format: 'pem' }).toString(); const CREDS = { clientId: 'SEARCHADS.client-placeholder', teamId: 'SEARCHADS.team-placeholder', keyId: 'key-placeholder' }; const dec = (s: string) => JSON.parse(Buffer.from(s, 'base64url').toString()); const ENV = { APPLE_ADS_CLIENT_ID: CREDS.clientId, APPLE_ADS_TEAM_ID: CREDS.teamId, APPLE_ADS_KEY_ID: CREDS.keyId, APPLE_ADS_PRIVATE_KEY: PEM, APPLE_ADS_ACCOUNT_ID: '123456789' }; let saved: Record = {}; let realFetch: typeof fetch; let root = ''; let tty: PropertyDescriptor | undefined; beforeEach(() => { realFetch = globalThis.fetch; saved = Object.fromEntries([...Object.keys(ENV), 'STOREBOUND_SERVE', 'STOREBOUND_MACHINE'].map(k => [k, process.env[k]])); Object.assign(process.env, ENV); delete process.env.STOREBOUND_SERVE; delete process.env.STOREBOUND_MACHINE; root = mkdtempSync(join(tmpdir(), 'sb-ads-')); tty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); }); afterEach(() => { globalThis.fetch = realFetch; for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } rmSync(root, { recursive: true, force: true }); if (tty) Object.defineProperty(process.stdin, 'isTTY', tty); else delete (process.stdin as { isTTY?: boolean }).isTTY; }); interface Call { url: string; method: string; headers: Record; body: string } function mockFetch(handler: (c: Call) => { status?: number; json?: unknown; headers?: Record }): Call[] { const calls: Call[] = []; globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { const c: Call = { url: String(url), method: init?.method ?? 'GET', headers: (init?.headers ?? {}) as Record, body: String(init?.body ?? '') }; calls.push(c); const r = handler(c); return new Response(r.json === undefined ? '' : JSON.stringify(r.json), { status: r.status ?? 200, headers: r.headers }); }) as typeof fetch; return calls; } const TOKEN = { access_token: 'token-placeholder', token_type: 'Bearer', expires_in: 3600, scope: 'searchadsorg' }; const CAMPAIGN = { id: 444555666, adAccountId: 123456789, name: 'Example - US', promotedObjectType: 'APPSTORE_APP', promotedObjectId: '987654321', status: 'ENABLED', systemStatus: 'RUNNING', displayStatus: 'RUNNING', dailyBudget: { value: { amount: '100.00', currency: 'USD' } }, targeting: { countryOrRegion: { include: ['US', 'CA'] } }, bidStrategy: { bidStrategyType: 'MANUAL_CPT', bidStrategyGoal: 'TAP', bid: { amount: '1.50', currency: 'USD' } }, deleted: false, }; const KEYWORD = { id: 888999000, adAccountId: 123456789, campaignId: 444555666, adGroupId: 555666777, text: 'photo editor', matchType: 'EXACT', bid: { amount: '2.50', currency: 'USD' }, status: 'ENABLED', deleted: false }; const CAMPAIGN_REPORT = { result: { rows: [ { metadata: { id: 444555666, name: 'Example - US', promotedObjectId: '987654321' }, totalMetrics: { localSpend: { amount: '500.00', currency: 'USD' }, impressions: 50000, taps: 2500, ttr: 0.05, tapInstalls: 600, viewInstalls: 120, totalInstalls: 720, totalNewDownloads: 650, totalRedownloads: 70 } }, { metadata: { id: 444555667, name: 'Example - Brand' }, totalMetrics: { localSpend: { amount: '12.34', currency: 'USD' }, impressions: 900, taps: 10, ttr: 0.0111, tapInstalls: 0, viewInstalls: 0, totalInstalls: 0 } }, ], summary: { grandTotal: { localSpend: { amount: '512.34', currency: 'USD' } } }, }, pagination: { offset: 0, pageSize: 1000, totalCount: 2 }, }; function ctx(argv: string[], appId?: string): Ctx { return { args: parseArgs(argv), loaded: { config: { apple: { bundleId: 'com.example.app', appId } } as never, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) } }; } async function quiet(f: () => Promise): Promise<{ out: string; value: T }> { const lines: string[] = []; const log = console.log, warn = console.warn, err = console.error; console.log = (...a: unknown[]) => { lines.push(a.join(' ')); }; console.warn = console.log; console.error = console.log; try { return { value: await f(), out: lines.join('\n') }; } finally { console.log = log; console.warn = warn; console.error = err; } } describe('client secret (ES256 JWT)', () => { test('header, claims and a valid P-256 signature', () => { const now = 1_800_000_000; const jwt = clientSecret(CREDS, PEM, now); const [h, p, s] = jwt.split('.') as [string, string, string]; expect(dec(h)).toEqual({ alg: 'ES256', kid: CREDS.keyId }); expect(dec(p)).toEqual({ sub: CREDS.clientId, iss: CREDS.teamId, aud: 'https://appleid.apple.com', iat: now, exp: now + 86400 }); expect(Buffer.from(s, 'base64url').length).toBe(64); expect(verify('sha256', Buffer.from(`${h}.${p}`), { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(s, 'base64url'))).toBe(true); }); test('lifetime capped at 180 days', () => { expect(() => clientSecret(CREDS, PEM, 0, 181 * 86400)).toThrow(/180 days/); expect(dec(clientSecret(CREDS, PEM, 0, 180 * 86400).split('.')[1]!).exp).toBe(180 * 86400); }); }); describe('requests', () => { test('token form and headers', () => { expect(Object.fromEntries(tokenForm('cid', 'sec'))).toEqual({ grant_type: 'client_credentials', client_id: 'cid', client_secret: 'sec', scope: 'searchadsorg' }); expect(adsHeaders('t', '42')).toMatchObject({ Authorization: 'Bearer t', 'X-AP-Context': 'adAccountId=42' }); expect(adsHeaders('t')['X-AP-Context']).toBeUndefined(); }); test('report body: campaignId filter, UTC window, grand total, no granularity', () => { const b = reportBody({ start: '2026-10-01', end: '2026-10-07', campaignId: '444555666', adGroupId: '555666777' }); expect(b).toEqual({ pagination: { offset: 0, pageSize: 1000 }, sorting: [{ field: 'localSpend', order: 'DESC' }], filters: [{ field: 'campaignId', operator: 'EQUALS', value: '444555666' }, { field: 'adGroupId', operator: 'EQUALS', value: '555666777' }], timeRange: { start: '2026-10-01', end: '2026-10-07', timeZone: 'UTC' }, options: { includeRows: ['GRAND_TOTAL'] }, }); }); test('window: --days ends yesterday, --start/--end checked', () => { expect(reportWindow({ days: 7 }, new Date('2026-10-11T12:00:00Z'))).toEqual({ start: '2026-10-04', end: '2026-10-10' }); expect(reportWindow({ start: '2026-09-01', end: '2026-09-30' })).toEqual({ start: '2026-09-01', end: '2026-09-30' }); expect(() => reportWindow({ start: '2026-09-30', end: '2026-09-01' })).toThrow(/after/); expect(() => reportWindow({ start: '2026-09-01' })).toThrow(/both/); }); test('amounts and change lines', () => { expect(parseAmount('2.5', '--bid')).toBe('2.5'); for (const bad of ['05.00', '-1', '0', '1.234', 'abc', undefined]) expect(() => parseAmount(bad, '--bid')).toThrow(); expect(changeLine('1.50', '2.00', 'USD')).toBe('1.50 -> 2.00 USD (+33%)'); expect(changeLine(null, '2.00', 'USD')).toBe('(default) -> 2.00 USD'); }); test('retry wait: Retry-After first, else exponential to 16 s', () => { expect(retryWait('7', 1)).toBe(7); expect([1, 2, 3, 4, 5].map(a => retryWait(null, a))).toEqual([2, 4, 8, 16, 16]); }); }); describe('rows', () => { test('flatten, CPA null with 0 installs, totals', () => { const rows = flattenRows('campaign', CAMPAIGN_REPORT); expect(rows[0]).toMatchObject({ campaignId: '444555666', campaign: 'Example - US', currency: 'USD', spend: 500, installs: 720, tapInstalls: 600, viewInstalls: 120, newDownloads: 650 }); expect(rows[0]!.cpa).toBeCloseTo(500 / 720, 10); expect(rows[0]!.avgCpt).toBeCloseTo(0.2, 10); expect(rows[0]!.ttr).toBeCloseTo(0.05, 10); expect(rows[0]!.conversionRate).toBeCloseTo(720 / 2500, 10); expect(rows[1]!.cpa).toBeNull(); const t = totals(rows); expect(t).toMatchObject({ currency: 'USD', spend: 512.34, impressions: 50900, taps: 2510, installs: 720 }); expect(reportTable(rows, t)).toContain('TOTAL'); expect(reportTable(rows, t)).toContain('n/a'); expect(nextOffset(CAMPAIGN_REPORT)).toBeNull(); expect(nextOffset({ pagination: { offset: 0, pageSize: 2, totalCount: 5 } })).toBe(2); }); test('keyword rows carry ids, text and the campaign name', () => { const res = { result: { rows: [{ metadata: { id: 888999000, text: 'photo editor', matchType: 'EXACT', campaignId: 444555666, adGroupId: 555666777, adGroup: { name: 'Exact' } }, totalMetrics: { localSpend: { amount: '75.00', currency: 'USD' }, impressions: 5000, taps: 250, tapInstalls: 60, totalInstalls: 72 } }] } }; const [r] = flattenRows('keyword', res, new Map([['444555666', 'Example - US']])); expect(r).toMatchObject({ level: 'keyword', keywordId: '888999000', keyword: 'photo editor', adGroup: 'Exact', campaign: 'Example - US', installs: 72 }); }); test('CSV: header, one line per row, empty CPA cell with 0 installs', () => { const csv = parseCsv(reportCsv(flattenRows('campaign', CAMPAIGN_REPORT))); expect(csv[0]).toEqual(CSV_HEAD); expect(csv.length).toBe(3); const cpa = CSV_HEAD.indexOf('cpa'); expect(csv[1]![cpa]).toBe('0.69'); expect(csv[2]![cpa]).toBe(''); expect(csv[1]![CSV_HEAD.indexOf('spend')]).toBe('500.00'); }); test('acls and campaigns', () => { const acl = aclRows({ result: { acls: [{ adAccount: { id: 123456789, name: 'Example', orgId: 1 }, roles: ['API Account Read Only'] }] } }, '123456789'); expect(acl).toEqual([{ accountId: '123456789', name: 'Example', orgId: '1', roles: ['API Account Read Only'], used: true }]); expect(campaignRow(CAMPAIGN, '987654321')).toMatchObject({ id: '444555666', dailyBudget: '100.00', currency: 'USD', countries: ['US', 'CA'], adamId: '987654321', thisApp: true }); }); }); describe('client (mocked fetch)', () => { test('token request then X-AP-Context; token cached in memory', async () => { const calls = mockFetch(c => (c.url.includes('appleid.apple.com') ? { json: TOKEN } : { json: { result: CAMPAIGN } })); const ads = adsClient(root); await ads.get('/campaigns/444555666'); await ads.get('/campaigns/444555666'); expect(calls.length).toBe(3); const tok = calls[0]!; expect(tok.url).toBe('https://appleid.apple.com/auth/oauth2/token'); expect(tok.method).toBe('POST'); expect(tok.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); const form = new URLSearchParams(tok.body); expect([form.get('grant_type'), form.get('client_id'), form.get('scope')]).toEqual(['client_credentials', CREDS.clientId, 'searchadsorg']); expect(dec(form.get('client_secret')!.split('.')[1]!)).toMatchObject({ sub: CREDS.clientId, iss: CREDS.teamId, aud: 'https://appleid.apple.com' }); expect(calls[1]!.url).toBe('https://api.ads.apple.com/v1/campaigns/444555666'); expect(calls[1]!.headers).toMatchObject({ Authorization: 'Bearer token-placeholder', 'X-AP-Context': 'adAccountId=123456789' }); }); test('429 retried after Retry-After; errors never carry the token', async () => { let n = 0; const waits: number[] = []; mockFetch(c => (c.url.includes('appleid') ? { json: TOKEN } : ++n === 1 ? { status: 429, json: { error: { code: 'rate_limit_exceeded' } }, headers: { 'Retry-After': '3' } } : { status: 403, json: { error: { code: 'forbidden' } } })); const ads = adsClient(root, { sleep: async ms => { waits.push(ms); } }); const e = await ads.get('/acls', false).catch(x => x as Error); expect(waits).toEqual([3000]); expect(String(e)).toContain('403'); expect(String(e)).not.toContain('token-placeholder'); }); test('a failed token request names the status, not the secret', async () => { mockFetch(() => ({ status: 401, json: { error: 'invalid_client' } })); const e = await adsClient(root).token().catch(x => x as Error); expect(String(e)).toContain('401 invalid_client'); expect(String(e)).not.toMatch(/eyJ/); }); test('missing credentials and template placeholders are reported by name', () => { delete process.env.APPLE_ADS_KEY_ID; process.env.APPLE_ADS_CLIENT_ID = 'SEARCHADS.00000000-0000-0000-0000-000000000000'; expect(() => adsClient(root)).toThrow(/APPLE_ADS_CLIENT_ID, APPLE_ADS_KEY_ID not set/); }); }); describe('command (mocked fetch)', () => { const api = (c: Call) => { if (c.url.includes('appleid.apple.com')) return { json: TOKEN }; const path = c.url.replace('https://api.ads.apple.com/v1', ''); if (path === '/keywords/888999000' && c.method === 'GET') return { json: { result: KEYWORD } }; if (path === '/campaigns/444555666' && c.method === 'GET') return { json: { result: CAMPAIGN } }; if (path === '/campaigns/query') return { json: { result: [CAMPAIGN], pagination: { offset: 0, pageSize: 1000, totalCount: 1 } } }; if (path === '/reports/apps/campaigns/query') return { json: CAMPAIGN_REPORT }; if (c.method === 'PUT') return { json: { result: {} } }; return { status: 404, json: { error: { code: 'not_found' } } }; }; test('bid --dry reads, prints old -> new, sends no PUT', async () => { const calls = mockFetch(api); const { out } = await quiet(() => run(ctx(['bid', '--keyword', '888999000', '--bid', '3.75', '--dry']))); expect(calls.some(c => c.method === 'PUT')).toBe(false); expect(out).toContain('2.50 -> 3.75 USD (+50%)'); expect(out).toContain('dry run: would PUT /keywords/888999000 {"bid":{"amount":"3.75","currency":"USD"}}'); }); test('budget without a terminal and without --yes sends no PUT', async () => { const calls = mockFetch(api); const { out } = await quiet(() => run(ctx(['budget', '--campaign', '444555666', '--daily', '150.00']))); expect(out).toContain('100.00 -> 150.00 USD (+50%)'); expect(out).toContain('not a terminal'); expect(calls.some(c => c.method === 'PUT')).toBe(false); }); test('pause under storebound serve is refused even with --yes', async () => { process.env.STOREBOUND_SERVE = '1'; const calls = mockFetch(api); await quiet(() => run(ctx(['pause', '--campaign', '444555666', '--yes']))); expect(calls.some(c => c.method === 'PUT')).toBe(false); }); test('budget with --yes in a terminal-less run sends the documented body', async () => { const calls = mockFetch(api); await quiet(() => run(ctx(['budget', '--campaign', '444555666', '--daily', '150.00', '--yes']))); const put = calls.find(c => c.method === 'PUT')!; expect(put.url).toBe('https://api.ads.apple.com/v1/campaigns/444555666'); expect(JSON.parse(put.body)).toEqual({ dailyBudget: { value: { amount: '150.00', currency: 'USD' } } }); }); test('bid rejects a keyword from another ad group', async () => { mockFetch(api); await expect(quiet(() => run(ctx(['bid', '--keyword', '888999000', '--adgroup', '1', '--bid', '1.00', '--dry'])))).rejects.toThrow(/belongs to ad group/); }); test('report --csv writes the rows; with --dry it writes nothing', async () => { const calls = mockFetch(api); await quiet(() => run(ctx(['report', '--days', '7', '--csv', 'dry.csv', '--dry']))); expect(() => readFileSync(join(root, 'dry.csv'))).toThrow(); const { out } = await quiet(() => run(ctx(['report', '--start', '2026-10-01', '--end', '2026-10-07', '--csv', 'ads.csv'], '987654321'))); const csv = parseCsv(readFileSync(join(root, 'ads.csv'), 'utf8')); expect(csv.length).toBe(3); expect(out).toContain('TOTAL'); const rep = calls.filter(c => c.url.endsWith('/reports/apps/campaigns/query')).at(-1)!; expect(rep.method).toBe('POST'); expect(JSON.parse(rep.body)).toMatchObject({ filters: [{ field: 'campaignId', operator: 'EQUALS', value: '444555666' }], timeRange: { start: '2026-10-01', end: '2026-10-07', timeZone: 'UTC' } }); expect(calls.some(c => c.method === 'PUT')).toBe(false); }); test('report --json: stable shape', async () => { mockFetch(api); const { out } = await quiet(() => run(ctx(['report', '--start', '2026-10-01', '--end', '2026-10-07', '--json']))); const j = JSON.parse(out); expect(Object.keys(j)).toEqual(['accountId', 'by', 'start', 'end', 'timeZone', 'campaigns', 'rows', 'totals']); expect(j.rows[1].cpa).toBeNull(); }); });