storebound/tests/listing-plan.test.ts

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: 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<!-- 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();
});
});