// 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 = {}; 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\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 bytes, 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.bytes).toBeLessThanOrEqual(100); expect(new Set(p.after.split(',')).size).toBe(p.after.split(',').length); // bytes, not characters: Turkish letters take two 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.bytes).toBeLessThanOrEqual(100); expect(Buffer.byteLength(t.after)).toBe(t.bytes); 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\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