confirm() prints 'declined: <reason>' and exits 3 whenever the answer is no or nobody can answer (not a terminal without --yes, serve without the owner's dialog), so no command reports success after declining. flow run stops at such a step with 'step N declined: <reason>' (status declined), never ok. flow run --apply --yes passes --yes to write steps only; --yes without --apply is refused. Pitfall 24.
280 lines
17 KiB
TypeScript
280 lines
17 KiB
TypeScript
// 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<string, string | undefined> = {};
|
|
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(() => {
|
|
process.exitCode = 0; // a declined confirmation sets it (DECLINED_EXIT); the tests below check it
|
|
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<string, string>; body: string }
|
|
function mockFetch(handler: (c: Call) => { status?: number; json?: unknown; headers?: Record<string, string> }): 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<string, string>, 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<T>(f: () => Promise<T>): 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);
|
|
expect(process.exitCode).toBe(3);
|
|
});
|
|
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);
|
|
expect(process.exitCode).toBe(3);
|
|
});
|
|
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();
|
|
});
|
|
});
|