// Store-only apps edit their listing straight on the store (`storebound listing set`, serve method listing.set): the // plan (old -> new per field, only the fields sent are judged, the App Store version rule), and the serve rules: the // only write a store-only app (virtual) may run, a dry run unless the host's token for exactly those store ids comes. import { describe, expect, test } from 'bun:test'; import { mint } from '../cli/lib/confirm-token'; import { ERR } from '../cli/lib/desktop-protocol'; import { setPlan } from '../cli/listing-store'; import type { RunOptions, Running } from '../cli/lib/runner'; import { Session } from '../cli/serve'; describe('listing set: the plan', () => { test('old -> new for the fields sent, the rest left alone', () => { const p = setPlan('play', 'en-US', { title: 'Tidy', short: 'Old short', full: 'Full text.' }, { short: 'New short' }, { appName: 'Tidy' }); expect(p.changes).toEqual([{ field: 'short', from: 'Old short', to: 'New short' }]); expect(p.issues.filter(i => i.level === 'error')).toEqual([]); expect(setPlan('play', 'en-US', { short: 'Same' }, { short: 'Same ' }, { appName: 'A' }).changes).toEqual([]); }); test('limits and rules of the sent fields; a new language', () => { const p = setPlan('apple', 'en-US', null, { subtitle: 'x'.repeat(31) }, { appName: 'Tidy', editable: true }); expect(p.create).toBe(true); expect(p.issues.map(i => [i.field, i.level])).toContainEqual(['subtitle', 'error']); // required fields the run does not send are not this run's problem expect(p.issues.some(i => i.field === 'keywords')).toBe(false); }); test('App Store: without an editable version only name, subtitle and promotional text', () => { expect(setPlan('apple', 'en-US', { promotionalText: 'a' }, { promotionalText: 'b' }, { appName: 'A', editable: false }).refused).toBeNull(); expect(setPlan('apple', 'en-US', { description: 'a' }, { description: 'b' }, { appName: 'A', editable: false }).refused).toMatch(/no editable App Store version/); }); }); const SECRET_HEX = Array.from({ length: 32 }, (_, i) => (i + 3).toString(16).padStart(2, '0')).join(''); const SECRET = Buffer.from(SECRET_HEX, 'hex'); type J = Record; async function session(now = () => Date.now()) { const calls: { argv: string[]; o: RunOptions }[] = []; const run = (argv: string[], o: RunOptions): Running => { calls.push({ argv, o }); return { result: Promise.resolve({ code: 0, out: 'done', cancelled: false }), cancel: () => {} }; }; const s = new Session(() => {}, run, now); let id = 0; const call = async (method: string, params: unknown = {}) => (await s.handle({ jsonrpc: '2.0', id: ++id, method, params }))! as J; await call('session.hello', { secret: SECRET_HEX }); return { calls, call }; } const err = (r: J) => r.error as { code: number; message: string } | undefined; describe('serve: listing.set is the one write of a store-only app', () => { const virt = { name: 'com.example.tidy', play: { packageName: 'com.example.tidy' } }; const args = { store: 'play', lang: 'en-US', fields: JSON.stringify({ short: 'Lists that stay tidy' }) }; test('dry run with the store ids, the real run only with a token bound to them', async () => { const now = 5_000_000; const s = await session(() => now); const dry = await s.call('listing.set', { virtual: virt, args }); expect(err(dry)).toBeUndefined(); expect(s.calls.at(-1)!.argv).toEqual(['listing', 'set', '--store', 'play', '--lang', 'en-US', '--fields', args.fields, '--dry', '--json']); expect(err(await s.call('listing.set', { virtual: virt, args, dryRun: false }))!.code).toBe(ERR.confirmRequired); // a token for a folder, or for another app's store ids, does not fit expect(err(await s.call('listing.set', { virtual: virt, args, dryRun: false, confirmToken: mint(SECRET, 'listing.set', { project: '/x', args }, { now }) }))!.code).toBe(ERR.confirmInvalid); expect(err(await s.call('listing.set', { virtual: virt, args, dryRun: false, confirmToken: mint(SECRET, 'listing.set', { virtual: { ...virt, name: 'other' }, args }, { now }) }))!.code).toBe(ERR.confirmInvalid); const real = await s.call('listing.set', { virtual: virt, args, dryRun: false, confirmToken: mint(SECRET, 'listing.set', { virtual: virt, args }, { now }) }); expect(err(real)).toBeUndefined(); expect(s.calls.at(-1)!.argv).not.toContain('--dry'); }); test('folder writes and other store writes stay refused for store-only apps; listing.live reads', async () => { const s = await session(); for (const m of ['play.listing', 'apple.listing', 'project.edit', 'play.images']) { const r = await s.call(m, { virtual: virt, args: m === 'project.edit' ? { ops: '[]' } : {} }); expect(err(r)?.message, m).toMatch(/needs an app folder/); } expect(err(await s.call('listing.live', { virtual: virt }))).toBeUndefined(); expect(s.calls.at(-1)!.argv).toEqual(['listing', 'live', '--json']); }); });