// Write plans of apple listing, play listing and apple appinfo: the dry run reads only and prints old -> new per field, // drafts are skipped, the version decision is explicit; the real run applies exactly the plan. Recorded answers only // (placeholders, no network). import { afterEach, describe, expect, test } from 'bun:test'; import { run as appInfo } from '../cli/apple/appinfo'; import { run as appleListing } from '../cli/apple/listing'; import { run as playListing } from '../cli/play/listing'; import { planItem, planVersion, short } from '../cli/lib/listing-plan'; import { ascEnv, BASE, capture, jsonOut, mockFetch, playEnv, project, restore, type Call } from './helpers/store-mock'; afterEach(restore); const page = (data: unknown[]) => ({ data, links: { self: 'x' } }); const v = (id: string, versionString: string, appVersionState: string) => ({ type: 'appStoreVersions', id, attributes: { versionString, appVersionState, platform: 'IOS', copyright: `${new Date().getFullYear()} Example Notes`, releaseType: 'MANUAL' } }); const fence = (t: string) => '```\n' + t + '\n```'; const sec = (loc: string, f: Record, mark = '') => `## ${loc}\n${mark}\n` + Object.entries(f).map(([k, t]) => `### ${k}\n${fence(t)}\n`).join('\n') + '\n'; const TEXT = { subtitle: 'Notes that sync', promotionalText: 'Now with lists.', keywords: 'notes,lists', description: 'Write things down.' }; const APPLE_MD = '# App Store\n\n' + sec('en-US', TEXT) + sec('de-DE', { ...TEXT, subtitle: 'Notizen, synchron', description: 'Schreib es auf.' }) + sec('tr', { ...TEXT, subtitle: 'Senkron notlar' }, ''); const methods = (calls: Call[]) => calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path.split('?')[0]}`); describe('version decision', () => { const attrs = { copyright: '2026 Example Notes', releaseType: 'MANUAL' }; test('update the editable version string while PREPARE_FOR_SUBMISSION', () => { const p = planVersion([v('v-1', '1.0', 'PREPARE_FOR_SUBMISSION')], '1.0.0', attrs); expect(p).toMatchObject({ action: 'update', id: 'v-1', first: true, reason: 'update the editable version 1.0 (PREPARE_FOR_SUBMISSION) to 1.0.0' }); expect(p.changes.find(c => c.field === 'versionString')).toEqual({ field: 'versionString', from: '1.0', to: '1.0.0' }); }); test('keep, create, refuse', () => { expect(planVersion([{ ...v('v-1', '1.0.0', 'PREPARE_FOR_SUBMISSION'), attributes: { ...v('v-1', '1.0.0', 'x').attributes, appVersionState: 'PREPARE_FOR_SUBMISSION', copyright: '2026 Example Notes' } }], '1.0.0', attrs).action).toBe('keep'); expect(planVersion([v('v-0', '1.0.0', 'READY_FOR_DISTRIBUTION')], '1.1.0', attrs)).toMatchObject({ action: 'create', first: false }); expect(planVersion([v('v-0', '1.0.0', 'READY_FOR_DISTRIBUTION')], '1.0.0', attrs).reason).toContain('already exists (READY_FOR_DISTRIBUTION)'); expect(planVersion([v('v-0', '1.2.0', 'READY_FOR_DISTRIBUTION')], '1.1.0', attrs).reason).toContain('not higher than 1.2.0'); // the version asked for is the one in review (live sweep: apple listing --version 1.0.0 on an app in review said // "already exists; a new version needs a higher number"): say it is locked, not to make another one const inReview = planVersion([v('v-0', '1.0.0', 'WAITING_FOR_REVIEW')], '1.0.0', attrs); expect(inReview).toMatchObject({ action: 'refuse', reason: expect.stringContaining('version 1.0.0 is WAITING_FOR_REVIEW: App Store Connect locks its texts and build') }); expect(String(inReview.reason)).not.toContain('higher number'); expect(planVersion([v('v-0', '1.0.0', 'READY_FOR_DISTRIBUTION'), v('v-1', '1.1.0', 'WAITING_FOR_REVIEW')], '1.2.0', attrs).reason).toContain('1.1.0 is WAITING_FOR_REVIEW'); expect(planVersion([v('v-0', '1.0.0', 'READY_FOR_DISTRIBUTION'), v('v-1', '1.1', 'REJECTED')], '1.1.0', attrs)).toMatchObject({ action: 'refuse', reason: expect.stringContaining('only while PREPARE_FOR_SUBMISSION') }); expect(planVersion([], '1.0.0', attrs)).toMatchObject({ action: 'create', first: true }); }); test('field diff and long values cut with their length', () => { expect(planItem('en-US', { attributes: { subtitle: 'A', keywords: null } }, { subtitle: 'B', keywords: '', description: undefined })).toMatchObject({ action: 'update', changes: [{ field: 'subtitle', from: 'A', to: 'B' }], unchanged: ['keywords'] }); expect(short('x'.repeat(100))).toBe(`"${'x'.repeat(60)}…" (100 chars)`); }); }); function ascRoutes(versions: unknown[], o: { versionLocs?: unknown[]; created?: boolean } = {}) { return mockFetch([ { match: /^\/v1\/apps\/6400000101\/appStoreVersions\?filter\[platform\]=IOS/, answer: page(versions) }, { match: /^\/v1\/apps\/6400000101\/appInfos/, answer: page([{ type: 'appInfos', id: 'ai-1', attributes: { state: 'PREPARE_FOR_SUBMISSION' } }]) }, { match: /^\/v1\/appInfos\/ai-1\/appInfoLocalizations/, answer: page([{ type: 'appInfoLocalizations', id: 'il-en', attributes: { locale: 'en-US', name: 'Example Notes', subtitle: 'Old subtitle', privacyPolicyUrl: 'https://example.com/privacy' } }]) }, { match: /^\/v1\/appStoreVersions\/(v-1|v-new)\/appStoreVersionLocalizations/, answer: page(o.versionLocs ?? [{ type: 'appStoreVersionLocalizations', id: 'vl-en', attributes: { locale: 'en-US', ...TEXT, whatsNew: null, supportUrl: 'https://example.com/support', marketingUrl: 'https://example.com' } }]) }, { method: 'PATCH', match: /^\/v1\/appStoreVersions\/v-1$/, answer: { data: v('v-1', '1.0.0', 'PREPARE_FOR_SUBMISSION') } }, { method: 'POST', match: /^\/v1\/appStoreVersions$/, answer: { data: v('v-new', '1.1.0', 'PREPARE_FOR_SUBMISSION') } }, { method: 'POST', match: /^\/v1\/(appInfoLocalizations|appStoreVersionLocalizations)$/, answer: c => ({ data: { id: 'new', attributes: (c.body as { data: { attributes: unknown } }).data.attributes } }) }, { method: 'PATCH', match: /^\/v1\/(appInfoLocalizations|appStoreVersionLocalizations)\//, answer: { data: {} } }, ]); } describe('apple listing plan', () => { test('--dry --json: version update, per-locale create / update with old -> new, draft skipped; GETs only', async () => { ascEnv(); const p = project({ ...BASE, play: undefined }, { 'store/appstore.md': APPLE_MD }); const calls = ascRoutes([v('v-1', '1.0', 'PREPARE_FOR_SUBMISSION')]); const r = await capture(() => appleListing(p.ctx(['--version', '1.0.0', '--dry', '--json']))); expect(methods(calls)).toEqual([]); const j = jsonOut(r.out) as any; expect(j.versionPlan).toMatchObject({ action: 'update', reason: 'update the editable version 1.0 (PREPARE_FOR_SUBMISSION) to 1.0.0' }); expect(j.versionPlan.existing).toEqual([{ id: 'v-1', versionString: '1.0', state: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }]); expect(j.appInfo.locales.map((l: any) => `${l.key} ${l.action}`)).toEqual(['en-US update', 'de-DE create']); expect(j.appInfo.locales[0].changes).toEqual([{ field: 'subtitle', from: 'Old subtitle', to: 'Notes that sync' }]); expect(j.versionLocales.map((l: any) => `${l.key} ${l.action}`)).toEqual(['en-US same', 'de-DE create']); expect(j.skipped).toEqual([{ key: 'tr', reason: expect.stringContaining('machine-translated draft') }]); expect(j.changes).toBe(4); const t = await capture(() => appleListing(p.ctx(['--version', '1.0.0', '--dry']))); expect(t.out).toContain('UPDATE: update the editable version 1.0 (PREPARE_FOR_SUBMISSION) to 1.0.0'); expect(t.out).toContain('subtitle: "Old subtitle" -> "Notes that sync"'); expect(t.out).toContain('en-US: unchanged (5 fields)'); expect(t.out).toContain('Skipped tr: machine-translated draft'); }); test('--include-drafts plans the draft locale too', async () => { ascEnv(); const p = project({ ...BASE, play: undefined }, { 'store/appstore.md': APPLE_MD }); ascRoutes([v('v-1', '1.0.0', 'PREPARE_FOR_SUBMISSION')]); const j = jsonOut((await capture(() => appleListing(p.ctx(['--version', '1.0.0', '--dry', '--json', '--include-drafts'])))).out) as any; expect(j.skipped).toEqual([]); expect(j.appInfo.locales.map((l: any) => l.key)).toEqual(['en-US', 'de-DE', 'tr']); }); test('the real run applies exactly the plan: no write for unchanged locales', async () => { ascEnv(); const p = project({ ...BASE, play: undefined }, { 'store/appstore.md': APPLE_MD }); const calls = ascRoutes([v('v-1', '1.0', 'PREPARE_FOR_SUBMISSION')]); await capture(() => appleListing(p.ctx(['--version', '1.0.0']))); expect(methods(calls)).toEqual(['PATCH /v1/appStoreVersions/v-1', 'PATCH /v1/appInfoLocalizations/il-en', 'POST /v1/appInfoLocalizations', 'POST /v1/appStoreVersionLocalizations']); expect((calls.find(c => c.path === '/v1/appInfoLocalizations/il-en')!.body as any).data.attributes).toEqual({ subtitle: 'Notes that sync' }); expect((calls.find(c => c.path === '/v1/appStoreVersions/v-1')!.body as any).data.attributes).toEqual({ versionString: '1.0.0' }); }); test('a new version is created, then its copied texts are planned again and applied', async () => { ascEnv(); const p = project({ ...BASE, play: undefined }, { 'store/appstore.md': APPLE_MD }); const calls = ascRoutes([v('v-0', '1.0.0', 'READY_FOR_DISTRIBUTION')]); const j = jsonOut((await capture(() => appleListing(p.ctx(['--version', '1.1.0', '--dry', '--json'])))).out) as any; expect(j.versionPlan).toMatchObject({ action: 'create', reason: expect.stringContaining('create version 1.1.0 (no editable iOS version), release type MANUAL') }); expect(j.versionLocales).toBeNull(); await capture(() => appleListing(p.ctx(['--version', '1.1.0']))); expect(methods(calls).filter(m => m.includes('appStoreVersion'))).toEqual(['POST /v1/appStoreVersions', 'POST /v1/appStoreVersionLocalizations']); }); test('a refused version stops the run; the dry run says so and exits 1', async () => { ascEnv(); const p = project({ ...BASE, play: undefined }, { 'store/appstore.md': APPLE_MD }); const calls = ascRoutes([v('v-0', '1.2.0', 'READY_FOR_DISTRIBUTION')]); const r = await capture(() => appleListing(p.ctx(['--version', '1.1.0', '--dry']))); expect(r.out).toContain('REFUSE: version 1.1.0 is not higher than 1.2.0'); expect(r.exit).toBe(1); await expect(appleListing(p.ctx(['--version', '1.1.0']))).rejects.toThrow('not higher than 1.2.0'); expect(methods(calls)).toEqual([]); }); }); describe('play listing plan', () => { const PLAY_MD = '# Play\n\n## English\n\n\n### title\n```\nExample Notes\n```\n\n### short\n```\nNew short\n```\n\n### full\n```\nFull text.\n```\n\n' + '## German\n\n\n\n### short\n```\nKurz\n```\n\n### full\n```\nLang.\n```\n'; const routes = () => mockFetch([ { method: 'POST', match: /\/edits$/, answer: { id: 'edit-1' } }, { match: /\/edits\/edit-1\/details$/, answer: { defaultLanguage: 'en-US', contactEmail: 'support@example.com', contactWebsite: 'https://example.com' } }, { match: /\/edits\/edit-1\/listings$/, answer: { listings: [{ language: 'en-US', title: 'Example Notes', shortDescription: 'Old short', fullDescription: 'Full text.' }] } }, { method: 'DELETE', match: /\/edits\/edit-1$/, answer: new Response(null, { status: 204 }) }, { method: 'PUT', match: /\/edits\/edit-1\/listings\/en-US$/, answer: {} }, { method: 'POST', match: /\/edits\/edit-1:commit$/, answer: {} }, ]); test('--dry --json: old -> new per field, unchanged details, draft language skipped; real run PUTs only the change', async () => { playEnv(); const p = project({ ...BASE, apple: undefined }, { 'store/listing.md': PLAY_MD }); let calls = routes(); const j = jsonOut((await capture(() => playListing(p.ctx(['--dry', '--json'])))).out) as any; expect(j.details.action).toBe('same'); expect(j.listings).toEqual([expect.objectContaining({ key: 'en-US', action: 'update', changes: [{ field: 'shortDescription', from: 'Old short', to: 'New short' }] })]); expect(j.skipped).toEqual([{ key: 'de-DE', reason: expect.stringContaining('draft') }]); expect(calls.some(c => c.method === 'PUT' || c.path.includes(':commit'))).toBe(false); calls = routes(); await capture(() => playListing(p.ctx([]))); expect(calls.filter(c => c.method === 'PUT').map(c => c.path.split('/').slice(-2).join('/'))).toEqual(['listings/en-US']); }); }); describe('apple appinfo plan', () => { test('--dry --json: categories and age rating old -> new; nothing written', async () => { ascEnv(); const p = project({ ...BASE, play: undefined, apple: { ...BASE.apple!, info: { categories: { primary: 'PRODUCTIVITY' }, ageRating: { advertising: true }, free: false } } }); const calls = mockFetch([ { match: /^\/v1\/apps\/6400000101\/appInfos/, answer: page([{ type: 'appInfos', id: 'ai-1', attributes: { state: 'PREPARE_FOR_SUBMISSION' } }]) }, { match: /^\/v1\/appInfos\/ai-1\?include=/, answer: { data: { id: 'ai-1', relationships: { primaryCategory: { data: { type: 'appCategories', id: 'UTILITIES' } } } } } }, { match: /^\/v1\/appInfos\/ai-1\/ageRatingDeclaration/, answer: { data: { id: 'ai-1', attributes: { advertising: false, gambling: false, violenceRealistic: 'NONE' } } } }, ]); const j = jsonOut((await capture(() => appInfo(p.ctx(['--dry', '--json'])))).out) as any; expect(methods(calls)).toEqual([]); expect(j.items[0]).toMatchObject({ key: 'categories', action: 'update', changes: [{ field: 'primaryCategory', from: 'UTILITIES', to: 'PRODUCTIVITY' }] }); expect(j.items[1].changes).toContainEqual({ field: 'advertising', from: 'false', to: 'true' }); expect(j.items[1].changes.find((c: any) => c.field === 'gambling')).toBeUndefined(); }); });