162 lines
14 KiB
TypeScript
162 lines
14 KiB
TypeScript
// 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<string, string>, 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' }, '<!-- storebound-translation from=en-US status=draft -->');
|
|
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: 'create version 1.1.0 (no editable iOS version)' });
|
|
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<!-- play: en-US -->\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<!-- play: de-DE -->\n<!-- storebound-translation from=en-US status=draft -->\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();
|
|
});
|
|
});
|