283 lines
23 KiB
TypeScript
283 lines
23 KiB
TypeScript
// Listing pulls, release notes in many languages, keyword ranks and the AdAttributionKit scan: pure parts plus the
|
|
// commands against recorded store answers (placeholders only).
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { compactDiff, lineDiff } from '../cli/lib/diff';
|
|
import { catalogProposal, mergeAppleListing, mergePlayListing, overLimits } from '../cli/lib/listing-pull';
|
|
import { applyLanguageNotes, checkLanguageNotes, notesFileText, parseNotesFile, resolveLanguages } from '../cli/lib/whats-new';
|
|
import { competitors, keywordList, parseStorefronts, rankDelta, rankOf, searchUrl } from '../cli/lib/aso-rank';
|
|
import { parsePlist } from '../cli/lib/plist';
|
|
import { evaluateAdAttribution, sourceSignals } from '../cli/lib/adattribution';
|
|
import { parseArgs } from '../cli/lib/args';
|
|
import { run as appleListing } from '../cli/apple/listing';
|
|
import { run as playListing } from '../cli/play/listing';
|
|
import { run as catalog } from '../cli/catalog';
|
|
import { run as whatsNew } from '../cli/whats-new';
|
|
import { run as aso } from '../cli/aso';
|
|
import { run as ads } from '../cli/ads';
|
|
import { TOOLS, toArgv } from '../cli/mcp-tools';
|
|
import { ascEnv, BASE, capture, jsonOut, mockFetch, playEnv, project, restore } from './helpers/store-mock';
|
|
|
|
afterEach(restore);
|
|
const page = (data: unknown[]) => ({ data, links: { self: 'x' } });
|
|
|
|
const APPLE_MD = '# App Store\n\n## en-US · English\n\n### subtitle\n```\nNotes that sync\n```\n\n### keywords\n```\nnotes,lists\n```\n\n### description\n```\nOld text.\n```\n\n## Notes\n\nprose\n';
|
|
const PLAY_MD = '# Play\n\n## English\n<!-- play: en-US -->\n\n### Play short description (max 80)\n```\nOld short\n```\n\n### Play full description (max 4000)\n```\nOld full.\n```\n\n## Spanish\n<!-- play: es-ES, es-419 -->\n\n### short\n```\nCorta\n```\n\n### full\n```\nLarga.\n```\n';
|
|
|
|
describe('diff', () => {
|
|
test('line diff and compact diff', () => {
|
|
expect(lineDiff('a\nb\nc', 'a\nx\nc')).toEqual([' a', '-b', '+x', ' c']);
|
|
expect(compactDiff('1\n2\n3\n4\n5\n6\n7\n8', '1\n2\n3\n4\n5\n6\n7\nX', 1)).toEqual([' 7', '-8', '+X']);
|
|
expect(compactDiff('same', 'same')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('listing pull merges', () => {
|
|
test('App Store: changed blocks replaced, missing ones added, new locales appended, prose kept', () => {
|
|
const r = mergeAppleListing(APPLE_MD, { 'en-US': { subtitle: 'Notes that sync', description: 'New text.', promotionalText: 'Now with lists.' }, 'tr': { subtitle: 'Senkron notlar', keywords: 'not,liste' } });
|
|
expect(r.changes.map(c => `${c.section} ${c.field} ${c.from === null ? 'new' : 'changed'}`)).toEqual(['en-US promotionalText new', 'en-US description changed', 'tr subtitle new', 'tr keywords new']);
|
|
expect(r.md).toContain('### description\n```\nNew text.\n```');
|
|
expect(r.md).toContain('### promotionalText\n```\nNow with lists.\n```');
|
|
expect(r.md).toContain('## Notes\n\nprose');
|
|
expect(r.md).toContain('## tr\n\n### subtitle\n```\nSenkron notlar\n```');
|
|
expect(r.added).toEqual(['tr']);
|
|
expect(overLimits([{ section: 'tr', field: 'subtitle', from: null, to: 'x'.repeat(31) }], 'apple')).toEqual(['tr subtitle: 31 > 30']);
|
|
});
|
|
test('Play: per section, shared sections only when the codes agree, title equal to the app name left out', () => {
|
|
const r = mergePlayListing(PLAY_MD, [
|
|
{ language: 'en-US', title: 'Example Notes', shortDescription: 'New short', fullDescription: 'Old full.' },
|
|
{ language: 'es-ES', shortDescription: 'Corta nueva', fullDescription: 'Larga.' }, { language: 'es-419', shortDescription: 'Otra corta', fullDescription: 'Larga.' },
|
|
{ language: 'fr-FR', title: 'Notes Exemple', shortDescription: 'Court', fullDescription: 'Long.' },
|
|
], 'Example Notes');
|
|
expect(r.changes.map(c => `${c.section} ${c.field}`)).toEqual(['en-US shortDescription', 'fr-FR title', 'fr-FR shortDescription', 'fr-FR fullDescription']);
|
|
expect(r.conflicts[0]).toContain('es-ES, es-419: shortDescription differs');
|
|
expect(r.md).toContain('### Play short description (max 80)\n```\nNew short\n```');
|
|
expect(r.md).not.toContain('### Title (max 30)\n```\nExample Notes');
|
|
expect(r.md).toContain('## fr-FR\n<!-- play: fr-FR -->\n\n### Title (max 30)\n```\nNotes Exemple\n```');
|
|
});
|
|
test('catalog proposal maps store languages to language ids and never touches unknown products', () => {
|
|
const cfg = { ...BASE, products: [{ id: 'com.example.notes.pro', type: 'non_consumable' as const, usd: 4.99, listings: { en: { title: 'Pro', description: 'All features' } } }] };
|
|
const p = catalogProposal(cfg, { apple: { 'com.example.notes.pro': { 'en-US': { title: 'Pro', description: 'Every feature' }, 'tr': { title: 'Pro', description: 'Tüm özellikler' }, 'ja': { title: 'プロ', description: 'x' } }, 'com.example.notes.other': {} },
|
|
play: { 'com.example.notes.pro': { 'en-US': { title: 'Pro unlock', description: 'All features' } } } });
|
|
expect(p.changes.map(c => `${c.store} ${c.lang} ${c.field}`)).toEqual(['play en title', 'apple en description', 'apple tr title', 'apple tr description']);
|
|
expect(p.listings['com.example.notes.pro']!.tr).toEqual({ title: 'Pro', description: 'Tüm özellikler' });
|
|
expect(p.unmapped).toEqual(['apple:ja']);
|
|
expect(p.unknown).toEqual(['com.example.notes.other']);
|
|
});
|
|
});
|
|
|
|
describe('listing pull commands', () => {
|
|
test('apple listing pull: diff without --write, file updated with --write', async () => {
|
|
ascEnv();
|
|
const p = project(BASE, { 'store/appstore.md': APPLE_MD });
|
|
mockFetch([
|
|
{ match: /^\/v1\/apps\/6400000101\/appStoreVersions\?filter\[platform\]=IOS/, answer: page([{ type: 'appStoreVersions', id: 'v-1', attributes: { versionString: '1.1.0', appVersionState: 'READY_FOR_DISTRIBUTION', createdDate: '2026-09-01T00:00:00-07:00' } }]) },
|
|
{ match: /^\/v1\/apps\/6400000101\/appInfos/, answer: page([{ type: 'appInfos', id: 'ai-1', attributes: { state: 'READY_FOR_DISTRIBUTION' } }]) },
|
|
{ match: /^\/v1\/appInfos\/ai-1\/appInfoLocalizations/, answer: page([{ type: 'appInfoLocalizations', id: 'l', attributes: { locale: 'en-US', name: 'Example Notes', subtitle: 'Notes that sync', privacyPolicyUrl: 'https://example.com/privacy' } }]) },
|
|
{ match: /^\/v1\/appStoreVersions\/v-1\/appStoreVersionLocalizations/, answer: page([{ type: 'appStoreVersionLocalizations', id: 'vl', attributes: { locale: 'en-US', description: 'Live text.', keywords: 'notes,lists', promotionalText: null, whatsNew: 'Faster sync.' } }]) },
|
|
]);
|
|
let r = await capture(() => appleListing(p.ctx(['pull', '--json'])));
|
|
const j = jsonOut(r.out);
|
|
expect(j).toMatchObject({ version: '1.1.0', write: false, added: [] });
|
|
expect((j.changes as { field: string }[]).map(c => c.field)).toEqual(['description', 'whatsNew']); // the live name equals app.name: not written into the file
|
|
expect(j.diff).toContain('-Old text.');
|
|
expect(readFileSync(join(p.root, 'store/appstore.md'), 'utf8')).toBe(APPLE_MD);
|
|
r = await capture(() => appleListing(p.ctx(['pull', '--write'])));
|
|
expect(readFileSync(join(p.root, 'store/appstore.md'), 'utf8')).toContain('### whatsNew\n```\nFaster sync.\n```');
|
|
expect(r.out).toContain('2 change(s) written');
|
|
});
|
|
test('play listing pull: one read-only edit, deleted; --write updates the file', async () => {
|
|
playEnv();
|
|
const p = project(BASE, { 'store/listing.md': PLAY_MD });
|
|
const routes = () => mockFetch([
|
|
{ method: 'POST', match: /\/applications\/com\.example\.notes\/edits$/, answer: { id: 'edit-1', expiryTimeSeconds: '1760000000' } },
|
|
{ match: /\/edits\/edit-1\/listings$/, answer: { kind: 'androidpublisher#listingsListResponse', listings: [{ language: 'en-US', title: 'Example Notes', shortDescription: 'Live short', fullDescription: 'Old full.' }] } },
|
|
{ method: 'DELETE', match: /\/edits\/edit-1$/, answer: new Response(null, { status: 204 }) },
|
|
]);
|
|
let calls = routes();
|
|
let r = await capture(() => playListing(p.ctx(['pull', '--json'])));
|
|
expect(jsonOut(r.out)).toMatchObject({ write: false, languages: ['en-US'], changes: [{ section: 'en-US', field: 'shortDescription', from: 'Old short', to: 'Live short' }] });
|
|
expect(calls.map(c => c.method)).toEqual(['POST', 'GET', 'DELETE']);
|
|
calls = routes();
|
|
r = await capture(() => playListing(p.ctx(['pull', '--write'])));
|
|
expect(readFileSync(join(p.root, 'store/listing.md'), 'utf8')).toContain('```\nLive short\n```');
|
|
expect(calls.some(c => c.path.includes(':commit'))).toBe(false);
|
|
});
|
|
test('catalog pull --write saves a proposal next to an unchanged config', async () => {
|
|
ascEnv();
|
|
const p = project({ ...BASE, play: undefined, products: [{ id: 'com.example.notes.pro', type: 'non_consumable', usd: 4.99, listings: { en: { title: 'Pro', description: 'All features' } } }] });
|
|
mockFetch([
|
|
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2/, answer: page([{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: 'com.example.notes.pro' } }]) },
|
|
{ match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseLocalizations/, answer: page([{ type: 'inAppPurchaseLocalizations', id: 'l1', attributes: { locale: 'en-US', name: 'Pro', description: 'Every feature' } }]) },
|
|
{ match: /^\/v1\/apps\/6400000101\/subscriptionGroups/, answer: page([]) },
|
|
]);
|
|
const r = await capture(() => catalog(p.ctx(['pull', '--store', 'apple', '--write'])));
|
|
const saved = JSON.parse(readFileSync(join(p.root, '.storebound/pull/catalog.json'), 'utf8'));
|
|
expect(saved.listings['com.example.notes.pro'].en).toEqual({ title: 'Pro', description: 'Every feature' });
|
|
expect(r.out).toContain('storebound.config.ts is unchanged');
|
|
});
|
|
});
|
|
|
|
describe('release notes in many languages', () => {
|
|
const langs = BASE.languages;
|
|
test('language files, limits per store, apply into both listing files', () => {
|
|
expect(resolveLanguages(langs, ['en-US', 'de', 'tr-TR']).map(l => l.id)).toEqual(['en', 'de', 'tr']);
|
|
expect(() => resolveLanguages(langs, ['fr'])).toThrow('not a language of the config');
|
|
const f = { lang: 'de', source: 'en', status: 'translate' as const, text: 'New: lists.' };
|
|
expect(parseNotesFile(notesFileText(f), 'de')).toEqual(f);
|
|
expect(parseNotesFile('Plain text', 'tr')).toEqual({ lang: 'tr', source: '', status: 'translated', text: 'Plain text' });
|
|
expect(checkLanguageNotes(langs[1]!, f, 'New: lists.').problems).toEqual(['not translated yet (status=translate)']);
|
|
expect(checkLanguageNotes(langs[1]!, { ...f, status: 'translated' }, 'New: lists.').problems).toEqual(['identical to the source language: translate it']);
|
|
const long = checkLanguageNotes(langs[2]!, { ...f, status: 'translated', text: 'x'.repeat(501) });
|
|
expect(long.play).toEqual({ limit: 500, ok: false });
|
|
expect(long.apple).toEqual({ limit: 4000, ok: true });
|
|
expect(checkLanguageNotes({ id: 'ios-only', apple: 'ja' }, { ...f, status: 'translated', text: 'x'.repeat(600) }).problems.filter(p => !p.startsWith('first line'))).toEqual([]);
|
|
const out = applyLanguageNotes({ play: PLAY_MD.replace('<!-- play: en-US -->', '<!-- play: en-US -->').replace('## Spanish', '## German\n<!-- play: de-DE -->\n\n### short\n```\nk\n```\n\n### full\n```\nl\n```\n\n## Spanish'), apple: APPLE_MD.replace('## Notes', '## de-DE\n\n### subtitle\n```\nx\n```\n\n## Notes') }, langs[1]!, 'Neu: Listen.');
|
|
expect(out.changed).toEqual(['play:de-DE', 'apple:de-DE']);
|
|
expect(out.play).toContain("### What's new (max 500)\n```\nNeu: Listen.\n```");
|
|
expect(out.apple).toContain('### whatsNew\n```\nNeu: Listen.\n```');
|
|
});
|
|
test('command: --languages writes one file per language, a --translate hook fills them, check and apply', async () => {
|
|
const p = project(BASE, { 'store/listing.md': PLAY_MD.replace('## Spanish', '## German\n<!-- play: de-DE -->\n\n### short\n```\nk\n```\n\n### full\n```\nl\n```\n\n## Spanish'), 'store/appstore.md': APPLE_MD });
|
|
const git = (...a: string[]) => Bun.spawnSync(['git', ...a], { cwd: p.root, env: { ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@example.com', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@example.com' } });
|
|
git('init', '-q'); git('add', '.'); git('commit', '-qm', 'chore: init'); git('tag', 'v1.0.0'); git('commit', '-q', '--allow-empty', '-m', 'feat: shared lists');
|
|
let r = await capture(() => whatsNew(p.ctx(['--languages', 'en,de', '--dry', '--json'])));
|
|
expect((jsonOut(r.out).files as { status: string }[]).map(f => f.status)).toEqual(['source', 'translate']);
|
|
expect(existsSync(join(p.root, 'store/whats-new'))).toBe(false);
|
|
r = await capture(() => whatsNew(p.ctx(['--languages', 'en,de'])));
|
|
expect(readFileSync(join(p.root, 'store/whats-new/de.md'), 'utf8')).toMatch(/^<!-- whats-new lang=de source=en status=translate -->\nNew in this update:\n• Shared lists\./);
|
|
r = await capture(() => whatsNew(p.ctx(['check', '--json'])));
|
|
expect(r.exit).toBe(1);
|
|
expect((jsonOut(r.out).checks as { lang: string; problems: string[] }[])[1]!.problems).toContain('not translated yet (status=translate)');
|
|
r = await capture(() => whatsNew(p.ctx(['apply'])));
|
|
expect(r.out).toContain('nothing written');
|
|
// the provider hook: any command; here one that upper-cases the text
|
|
r = await capture(() => whatsNew(p.ctx(['--languages', 'en,de', '--translate', 'tr a-z A-Z'])));
|
|
expect(readFileSync(join(p.root, 'store/whats-new/de.md'), 'utf8')).toContain('status=translated -->\nNEW IN THIS UPDATE:');
|
|
r = await capture(() => whatsNew(p.ctx(['apply', '--json'])));
|
|
expect(jsonOut(r.out).changed).toEqual(['play:en-US', 'apple:en-US', 'play:de-DE']);
|
|
expect(readFileSync(join(p.root, 'store/listing.md'), 'utf8')).toContain('NEW IN THIS UPDATE:');
|
|
});
|
|
});
|
|
|
|
describe('keyword ranks (iTunes Search API)', () => {
|
|
const RESULTS = { resultCount: 3, results: [
|
|
{ trackId: 6400000999, trackName: 'Other Notes', bundleId: 'com.example.other', sellerName: 'Other Studio', averageUserRating: 4.5, userRatingCount: 1200, primaryGenreName: 'Productivity' },
|
|
{ trackId: 6400000101, trackName: 'Example Notes', bundleId: 'com.example.notes', sellerName: 'Example', averageUserRating: 4.8, userRatingCount: 50 },
|
|
{ trackId: 6400000998, trackName: 'Lists Pro', bundleId: 'com.example.lists', sellerName: 'Lists Inc' },
|
|
] };
|
|
test('pure parts', () => {
|
|
expect(searchUrl('shared lists', 'US', 500)).toBe('https://itunes.apple.com/search?term=shared+lists&country=us&media=software&entity=software&limit=200');
|
|
expect(() => searchUrl('x', 'usa')).toThrow('two-letter');
|
|
expect(keywordList('notes, lists,Notes,,todo', ['todo', 'planner'])).toEqual(['notes', 'lists', 'todo', 'planner']);
|
|
expect(parseStorefronts(['us', 'de=de-DE'], 'en-US')).toEqual([{ storefront: 'us', locale: 'en-US' }, { storefront: 'de', locale: 'de-DE' }]);
|
|
expect(rankOf(RESULTS.results, { appId: '6400000101' })).toBe(2);
|
|
expect(rankOf(RESULTS.results, { bundleId: 'com.example.lists' })).toBe(3);
|
|
expect(rankOf(RESULTS.results, { appId: '1' })).toBeNull();
|
|
expect(competitors(RESULTS.results, { appId: '6400000101' }, 2)).toEqual([{ rank: 1, title: 'Other Notes', developer: 'Other Studio', rating: 4.5, ratings: 1200, genre: 'Productivity' }, { rank: 3, title: 'Lists Pro', developer: 'Lists Inc', rating: null, ratings: 0, genre: '' }]);
|
|
expect(rankDelta([{ storefront: 'us', locale: 'en-US', keyword: 'Notes', rank: 2, results: 3, competitors: [] }], [{ storefront: 'us', locale: 'en-US', keyword: 'notes', rank: 5, results: 3, competitors: [] }]).get('us|notes')).toBe(3);
|
|
});
|
|
test('command: --dry lists the queries; a run saves a snapshot', async () => {
|
|
const p = project(BASE, { 'store/appstore.md': '## en-US\n\n### keywords\n```\nnotes\n```\n' });
|
|
const calls = mockFetch([{ match: /^\/search\?term=notes&country=us/, answer: RESULTS }]);
|
|
let r = await capture(() => aso(p.ctx(['rank', '--dry', '--json'])));
|
|
expect(jsonOut(r.out).queries).toEqual([{ storefront: 'us', keyword: 'notes', url: 'https://itunes.apple.com/search?term=notes&country=us&media=software&entity=software&limit=200' }]);
|
|
expect(calls).toHaveLength(0);
|
|
r = await capture(() => aso(p.ctx(['rank', '--json'])));
|
|
expect((jsonOut(r.out).rows as { rank: number; competitors: unknown[] }[])[0]).toMatchObject({ keyword: 'notes', rank: 2, results: 3 });
|
|
expect(calls).toHaveLength(1);
|
|
const snap = JSON.parse(readFileSync(join(p.root, '.storebound/aso', `${new Date().toISOString().slice(0, 10)}.json`), 'utf8'));
|
|
expect(snap.rows[0].competitors[0].title).toBe('Other Notes');
|
|
});
|
|
});
|
|
|
|
describe('AdAttributionKit scan', () => {
|
|
const PLIST = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
<key>SKAdNetworkItems</key>
|
|
<array>
|
|
<dict><key>SKAdNetworkIdentifier</key><string>cstr6suwn9.skadnetwork</string></dict>
|
|
<dict><key>SKAdNetworkIdentifier</key><string>4FZDC2EVR5.skadnetwork</string></dict>
|
|
</array>
|
|
<key>AdNetworkIdentifiers</key>
|
|
<array><string>cstr6suwn9.skadnetwork</string><string>f2d92a.adattributionkit</string></array>
|
|
<key>AdAttributionKit</key>
|
|
<dict>
|
|
<key>AttributionCopyEndpoint</key><string>https://postbacks.example.com/attribution</string>
|
|
<key>EligibleForAdAttributionKitReengagementPostbackCopies</key><true/>
|
|
</dict>
|
|
<key>NSAdvertisingAttributionReportEndpoint</key><string>https://example.com</string>
|
|
<key>AdAttributionKitConfigurations</key>
|
|
<dict>
|
|
<key>AttributionWindows</key>
|
|
<dict>
|
|
<key>global</key><dict><key>install</key><dict><key>view</key><integer>9</integer><key>ignoreInteractionType</key><string>view</string></dict></dict>
|
|
<key>f2d92a.adattributionkit</key><dict><key>install</key><dict><key>click</key><integer>23</integer></dict></dict>
|
|
</dict>
|
|
<key>AttributionCooldown</key><dict><key>install-cooldown-hours</key><integer>24</integer><key>re-engagement-cooldown-hours</key><integer>900</integer></dict>
|
|
</dict>
|
|
<key>Empty</key><string/>
|
|
<key>Ratio</key><real>1.5</real>
|
|
</dict>
|
|
</plist>`;
|
|
test('plist reader', () => {
|
|
const p = parsePlist(PLIST) as Record<string, unknown>;
|
|
expect(p.CFBundleIdentifier).toBe('$(PRODUCT_BUNDLE_IDENTIFIER)');
|
|
expect(p.Empty).toBe('');
|
|
expect(p.Ratio).toBe(1.5);
|
|
expect((p.AdAttributionKit as Record<string, unknown>).EligibleForAdAttributionKitReengagementPostbackCopies).toBe(true);
|
|
expect(() => parsePlist('bplist00...')).toThrow('binary plist');
|
|
});
|
|
test('findings: id case, windows and cooldown ranges, global ignore, endpoints, missing conversion updates', () => {
|
|
const sig = sourceSignals([{ path: 'Podfile.lock', text: 'PODS:\n - Google-Mobile-Ads-SDK (11.0.0)\n' }, { path: 'App/AppDelegate.swift', text: 'import UIKit\nimport AdAttributionKit\n' }]);
|
|
expect(sig).toEqual({ aakImport: true, conversionUpdates: false, adSdks: ['Google Mobile Ads (AdMob)'], mmpSdks: [] });
|
|
const f = evaluateAdAttribution(parsePlist(PLIST) as Record<string, never>, sig);
|
|
const codes = f.map(x => `${x.level}:${x.code}`);
|
|
for (const c of ['blocker:id-case', 'info:aak-ids-partial', 'warn:endpoint-path', 'ok:copy-endpoint', 'ok:skan-endpoint', 'blocker:window-view', 'warn:ignore-global', 'blocker:cooldown-range', 'warn:no-conversion-update'])
|
|
expect(codes).toContain(c);
|
|
expect(codes).not.toContain('warn:windows-unknown-network');
|
|
expect(f.find(x => x.code === 'copy-endpoint')!.msg).toContain('https://example.com/.well-known/appattribution/report-attribution/');
|
|
// a publisher with an ad SDK and no ids at all, the top-level copy key, an MMP SDK doing the conversion updates
|
|
const g = evaluateAdAttribution({ AttributionCopyEndpoint: 'http://example.com' } as never, { aakImport: false, conversionUpdates: false, adSdks: ['AppLovin MAX'], mmpSdks: ['AppsFlyer'] });
|
|
expect(g.map(x => x.code)).toEqual(['no-network-ids', 'endpoint-url']);
|
|
expect(evaluateAdAttribution({}, { aakImport: false, conversionUpdates: false, adSdks: [], mmpSdks: [] }).map(x => x.code)).toEqual(['none']);
|
|
});
|
|
test('command: scans the app Info.plist, skips Pods and tests, exit 1 on a blocker', async () => {
|
|
const p = project(BASE, { 'ios/App/App/Info.plist': PLIST, 'ios/App/Pods/X/Info.plist': '<plist><dict/></plist>', 'ios/App/AppTests/Info.plist': '<plist><dict/></plist>', 'ios/App/Podfile.lock': 'PODS:\n - AppLovinSDK (13.0.0)\n',
|
|
'ios/App/App/Attribution.swift': 'import AdAttributionKit\nfunc first() async { try? await Postback.updateConversionValue(1, lockPostback: false) }\n' });
|
|
const r = await capture(() => ads(parseArgs(['scan', '--ios', join(p.root, 'ios/App'), '--json'])));
|
|
const j = jsonOut(r.out);
|
|
expect((j.results as { plist: string }[]).map(x => x.plist.split('/').slice(-3).join('/'))).toEqual(['App/App/Info.plist']);
|
|
expect(j.signals).toEqual({ aakImport: true, conversionUpdates: true, adSdks: ['AppLovin MAX'], mmpSdks: [] });
|
|
expect(j.blockers).toBe(3);
|
|
expect(r.exit).toBe(1);
|
|
mkdirSync(join(p.root, 'empty'), { recursive: true });
|
|
writeFileSync(join(p.root, 'empty/readme.txt'), 'no app');
|
|
const e = await capture(() => ads(parseArgs(['scan', '--ios', join(p.root, 'empty')])));
|
|
expect(e.out).toContain('no app Info.plist');
|
|
expect(e.exit).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('MCP tools of the store operations', () => {
|
|
const t = (n: string) => TOOLS.find(x => x.name === n)!;
|
|
test('pulls write only with dry_run false; plans and reads stay read-only; owner steps are not tools', () => {
|
|
expect(toArgv(t('apple_listing_pull')).argv).toEqual(['apple', 'listing', 'pull', '--json', '--dry']);
|
|
expect(toArgv(t('apple_listing_pull'), { dry_run: false }).argv).toEqual(['apple', 'listing', 'pull', '--json', '--write']);
|
|
expect(toArgv(t('play_listing_pull'), { dry_run: false }).argv).toEqual(['play', 'listing', 'pull', '--json', '--write']);
|
|
expect(toArgv(t('apple_iap_review_plan'), { with_version: true }).argv).toEqual(['apple', 'iap-submit', '--with-version', '--json', '--dry']);
|
|
expect(toArgv(t('apple_testflight_wait'), { build: '50', check_once: true }).argv).toEqual(['apple', 'testflight', 'wait', '--build', '50', '--timeout', '30m', '--json', '--dry']);
|
|
expect(toArgv(t('reports'), { kind: 'play-earnings', month: '2026-09' }).argv).toEqual(['reports', 'play-earnings', '--month', '2026-09', '--json', '--dry']);
|
|
expect(toArgv(t('aso_rank'), { storefronts: 'us,de=de-DE' }).argv).toEqual(['aso', 'rank', '--storefronts', 'us,de=de-DE', '--json', '--dry']);
|
|
expect(t('triage')).toMatchObject({ write: false });
|
|
expect(t('ads_scan')).toMatchObject({ write: false, network: false });
|
|
const commands = TOOLS.map(x => x.command.join(' '));
|
|
for (const c of ['apple offer-codes', 'apple win-back', 'apple promoted', 'apple preorder enable', 'apple preorder end', 'apple cpp submit', 'apple cpp visible', 'apple events submit', 'apple testflight expire'])
|
|
expect(commands).not.toContain(c);
|
|
for (const x of TOOLS) expect(toArgv(x, { dry_run: false, kind: 'apple-sales', build: '1', languages: 'en', name: 'x', ios: 'ios' }).argv.join(' ')).not.toMatch(/--(codes|enable|yes|translate|no-submit)\b/);
|
|
});
|
|
});
|