323 lines
20 KiB
TypeScript
323 lines
20 KiB
TypeScript
// aso competitors / suggest / audit: pure parts and the commands against recorded iTunes Search API answers
|
|
// (tests/fixtures/aso, real response shape, placeholder apps) and mocked Apple Ads keyword suggestions.
|
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import { generateKeyPairSync } from 'node:crypto';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { run as aso } from '../cli/aso';
|
|
import { run as check } from '../cli/check';
|
|
import { auditText, buildAudit, nextActions, playUse, rankTrend } from '../cli/lib/aso-audit';
|
|
import {
|
|
bigrams, candidates, categoryNames, competitorRow, coverageGaps, DRAFT_RE, draftLocales, draftMark, fitKeywordField, keywordSuggestionBody, newEntrants,
|
|
parseKeywordSuggestions, sameWord, setKeywords, stem, storefrontOf, tokens,
|
|
} from '../cli/lib/aso-intel';
|
|
import type { SearchApp } from '../cli/lib/aso-rank';
|
|
import { FORBIDDEN_ARGS, TOOLS, toArgv } from '../cli/mcp-tools';
|
|
import { JSON_TOOLS, methodName } from '../cli/serve';
|
|
import { BASE, capture, jsonOut, mockFetch, project, restore } from './helpers/store-mock';
|
|
|
|
const FIX = join(import.meta.dir, 'fixtures/aso');
|
|
const US = JSON.parse(readFileSync(join(FIX, 'search-grocery-list-us.json'), 'utf8')) as { results: SearchApp[] };
|
|
const DE = JSON.parse(readFileSync(join(FIX, 'search-einkaufsliste-de.json'), 'utf8')) as { results: SearchApp[] };
|
|
const ADS = JSON.parse(readFileSync(join(FIX, 'ads-keyword-suggestions.json'), 'utf8'));
|
|
const APP = { appId: '6400000101', bundleId: 'com.example.notes' };
|
|
const ADS_KEYS = ['APPLE_ADS_CLIENT_ID', 'APPLE_ADS_TEAM_ID', 'APPLE_ADS_KEY_ID', 'APPLE_ADS_PRIVATE_KEY', 'APPLE_ADS_ACCOUNT_ID'];
|
|
const saved: Record<string, string | undefined> = {};
|
|
|
|
beforeEach(() => { for (const k of ADS_KEYS) { saved[k] = process.env[k]; delete process.env[k]; } });
|
|
afterEach(() => { restore(); for (const k of ADS_KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } });
|
|
|
|
const LISTING = `# App Store
|
|
|
|
## en-US · English
|
|
|
|
### subtitle
|
|
\`\`\`
|
|
Shared grocery lists
|
|
\`\`\`
|
|
|
|
### promotionalText
|
|
\`\`\`
|
|
Promo.
|
|
\`\`\`
|
|
|
|
### keywords
|
|
\`\`\`
|
|
todo,checklist,list,family
|
|
\`\`\`
|
|
|
|
### description
|
|
\`\`\`
|
|
Example Notes keeps grocery lists and packing lists in one place. Share a packing list with family.
|
|
|
|
Sort groceries by aisle and reuse a packing checklist.
|
|
\`\`\`
|
|
|
|
## de-DE · Deutsch
|
|
|
|
### subtitle
|
|
\`\`\`
|
|
Gemeinsame Listen
|
|
\`\`\`
|
|
|
|
### promotionalText
|
|
\`\`\`
|
|
Promo.
|
|
\`\`\`
|
|
|
|
### keywords
|
|
\`\`\`
|
|
einkauf,familie
|
|
\`\`\`
|
|
|
|
### description
|
|
\`\`\`
|
|
Einkaufslisten für die ganze Familie.
|
|
\`\`\`
|
|
`;
|
|
const PLAY = '# Play\n\n## English\n<!-- play: en-US -->\n\n### title\n```\nExample Notes: Groceries\n```\n\n### short\n```\nShared lists\n```\n\n### full\n```\nGrocery lists for the family.\n```\n';
|
|
|
|
describe('words', () => {
|
|
test('tokens, bigrams, plural-insensitive and suffix-aware sameness', () => {
|
|
expect(tokens('The best Grocery Lists for your family app', 'en-US')).toEqual(['best', 'grocery', 'lists', 'family']);
|
|
expect(tokens("Ayarlar'dan sil", 'tr')).toEqual(['ayarlar', 'sil']);
|
|
expect(bigrams('Grocery lists. Packing lists and more', 'en-US')).toEqual(['grocery lists', 'packing lists']);
|
|
expect(stem('groceries', 'en-US')).toBe('grocery');
|
|
expect(stem('lists', 'en-US')).toBe('list');
|
|
expect(stem('lists', 'de-DE')).toBe('lists');
|
|
expect(sameWord('listeler', 'listelerini', 'tr')).toBe(true);
|
|
expect(sameWord('list', 'listing', 'en-US')).toBe(false);
|
|
expect(storefrontOf('en-US')).toBe('us');
|
|
expect(storefrontOf('tr')).toBe('tr');
|
|
expect(storefrontOf('ja')).toBe('jp');
|
|
});
|
|
});
|
|
|
|
describe('competitors (recorded Search API answers)', () => {
|
|
const row = competitorRow(US.results, APP, { storefront: 'us', locale: 'en-US', term: 'grocery list' }, 4);
|
|
test('top N other apps with release dates and localized genres; our own position', () => {
|
|
expect(row.ourRank).toBe(3);
|
|
expect(row.apps.map(a => a.rank)).toEqual([1, 2, 4, 5]);
|
|
expect(row.apps[0]).toMatchObject({ name: 'Basket: Grocery Shopping List', developer: 'Example Studio A', ratings: 80000, released: '2012-05-11', updated: '2026-09-11', genre: 'Productivity' });
|
|
expect(row.apps[0]!.genres).toEqual([{ id: '6007', name: 'Productivity' }, { id: '6024', name: 'Shopping' }]);
|
|
});
|
|
test('coverage gaps: words of two or more developers that we do not cover; one-developer words (brands) left out', () => {
|
|
const all = competitorRow(US.results, APP, { storefront: 'us', locale: 'en-US', term: 'grocery list' }, 10);
|
|
const gaps = coverageGaps([all], ['list', 'todo'], 'en-US');
|
|
expect(gaps.map(g => g.word)).toEqual(['grocery', 'shopping']);
|
|
expect(gaps[0]).toMatchObject({ apps: 3, developers: 3 });
|
|
expect(gaps.some(g => g.word === 'cartful')).toBe(false); // one developer, two apps: a brand
|
|
expect(coverageGaps([all], ['groceries', 'lists', 'shopping'], 'en-US')).toEqual([]);
|
|
});
|
|
test('new entrants and localized category names', () => {
|
|
const before = { ...row, apps: row.apps.slice(1) };
|
|
expect(newEntrants([row], [before]).get('us|grocery list')).toEqual(['Basket: Grocery Shopping List']);
|
|
expect(newEntrants([row], undefined).size).toBe(0);
|
|
const de = competitorRow(DE.results, APP, { storefront: 'de', locale: 'de-DE', term: 'einkaufsliste' }, 10);
|
|
expect(categoryNames([de], ['PRODUCTIVITY'])).toEqual(['Produktivität']);
|
|
expect(categoryNames([de], ['FOOD_AND_DRINK'])).toEqual(['Essen und Trinken']);
|
|
});
|
|
});
|
|
|
|
describe('Apple Ads keyword suggestions', () => {
|
|
test('request body: required filters, terms, countries, popularity order', () => {
|
|
const b = keywordSuggestionBody('6400000101', ['grocery'], ['us']) as { filters: { field: string; value: string[] }[]; sorting: unknown; pagination: unknown };
|
|
expect(b.filters.map(f => f.field)).toEqual(['promotedObjectId', 'promotedObjectType', 'terms', 'countriesOrRegions']);
|
|
expect(b.filters[1]!.value).toEqual(['APPSTORE_APP']);
|
|
expect(b.filters[3]!.value).toEqual(['US']);
|
|
expect(b.sorting).toEqual([{ field: 'popularity', order: 'DESC' }]);
|
|
expect(b.pagination).toEqual({ offset: 0, pageSize: 200 });
|
|
});
|
|
test('parse: text + popularity 0-100, junk left out', () => {
|
|
expect(parseKeywordSuggestions(ADS)).toHaveLength(4);
|
|
expect(parseKeywordSuggestions({ result: [{ text: 'x', popularity: 140 }, { text: 'y' }, null] })).toEqual([{ text: 'x', popularity: 100 }]);
|
|
expect(parseKeywordSuggestions({})).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('candidates and the 100-byte field', () => {
|
|
const comp = [competitorRow(US.results, APP, { storefront: 'us', locale: 'en-US', term: 'grocery list' }, 10)];
|
|
const base = { locale: 'en-US', name: 'Example Notes', subtitle: 'Shared grocery lists', keywords: 'todo,checklist,list,family', description: 'Grocery lists and packing lists. Share a packing list with family. Sort by aisle, reuse a packing checklist. Aisle sorting.', competitors: comp, categories: ['Productivity'] };
|
|
test('no popularity without Apple Ads; relevance and competition from own text and competitor names', () => {
|
|
const c = candidates({ ...base, ads: [] });
|
|
expect(c.every(x => x.popularity === null)).toBe(true);
|
|
const packing = c.find(x => x.term === 'packing')!;
|
|
expect(packing).toMatchObject({ status: 'candidate', relevance: 2, inNames: 1, ofApps: 5 });
|
|
expect(c.find(x => x.term === 'grocery')!.status).toBe('indexed');
|
|
expect(c.find(x => x.term === 'productivity')!.status).toBe('category');
|
|
expect(c.find(x => x.term === 'cartful')).toBeUndefined();
|
|
});
|
|
test('Apple Ads popularity: exact word, or the best phrase that contains it', () => {
|
|
const c = candidates({ ...base, ads: parseKeywordSuggestions(ADS) });
|
|
expect(c.find(x => x.term === 'packing')).toMatchObject({ popularity: 41, popularityFrom: 'exact' });
|
|
expect(c.find(x => x.term === 'shopping')).toMatchObject({ popularity: 64, popularityFrom: 'exact', sources: ['competitors', 'apple-ads'] });
|
|
expect(c.find(x => x.term === 'aisle')!.popularity).toBeNull();
|
|
});
|
|
test('fit: <= 100 characters, current terms kept, indexed and duplicate words dropped', () => {
|
|
const ranked = candidates({ ...base, ads: [] });
|
|
const p = fitKeywordField({ locale: 'en-US', current: base.keywords, name: base.name, subtitle: base.subtitle, ranked });
|
|
expect(p.kept).toEqual(['todo', 'checklist', 'family']);
|
|
expect(p.dropped).toEqual([{ term: 'list', why: 'already in the name / subtitle (indexed)' }]);
|
|
expect(p.added).toContain('packing');
|
|
expect(p.after).not.toMatch(/,\s|\s,/);
|
|
expect(p.chars).toBeLessThanOrEqual(100);
|
|
expect(new Set(p.after.split(',')).size).toBe(p.after.split(',').length);
|
|
// characters, not bytes: Turkish letters take two bytes and still count one (pitfall 43)
|
|
const many = Array.from({ length: 30 }, (_, i) => ({ term: `şğü${i}`, kind: 'word' as const, sources: ['description' as const], ours: 2, relevance: 1, inNames: 0, ofApps: 0, popularity: null, popularityFrom: null, status: 'candidate' as const }));
|
|
const t = fitKeywordField({ locale: 'tr', current: '', name: 'X', subtitle: '', ranked: many });
|
|
expect(t.chars).toBeLessThanOrEqual(100);
|
|
expect([...t.after].length).toBe(t.chars);
|
|
expect(Buffer.byteLength(t.after)).toBeGreaterThan(t.chars);
|
|
expect(fitKeywordField({ locale: 'en-US', current: 'todo', name: 'X', subtitle: '', ranked, fresh: true }).kept).toEqual([]);
|
|
});
|
|
test('write: keywords block replaced, draft line above it, found again, then removable', () => {
|
|
const md = setKeywords(LISTING, 'en-US', 'todo,packing', draftMark('2026-10-12'));
|
|
expect(md).toContain('### keywords\n<!-- storebound-aso: draft keywords from `aso suggest` 2026-10-12; review them, then delete this line -->\n```\ntodo,packing\n```\n');
|
|
expect(md).toContain('### description\n```\nExample Notes keeps');
|
|
expect(draftLocales(md)).toEqual(['en-US']);
|
|
expect(setKeywords(md, 'en-US', 'a,b', draftMark('2026-10-13')).match(/storebound-aso/g)).toHaveLength(1);
|
|
expect(draftLocales(md.replace(DRAFT_RE, ''))).toEqual([]);
|
|
expect(() => setKeywords(LISTING, 'fr-FR', 'x', 'm')).toThrow('no "## fr-FR"');
|
|
const noKw = '## ja · 日本語\n\n### subtitle\n```\nx\n```\n';
|
|
expect(setKeywords(noKw, 'ja', 'リスト', draftMark('2026-10-12'))).toContain('### subtitle\n```\nx\n```\n\n### keywords\n<!-- storebound-aso');
|
|
});
|
|
});
|
|
|
|
describe('audit parts', () => {
|
|
const r = (date: string, rows: [string, number | null][]) => ({ date, rows: rows.map(([keyword, rank]) => ({ storefront: 'us', locale: 'en-US', keyword, rank, results: 200, competitors: [] })) });
|
|
test('rank trend arrows', () => {
|
|
const t = rankTrend([r('2026-09-01', [['todo', 12], ['list', 40], ['plan', null]]), r('2026-09-15', [['todo', 8], ['list', 55], ['plan', 30], ['new', null]]), r('2026-08-01', [['todo', 20], ['gone', 9]])]);
|
|
const by = Object.fromEntries(t.rows.map(x => [x.keyword, x]));
|
|
expect(t.dates).toEqual(['2026-08-01', '2026-09-01', '2026-09-15']);
|
|
expect(by.todo).toMatchObject({ rank: 8, previous: 12, change: 4, arrow: '↑', best: 8, snapshots: 3 });
|
|
expect(by.list).toMatchObject({ arrow: '↓', change: -15 });
|
|
expect(by.plan!.arrow).toBe('new');
|
|
expect(by.new!.arrow).toBe('·');
|
|
expect(by.gone).toMatchObject({ rank: 9, arrow: 'new', snapshots: 1 });
|
|
expect(t.series.find(s => s.keyword === 'todo')!.ranks).toEqual([20, 12, 8]);
|
|
});
|
|
test('play use: short description room, title words, density', () => {
|
|
const full = Array.from({ length: 12 }, () => 'grocery lists for the family to share and plan').join('. ');
|
|
const u = playUse({ lang: 'en-US', title: 'Example: Groceries', short: 'Shared lists', full, whatsNew: '' });
|
|
expect(u).toMatchObject({ shortChars: 12, shortRoom: 68, titleWordsInShort: false });
|
|
expect(u.stuffed).toContain('grocery');
|
|
});
|
|
test('audit: next 3 actions, PPO cannot test the subtitle, Play experiments are a guide', () => {
|
|
const a = buildAudit({ today: '2026-10-12', storefront: 'US', findings: [{ level: 'warn', where: 'apple en-US', msg: 'keywords: 40/100 characters used; fill at least 90 (60 characters free)' }],
|
|
drafts: [], appName: 'Example Notes', ranks: [], competitors: null, analytics: [], variants: ['b'],
|
|
apple: { 'en-US': { subtitle: 'x', keywords: 'a' } }, play: [{ lang: 'en-US', title: 'T', short: 'Short', full: 'Full text.', whatsNew: '' }] });
|
|
expect(a.next).toHaveLength(3);
|
|
expect(a.next[0]!.command).toBe('storebound aso suggest --locale en-US');
|
|
expect(a.next[1]!.what).toBe('take a first rank snapshot');
|
|
expect(a.experiments.find(e => e.what === 'subtitle variant')!.why).toContain('cannot be A/B tested');
|
|
expect(a.experiments.find(e => e.store === 'apple')!.how).toContain('apple ppo create --variant b --dry');
|
|
expect(a.experiments.find(e => e.store === 'play')!.how).toContain('no API');
|
|
expect(auditText(a)).toContain('Next 3 actions');
|
|
expect(nextActions({ ...a, keywordField: { ...a.keywordField, findings: [] } }, { today: '2026-10-12', variants: [] } as never).length).toBeLessThanOrEqual(3);
|
|
});
|
|
});
|
|
|
|
describe('commands', () => {
|
|
const files = { 'store/appstore.md': LISTING, 'store/listing.md': PLAY };
|
|
test('competitors: --dry lists the queries, a run saves a snapshot and the gaps', async () => {
|
|
const p = project(BASE, files);
|
|
const calls = mockFetch([{ match: /^\/search\?term=grocery\+list&country=us/, answer: US }]);
|
|
let r = await capture(() => aso(p.ctx(['competitors', '--term', 'grocery list', '--only-terms', '--dry', '--json'])));
|
|
expect(jsonOut(r.out).queries).toEqual([{ storefront: 'us', locale: 'en-US', term: 'grocery list', url: 'https://itunes.apple.com/search?term=grocery+list&country=us&media=software&entity=software&limit=11' }]);
|
|
expect(calls).toHaveLength(0);
|
|
r = await capture(() => aso(p.ctx(['competitors', '--term', 'grocery list', '--only-terms', '--top', '3', '--json'])));
|
|
const j = jsonOut(r.out) as { rows: { ourRank: number; apps: unknown[] }[]; gaps: { gaps: { word: string }[] }[]; saved: boolean };
|
|
expect(j.rows[0]!.ourRank).toBe(3);
|
|
expect(j.rows[0]!.apps).toHaveLength(3);
|
|
expect(j.saved).toBe(true);
|
|
const snap = join(p.root, '.storebound/aso/competitors', `${new Date().toISOString().slice(0, 10)}.json`);
|
|
expect(JSON.parse(readFileSync(snap, 'utf8')).top).toBe(3);
|
|
// MCP form: read-only, nothing saved
|
|
const q = project(BASE, files);
|
|
mockFetch([{ match: /^\/search\?term=grocery\+list/, answer: US }]);
|
|
r = await capture(() => aso(q.ctx(['competitors', '--term', 'grocery list', '--only-terms', '--no-save', '--json'])));
|
|
expect(jsonOut(r.out).saved).toBe(false);
|
|
expect(existsSync(join(q.root, '.storebound/aso/competitors'))).toBe(false);
|
|
});
|
|
test('suggest: offline proposal is a dry diff; --write marks a draft that check --aso reports', async () => {
|
|
const p = project(BASE, files);
|
|
mockFetch([]);
|
|
let r = await capture(() => aso(p.ctx(['suggest', '--locale', 'en-US', '--json'])));
|
|
const j = jsonOut(r.out) as { written: boolean; results: { ads: string; proposal: { before: string; after: string; chars: number }; candidates: { popularity: number | null }[] }[] };
|
|
expect(j.written).toBe(false);
|
|
expect(j.results[0]!.ads).toMatch(/no Apple Ads credentials.*popularity unknown/);
|
|
expect(j.results[0]!.candidates.every(c => c.popularity === null)).toBe(true);
|
|
expect(j.results[0]!.proposal.before).toBe('todo,checklist,list,family');
|
|
expect(readFileSync(join(p.root, 'store/appstore.md'), 'utf8')).toBe(LISTING);
|
|
r = await capture(() => aso(p.ctx(['suggest', '--locale', 'en-US', '--write'])));
|
|
const md = readFileSync(join(p.root, 'store/appstore.md'), 'utf8');
|
|
expect(draftLocales(md)).toEqual(['en-US']);
|
|
expect(md).toContain(j.results[0]!.proposal.after);
|
|
r = await capture(() => check(p.ctx(['--aso'])));
|
|
expect(r.out).toContain('draft from `aso suggest`, not reviewed yet');
|
|
});
|
|
test('suggest with Apple Ads credentials: popularity from the keyword suggestions endpoint', async () => {
|
|
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
Object.assign(process.env, { APPLE_ADS_CLIENT_ID: 'SEARCHADS.client-placeholder', APPLE_ADS_TEAM_ID: 'SEARCHADS.team-placeholder', APPLE_ADS_KEY_ID: 'key-placeholder',
|
|
APPLE_ADS_PRIVATE_KEY: privateKey.export({ type: 'sec1', format: 'pem' }).toString(), APPLE_ADS_ACCOUNT_ID: '123456789' });
|
|
const p = project(BASE, files);
|
|
const calls = mockFetch([
|
|
{ method: 'POST', match: /^\/auth\/oauth2\/token$/, answer: { access_token: 'token-placeholder', token_type: 'Bearer', expires_in: 3600 } },
|
|
{ method: 'POST', match: /^\/v1\/suggestions\/keywords\/query$/, answer: ADS },
|
|
]);
|
|
const r = await capture(() => aso(p.ctx(['suggest', '--locale', 'en-US', '--json'])));
|
|
const j = jsonOut(r.out) as { results: { ads: string; candidates: { term: string; popularity: number | null }[] }[] };
|
|
expect(j.results[0]!.ads).toContain('Apple Ads keyword suggestions (US): 4');
|
|
expect(j.results[0]!.candidates.find(c => c.term === 'packing')!.popularity).toBe(41);
|
|
const q = calls.find(c => c.path === '/v1/suggestions/keywords/query')!;
|
|
expect((q.body as { filters: unknown[] }).filters[0]).toEqual({ field: 'promotedObjectId', operator: 'EQUALS', value: ['6400000101'] });
|
|
expect(calls.some(c => c.method !== 'POST')).toBe(false);
|
|
});
|
|
test('audit: offline report from the listing files and snapshots', async () => {
|
|
const p = project(BASE, { ...files,
|
|
'.storebound/aso/2026-09-01.json': JSON.stringify({ date: '2026-09-01', rows: [{ storefront: 'us', locale: 'en-US', keyword: 'todo', rank: 30, results: 200, competitors: [] }] }),
|
|
'.storebound/aso/2026-09-15.json': JSON.stringify({ date: '2026-09-15', rows: [{ storefront: 'us', locale: 'en-US', keyword: 'todo', rank: 21, results: 200, competitors: [] }] }),
|
|
'.storebound/aso/competitors/2026-09-15.json': JSON.stringify({ date: '2026-09-15', app: APP, top: 10, rows: [competitorRow(US.results, APP, { storefront: 'us', locale: 'en-US', term: 'grocery list' }, 10)] }),
|
|
});
|
|
const calls = mockFetch([]);
|
|
let r = await capture(() => aso(p.ctx(['audit', '--json'])));
|
|
const j = jsonOut(r.out) as { ranks: { rows: { arrow: string; change: number }[] }; competitors: { gaps: { gaps: { word: string }[] }[] }; next: unknown[]; play: unknown[] };
|
|
expect(j.ranks.rows[0]).toMatchObject({ arrow: '↑', change: 9 });
|
|
expect(j.competitors.gaps[0]!.gaps.map(g => g.word)).toContain('shopping');
|
|
expect(j.next.length).toBeLessThanOrEqual(3);
|
|
expect(j.play).toHaveLength(1);
|
|
expect(calls).toHaveLength(0);
|
|
r = await capture(() => aso(p.ctx(['audit'])));
|
|
expect(r.out).toContain('2. Ranks');
|
|
expect(r.out).toMatch(/us\s+todo\s+21\s+↑\s+\+9/);
|
|
});
|
|
test('unknown subcommand', async () => {
|
|
const p = project(BASE, files);
|
|
await expect(aso(p.ctx(['nope']))).rejects.toThrow('rank | competitors | suggest | audit');
|
|
});
|
|
});
|
|
|
|
describe('MCP and desktop', () => {
|
|
const t = (n: string) => TOOLS.find(x => x.name === n)!;
|
|
test('read-only tools: competitors saves nothing, suggest never writes, audit is offline', () => {
|
|
expect(t('aso_competitors')).toMatchObject({ write: false, network: true });
|
|
expect(toArgv(t('aso_competitors'), { storefront: 'us,tr', terms: 'grocery list', top: 5 }).argv).toEqual(['aso', 'competitors', '--storefront', 'us,tr', '--term', 'grocery list', '--top', '5', '--no-save', '--json']);
|
|
expect(t('aso_suggest')).toMatchObject({ write: false, network: true });
|
|
expect(toArgv(t('aso_suggest'), { locale: 'en-US', offline: true }).argv).toEqual(['aso', 'suggest', '--locale', 'en-US', '--offline', '--json']);
|
|
expect(t('aso_audit')).toMatchObject({ write: false, network: false });
|
|
expect(toArgv(t('aso_audit'), {}).argv).toEqual(['aso', 'audit', '--json']);
|
|
for (const n of ['aso_competitors', 'aso_suggest', 'aso_audit']) {
|
|
for (const a of FORBIDDEN_ARGS) expect(toArgv(t(n), { locale: 'x' }).argv).not.toContain(a);
|
|
expect(JSON_TOOLS.has(n)).toBe(true);
|
|
}
|
|
expect(methodName('aso_audit')).toBe('aso.audit');
|
|
});
|
|
});
|
|
|
|
// keep the fixture honest: placeholder apps only, the real Search API field set
|
|
test('fixtures are placeholders with the Search API fields used', () => {
|
|
for (const r of [...US.results, ...DE.results]) {
|
|
expect(r.bundleId).toMatch(/^com\.example\./);
|
|
for (const k of ['trackId', 'trackName', 'sellerName', 'userRatingCount', 'releaseDate', 'currentVersionReleaseDate', 'genres', 'genreIds', 'primaryGenreName']) expect(r).toHaveProperty(k);
|
|
}
|
|
});
|