// Play read paths that need POST / DELETE (a throwaway edit, Reporting API :query), against what the real APIs answered // in a guarded read-only run (2026-10; only edit insert / delete, :query and the token exchange were let through). // Recorded shapes are sanitized: package names, ids and hashes are placeholders. // - errorIssues:search answers 400 "Unsupported timezone" for America/Los_Angeles (the metric sets want exactly that // zone; the issues search wants hour-aligned UTC). The failure dropped the crash and ANR rates already read and // health / validate said "vitals not readable" with the error cut off inside the long query string. // - `play listing --dry` and `play images --dry` wrote into the edit (PUT details / listings, image DELETE + uploads, // :validate) before discarding it. Now they read and compare only. // - `play products --dry` (oneTimeProducts list + pricing:convertRegionPrices) said "update · 174 regions" for every // existing product, even when a run would change a regional price or replace the listings. It names the changes. import { afterEach, describe, expect, test } from 'bun:test'; import { createHash, generateKeyPairSync } from 'node:crypto'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { collectPlayHealth } from '../cli/health'; import { parseArgs } from '../cli/lib/args'; import type { Loaded, ShipConfig } from '../cli/lib/config'; import { healthFindings, healthTable, issuesQuery, metricQuery } from '../cli/lib/health'; import { imageChange, missingChange, missingInfo, noListing, run as imagesRun } from '../cli/play/images'; import { defaultSizes } from '../cli/shots/lib'; import { requiredSets, resolvedTablets } from '../cli/shots/report'; import { playRequiredImages } from '../cli/lib/validate-rules'; import { detailsChanges, listingChanges, run as listingRun } from '../cli/play/listing'; import { type Existing, productChanges, run as productsRun } from '../cli/play/products'; import { toMoney } from '../cli/lib/money'; const PKG = 'com.example.game'; const API = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${PKG}`; const REPORTING = `https://playdeveloperreporting.googleapis.com/v1beta1/apps/${PKG}`; // GET apps/{pkg}/crashRateMetricSet, as answered (an app with no vitals data yet) const METRIC_SET = { name: `apps/${PKG}/crashRateMetricSet`, freshnessInfo: { freshnesses: [ { aggregationPeriod: 'HOURLY', latestEndTime: { year: 2026, month: 10, day: 10, hours: 18, timeZone: { id: 'UTC' } } }, { aggregationPeriod: 'DAILY', latestEndTime: { year: 2026, month: 10, day: 8, timeZone: { id: 'America/Los_Angeles' } } }, ] } }; // errorIssues:search with an America/Los_Angeles interval, as sent (pretty-printed) const UNSUPPORTED_TZ = `{ "error": { "code": 400, "message": "Unsupported timezone: id: \\t \\"America/Los_Angeles\\"\\n", "status": "INVALID_ARGUMENT" } } `; // GET edits/{id}/listings: no "video" key when none is set const LISTINGS = { kind: 'androidpublisher#listingsListResponse', listings: [ { language: 'en-US', title: 'Example Game', fullDescription: 'Full text.', shortDescription: 'Short text.' }, { language: 'de-DE', title: 'Example Game', fullDescription: 'Volltext.', shortDescription: 'Kurz.' }, ] }; const image = (sha256: string) => ({ id: '1000000000000000000', url: 'https://lh3.googleusercontent.com/placeholder', sha1: '0'.repeat(40), sha256, aiGeneratedState: 'AI_GENERATED_STATE_UNSPECIFIED' }); const realFetch = globalThis.fetch; const saved = process.env.PLAY_SERVICE_ACCOUNT; afterEach(() => { globalThis.fetch = realFetch; if (saved === undefined) delete process.env.PLAY_SERVICE_ACCOUNT; else process.env.PLAY_SERVICE_ACCOUNT = saved; }); const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } }); function playKey(): void { const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' }); } interface Call { method: string; url: string } /** fetch double: the token exchange, then `route` for every API call; records method + URL. */ function fake(route: (c: Call) => Response): Call[] { const calls: Call[] = []; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const c = { method: (init?.method ?? 'GET').toUpperCase(), url: String(input) }; if (c.url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'tok', expires_in: 3600 }); calls.push(c); return route(c); }) as typeof fetch; return calls; } /** Only what the guarded run let through: GETs, edit insert / delete, Reporting :query. */ const readOnly = (c: Call) => c.method === 'GET' || (c.method === 'POST' && (c.url === `${API}/edits` || /:query$/.test(c.url))) || (c.method === 'DELETE' && /\/edits\/[^/]+$/.test(c.url)); describe('Play vitals: errorIssues:search time zone', () => { test('issues interval is hour-aligned UTC; the metric timelines stay on America/Los_Angeles days', () => { const q = new URLSearchParams(issuesQuery({ year: 2026, month: 10, day: 8 }, 7)); expect(q.get('interval.startTime.timeZone.id')).toBe('UTC'); expect(q.get('interval.endTime.timeZone.id')).toBe('UTC'); expect([q.get('interval.startTime.hours'), q.get('interval.endTime.hours')]).toEqual(['0', '0']); expect([q.get('interval.startTime.day'), q.get('interval.endTime.day')]).toEqual(['1', '8']); expect(issuesQuery({ year: 2026, month: 10, day: 8 }, 7)).not.toContain('Los_Angeles'); expect(JSON.stringify(metricQuery(['crashRate'], { year: 2026, month: 10, day: 8 }, 7))).toContain('America/Los_Angeles'); }); const reporting = (issues: (url: string) => Response) => fake(c => { if (c.url === `${REPORTING}/crashRateMetricSet`) return json(METRIC_SET); if (c.method === 'POST' && /MetricSet:query$/.test(c.url)) return json({}); if (c.url.startsWith(`${REPORTING}/errorIssues:search?`)) return issues(c.url); return json({ error: { code: 404 } }, 404); }); test('collect reads rates and issues with the zones each endpoint takes (the real API rejects LA for issues)', async () => { playKey(); const calls = reporting(url => (url.includes('America%2FLos_Angeles') ? new Response(UNSUPPORTED_TZ, { status: 400 }) : json({}))); const h = await collectPlayHealth(mkdtempSync(join(tmpdir(), 'sb-post-')), PKG, 7); expect(h.issuesError).toBeUndefined(); expect(h.window).toEqual({ start: '2026-10-01', end: '2026-10-08', days: 7 }); expect(calls.every(readOnly)).toBe(true); expect(calls.filter(c => c.method === 'POST').length).toBe(4); expect(healthFindings({ date: '2026-10-11', play: h })).toEqual([expect.objectContaining({ level: 'ok', msg: 'no vitals data yet (too few users or no release)' })]); }); test('a failing issues search keeps the rates; the reason is readable, not cut off in the query string', async () => { playKey(); reporting(() => new Response(UNSUPPORTED_TZ, { status: 400 })); const h = await collectPlayHealth(mkdtempSync(join(tmpdir(), 'sb-post-')), PKG, 7); expect(h.crash).toEqual([]); // one plain line: the API's message without the JSON around it (pitfall 57) expect(h.issuesError).toStartWith(`GET /v1beta1/apps/${PKG}/errorIssues:search -> 400 Unsupported timezone`); expect(h.issuesError).not.toContain('{"error"'); const f = healthFindings({ date: '2026-10-11', play: h }); expect(f.map(x => x.code)).toEqual(['vitals', 'vitals-issues']); expect(f[1]).toMatchObject({ level: 'warn', msg: expect.stringContaining('Unsupported timezone') }); expect(healthTable({ date: '2026-10-11', play: h })).toMatch(/top issues\s+not read/); }); }); describe('play listing --dry: read and compare only', () => { test('changes per language; empty equals missing; a Play video the config leaves out is cleared', () => { const wanted = [ { language: 'en-US', title: 'Example Game', shortDescription: 'Short text.', fullDescription: 'Full text.' }, { language: 'de-DE', title: 'Example Game', shortDescription: 'Neu.', fullDescription: 'Volltext.' }, { language: 'fr-FR', title: 'Example Game', shortDescription: 'Court.', fullDescription: 'Texte.' }, ]; expect(listingChanges(LISTINGS.listings, wanted)).toEqual([ { lang: 'en-US', change: 'same', fields: [] }, { lang: 'de-DE', change: 'update', fields: ['shortDescription'] }, { lang: 'fr-FR', change: 'new', fields: [] }, ]); const withVideo = [{ ...LISTINGS.listings[0]!, video: 'https://www.youtube.com/watch?v=placeholder' }]; expect(listingChanges(withVideo, [wanted[0]!])[0]!.fields).toEqual(['video (cleared)']); expect(detailsChanges({ defaultLanguage: 'en-US', contactEmail: 'a@example.com', contactWebsite: 'https://example.com' }, { defaultLanguage: 'en-US', contactEmail: 'b@example.com', contactWebsite: 'https://example.com' })).toEqual(['contactEmail']); }); test('the run opens an edit, reads details and listings, deletes the edit; no PUT, no :validate, no commit', async () => { playKey(); const root = mkdtempSync(join(tmpdir(), 'sb-post-')); mkdirSync(join(root, 'store')); writeFileSync(join(root, 'store/listing.md'), '## English\n\n\n### Play short description\n```\nShort text.\n```\n\n### Play full description\n```\nFull text.\n```\n'); const config = { app: { name: 'Example Game', contactEmail: 'support@example.com', website: 'https://example.com' }, languages: [{ id: 'en', play: 'en-US' }], play: { packageName: PKG } } as unknown as ShipConfig; const loaded: Loaded = { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) }; const calls = fake(c => { if (c.method === 'POST' && c.url === `${API}/edits`) return json({ id: 'edit-1', expiryTimeSeconds: '1760000000' }); if (c.url === `${API}/edits/edit-1/details`) return json({ defaultLanguage: 'en-US', contactWebsite: 'https://example.com', contactEmail: 'support@example.com' }); if (c.url === `${API}/edits/edit-1/listings`) return json(LISTINGS); if (c.method === 'DELETE') return new Response(null, { status: 204 }); return json({ error: { code: 400, message: 'unexpected' } }, 400); }); await listingRun({ loaded, args: parseArgs(['--dry']) }); expect(calls.map(c => `${c.method} ${c.url.replace(API, '')}`)).toEqual(['POST /edits', 'GET /edits/edit-1/details', 'GET /edits/edit-1/listings', 'DELETE /edits/edit-1']); }); }); describe('play images --dry: hashes compared, nothing uploaded', () => { const sha = (s: string) => createHash('sha256').update(s).digest('hex'); test('same files in the same order are unchanged; any other difference replaces the set', () => { expect(imageChange('icon', [image(sha('a'))], [sha('a')])).toEqual({ type: 'icon', change: 'same', remote: 1, local: 1 }); expect(imageChange('phoneScreenshots', [image(sha('a')), image(sha('b'))], [sha('b'), sha('a')]).change).toBe('replace'); expect(imageChange('phoneScreenshots', [], [sha('a'), sha('b')])).toMatchObject({ change: 'replace', remote: 0, local: 2 }); }); test('the run reads every image type of a language in one edit and deletes it; no image DELETE, upload or :validate', async () => { playKey(); const root = mkdtempSync(join(tmpdir(), 'sb-post-')); const files: Record = { 'store/icons/play-512.png': 'icon', 'store/renders/en/feature/1024x500.png': 'feature' }; for (const sub of ['play', 'tab7', 'tab10']) for (const n of ['01', '02']) files[`store/renders/en/${sub}/${n}.png`] = `${sub}${n}`; for (const [p, body] of Object.entries(files)) { mkdirSync(join(root, p, '..'), { recursive: true }); writeFileSync(join(root, p), body); } mkdirSync(join(root, 'store'), { recursive: true }); writeFileSync(join(root, 'store/listing.md'), '## English\n\n\n### Play short description\n```\nShort.\n```\n\n### Play full description\n```\nFull.\n```\n'); const config = { app: { name: 'Example Game' }, languages: [{ id: 'en', play: 'en-US' }], play: { packageName: PKG } } as unknown as ShipConfig; const loaded: Loaded = { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) }; const remote: Record = { icon: ['icon'], featureGraphic: ['old'], phoneScreenshots: ['play01', 'play02'], sevenInchScreenshots: ['tab701', 'tab702'], tenInchScreenshots: [] }; const calls = fake(c => { if (c.method === 'POST' && c.url === `${API}/edits`) return json({ id: 'edit-2' }); const m = c.url.match(/\/edits\/edit-2\/listings\/en-US\/(\w+)$/); if (c.method === 'GET' && m) return json(remote[m[1]!]?.length ? { images: remote[m[1]!]!.map(s => image(sha(s))) } : {}); if (c.method === 'DELETE' && c.url === `${API}/edits/edit-2`) return new Response(null, { status: 204 }); return json({ error: { code: 400, message: 'unexpected' } }, 400); }); const lines: string[] = []; const log = console.log; console.log = (...a: unknown[]) => { lines.push(a.join(' ')); }; try { await imagesRun({ loaded, args: parseArgs(['--dry']) }); } finally { console.log = log; } expect(calls.every(readOnly)).toBe(true); expect(calls.filter(c => c.method === 'POST').length).toBe(1); expect(calls.at(-1)).toEqual({ method: 'DELETE', url: `${API}/edits/edit-2` }); expect(lines.join('\n')).toContain('en-US: would replace featureGraphic (1 -> 1), tenInchScreenshots (0 -> 2)'); }); }); describe('play images: tablet, TV and Wear sets are optional (play.tablets)', () => { const sha = (s: string) => createHash('sha256').update(s).digest('hex'); const LISTING_MD = '## English\n\n\n### Play short description\n```\nShort.\n```\n\n### Play full description\n```\nFull.\n```\n'; /** A phone-only project like STAR DASH: renders/en has feature, ios65, ios69 and play; no tab7 / tab10. */ function phoneOnly(play: Record = {}): Loaded { const root = mkdtempSync(join(tmpdir(), 'sb-tabs-')); const files: Record = { 'store/icons/play-512.png': 'icon', 'store/renders/en/feature/1024x500.png': 'feature', 'store/listing.md': LISTING_MD }; for (const sub of ['play', 'ios65', 'ios69']) for (const n of ['01', '02']) files[`store/renders/en/${sub}/${n}.png`] = `${sub}${n}`; for (const [p, body] of Object.entries(files)) { mkdirSync(join(root, p, '..'), { recursive: true }); writeFileSync(join(root, p), body); } const config = { app: { name: 'Example Game' }, languages: [{ id: 'en', play: 'en-US' }], play: { packageName: PKG, ...play } } as unknown as ShipConfig; return { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) }; } /** Play double: images per type (Play already has 7" tablet screenshots), edits, uploads, commit; records calls. */ function store(remote: Record) { return fake(c => { if (c.method === 'POST' && c.url === `${API}/edits`) return json({ id: 'edit-3' }); const m = c.url.match(/\/edits\/edit-3\/listings\/en-US\/(\w+)(\?.*)?$/); if (c.method === 'GET' && m) return json(remote[m[1]!]?.length ? { images: remote[m[1]!]!.map(s => image(sha(s))) } : {}); if (c.method === 'DELETE' && m) { remote[m[1]!] = []; return json({ deleted: [] }); } if (c.method === 'POST' && m) return json({ image: image(sha('x')) }); if (c.method === 'POST' && c.url.endsWith(':commit')) return json({ id: 'edit-3' }); if (c.method === 'DELETE' && c.url === `${API}/edits/edit-3`) return new Response(null, { status: 204 }); return json({ error: { code: 400, message: `unexpected ${c.method} ${c.url}` } }, 400); }); } async function capture(fn: () => Promise): Promise { const lines: string[] = []; const log = console.log; console.log = (...a: unknown[]) => { lines.push(a.join(' ')); }; try { await fn(); } finally { console.log = log; } return lines.join('\n'); } test('phone-only renders: the dry plan lists icon, feature graphic and phone screenshots, an info line for tablets, exit 0', async () => { playKey(); const loaded = phoneOnly(); const calls = store({ sevenInchScreenshots: ['old701', 'old702'] }); const before = process.exitCode; const out = await capture(() => imagesRun({ loaded, args: parseArgs(['--dry']) })); expect(process.exitCode).toBe(before); expect(out).toContain('1 languages: icon (1), featureGraphic (1), phoneScreenshots (2)'); expect(out).toContain("tablet screenshots: none (optional; the app isn't eligible for tablet featuring without them)"); expect(out).toContain('TV / Wear screenshots: none (optional; only for Android TV / Wear OS apps)'); expect(out).toContain('en-US: would replace icon (0 -> 1), featureGraphic (0 -> 1), phoneScreenshots (0 -> 2); keeps sevenInchScreenshots on Play (2, none locally; --prune deletes them)'); expect(calls.every(readOnly)).toBe(true); }); test('--dry --prune shows the tablet set Play has as a delete; still read-only', async () => { playKey(); const calls = store({ icon: ['icon'], featureGraphic: ['feature'], phoneScreenshots: ['play01', 'play02'], sevenInchScreenshots: ['old701'] }); const out = await capture(() => imagesRun({ loaded: phoneOnly(), args: parseArgs(['--dry', '--prune']) })); expect(out).toContain('en-US: would delete sevenInchScreenshots (1 on Play, none locally; --prune)'); expect(calls.every(readOnly)).toBe(true); }); test('a real run uploads the phone sets and never deletes the tablet screenshots on Play without --prune', async () => { playKey(); const remote: Record = { sevenInchScreenshots: ['old701', 'old702'] }; const calls = store(remote); await capture(() => imagesRun({ loaded: phoneOnly(), args: parseArgs([]) })); const writes = calls.filter(c => !readOnly(c)).map(c => `${c.method} ${c.url.replace(API, '').replace(/^.*\/upload\/androidpublisher\/v3\/applications\/[^/]+/, 'UPLOAD').replace(/\?.*$/, '')}`); expect(writes.filter(w => w.startsWith('POST UPLOAD'))).toHaveLength(4); expect(writes.some(w => w.includes('sevenInch') || w.includes('tenInch'))).toBe(false); expect(writes.at(-1)).toBe('POST /edits/edit-3:commit'); expect(remote.sevenInchScreenshots).toHaveLength(2); }); test('play.tablets: true with no tablet renders is a clear error before any store call', async () => { playKey(); const calls = store({}); await expect(imagesRun({ loaded: phoneOnly({ tablets: true }), args: parseArgs(['--dry']) })) .rejects.toThrow(/en-US: play.tablets is true but there are no sevenInchScreenshots in .*tab7 \(render them: storebound shots render --size tab7,tab10, or set play.tablets: false\)/); expect(calls).toEqual([]); }); test('a missing required set is still an error with the fix', async () => { playKey(); const loaded = phoneOnly(); rmSync(join(loaded.root, 'store/renders/en/play'), { recursive: true }); store({}); await expect(imagesRun({ loaded, args: parseArgs(['--dry']) })).rejects.toThrow(/en-US: no phoneScreenshots in .*play \(at least 2, required; render first: storebound shots render\)/); }); test('play.tablets: false skips tablet renders that exist; the default follows the renders', async () => { playKey(); const loaded = phoneOnly({ tablets: false }); for (const n of ['01', '02']) { mkdirSync(join(loaded.root, 'store/renders/en/tab7'), { recursive: true }); writeFileSync(join(loaded.root, `store/renders/en/tab7/${n}.png`), `t${n}`); } store({}); const out = await capture(() => imagesRun({ loaded, args: parseArgs(['--dry']) })); expect(out).toContain('play.tablets is false: tablet screenshots are not uploaded'); expect(out).not.toContain('sevenInchScreenshots (0 -> 2)'); expect(resolvedTablets({ play: {} }, join(loaded.root, 'store/renders'))).toBe(true); expect(resolvedTablets({ play: { tablets: false } }, join(loaded.root, 'store/renders'))).toBe(false); expect(resolvedTablets({ play: {} }, join(phoneOnly().root, 'store/renders'))).toBe(false); }); test('a draft section is skipped before any call, a language with a 404 listing is created by the run (dry: named); exit 0', async () => { playKey(); const root = mkdtempSync(join(tmpdir(), 'sb-langs-')); const sec = (name: string, code: string, marker = '') => `## ${name}\n\n${marker}\n### Play short description\n\`\`\`\nShort.\n\`\`\`\n\n### Play full description\n\`\`\`\nFull.\n\`\`\`\n\n`; const files: Record = { 'store/icons/play-512.png': 'icon', 'store/listing.md': sec('English', 'en-US') + sec('German', 'de-DE') + sec('French', 'fr-FR', '\n') }; // de-DE has local renders but no listing on Play; fr-FR (draft) has no renders at all and must not fail for (const l of ['en', 'de']) { files[`store/renders/${l}/feature/1024x500.png`] = 'feature'; for (const n of ['01', '02']) files[`store/renders/${l}/play/${n}.png`] = `play${n}`; } for (const [p, body] of Object.entries(files)) { mkdirSync(join(root, p, '..'), { recursive: true }); writeFileSync(join(root, p), body); } const config = { app: { name: 'Example Game' }, languages: [{ id: 'en', play: 'en-US' }, { id: 'de', play: 'de-DE' }, { id: 'fr', play: 'fr-FR' }], play: { packageName: PKG } } as unknown as ShipConfig; const loaded: Loaded = { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) }; const calls = fake(c => { if (c.method === 'POST' && c.url === `${API}/edits`) return json({ id: 'edit-4' }); if (c.method === 'GET' && c.url.includes('/listings/de-DE/')) return json({ error: { code: 404, message: "Listing for language 'de-DE' not found.", status: 'NOT_FOUND' } }, 404); if (c.method === 'GET' && c.url.includes('/listings/en-US/')) return json({}); if (c.method === 'DELETE' && c.url === `${API}/edits/edit-4`) return new Response(null, { status: 204 }); return json({ error: { code: 400, message: `unexpected ${c.method} ${c.url}` } }, 400); }); const before = process.exitCode; const out = await capture(() => imagesRun({ loaded, args: parseArgs(['--dry']) })); expect(process.exitCode).toBe(before); expect(out).toContain('skipped fr-FR: no Play listing yet (machine-translated draft, not reviewed'); expect(out).toContain('de-DE: no Play listing yet, this run creates it from the listing file with its images'); expect(out).toContain('de-DE: would replace icon (0 -> 1)'); expect(out).toContain('en-US: would replace icon (0 -> 1)'); expect(calls.some(c => c.url.includes('fr-FR'))).toBe(false); expect(calls.every(readOnly)).toBe(true); expect(noListing(new Error("GET /edits/1/listings/de-DE/icon -> 404 Listing for language 'de-DE' not found."))).toBe(true); expect(noListing(new Error('GET /edits/1/listings/en-US/icon -> 500 backend'))).toBe(false); }); test('pure parts: missing optional sets, required sets and render sizes follow play.tablets', () => { expect(missingChange('tenInchScreenshots', 0, false).change).toBe('none'); expect(missingChange('tenInchScreenshots', 3, false).change).toBe('keep'); expect(missingChange('tenInchScreenshots', 3, true).change).toBe('delete'); expect(missingInfo(new Set(['tvScreenshots']))).toEqual(['tvScreenshots: none (optional)']); const cfg = { play: { packageName: PKG } }; expect(requiredSets(cfg).tab7).toBeUndefined(); expect(requiredSets(cfg, undefined, true)).toMatchObject({ tab7: true, tab10: true }); expect(defaultSizes(cfg as Pick, false)).toEqual(['play', 'feature']); expect(defaultSizes(cfg as Pick, true)).toEqual(['play', 'tab7', 'tab10', 'feature']); expect(playRequiredImages(false)).toEqual({ icon: 1, featureGraphic: 1, phoneScreenshots: 2 }); expect(Object.keys(playRequiredImages(true))).toContain('tenInchScreenshots'); }); }); describe('play products --dry: what would change on an existing product', () => { // one product of oneTimeProducts.list, as answered (3 of 174 regions; Money without nanos for whole prices) const CUR: Existing = { productId: 'com.example.game.no_ads', listings: [{ languageCode: 'en-US', title: 'No Ads', description: 'Removes interstitial ads.' }, { languageCode: 'tr-TR', title: 'Reklamsız', description: 'Ara reklamları kaldırır.' }], purchaseOptions: [{ purchaseOptionId: 'buy', state: 'ACTIVE', regionalPricingAndAvailabilityConfigs: [ { regionCode: 'AE', price: { currencyCode: 'AED', units: '11', nanos: 990000000 }, availability: 'AVAILABLE' }, { regionCode: 'JP', price: { currencyCode: 'JPY', units: '450' }, availability: 'AVAILABLE' }, { regionCode: 'TR', price: { currencyCode: 'TRY', units: '129', nanos: 990000000 }, availability: 'AVAILABLE' }], newRegionsConfig: { usdPrice: { currencyCode: 'USD', units: '2', nanos: 990000000 }, eurPrice: { currencyCode: 'EUR', units: '2', nanos: 670000000 }, availability: 'AVAILABLE' } }] }; const region = (regionCode: string, c: string, v: number) => ({ regionCode, availability: 'AVAILABLE' as const, price: toMoney(c, v) }); const want = { listings: CUR.listings!.map(l => ({ languageCode: l.languageCode, title: l.title!, description: l.description! })), regions: [region('AE', 'AED', 11.99), region('JP', 'JPY', 450), region('TR', 'TRY', 129.99)], newRegions: { usdPrice: toMoney('USD', 2.99), eurPrice: toMoney('EUR', 2.67) } }; test('the same product converted again is unchanged (toMoney nanos 0 equals Money without nanos)', () => { expect(productChanges(CUR, want)).toEqual([]); expect(productChanges(undefined, want)).toEqual([{ text: 'create', owner: false }]); }); test('a price override, a changed and a dropped listing, a dropped region are named', () => { const w = { ...want, listings: [{ ...want.listings[0]!, title: 'Remove Ads' }], regions: [want.regions[0]!, region('TR', 'TRY', 99.99)] }; expect(productChanges(CUR, w)).toEqual([ { text: 'listings changed: en-US', owner: false }, { text: 'listings removed: tr-TR', owner: false }, { text: 'prices in 1 region(s): TR TRY 129.99 -> TRY 99.99', owner: true, prices: [{ region: 'TR', from: 'TRY 129.99', to: 'TRY 99.99' }] }, { text: 'regions dropped: JP', owner: true, prices: [{ region: 'JP', from: 'JPY 450.00', to: 'not sold' }] }]); // activation is not a price change: --yes confirms it const inactive = { ...CUR, purchaseOptions: [{ ...CUR.purchaseOptions![0]!, state: 'INACTIVE' }] }; expect(productChanges(inactive, want)).toEqual([{ text: 'purchase option INACTIVE -> ACTIVE', owner: false }]); }); }); describe('play products: price and region changes of existing products are the owner\'s (--allow-price-changes, never --yes)', () => { // the existing product as listed (2 regions), and what convertRegionPrices answers for USD 2.99 const LISTED = { productId: 'com.example.game.no_ads', listings: [{ languageCode: 'en-US', title: 'No Ads', description: 'Removes ads.' }], purchaseOptions: [{ purchaseOptionId: 'buy', state: 'ACTIVE', buyOption: { legacyCompatible: true }, regionalPricingAndAvailabilityConfigs: [ { regionCode: 'TR', price: { currencyCode: 'TRY', units: '129', nanos: 990000000 }, availability: 'AVAILABLE' }, { regionCode: 'US', price: { currencyCode: 'USD', units: '2', nanos: 990000000 }, availability: 'AVAILABLE' }], newRegionsConfig: { usdPrice: { currencyCode: 'USD', units: '2', nanos: 990000000 }, eurPrice: { currencyCode: 'EUR', units: '2', nanos: 670000000 }, availability: 'AVAILABLE' } }] }; const CONVERTED = { convertedRegionPrices: { TR: { regionCode: 'TR', price: { currencyCode: 'TRY', units: '129', nanos: 990000000 } }, US: { regionCode: 'US', price: { currencyCode: 'USD', units: '2', nanos: 990000000 } } }, convertedOtherRegionsPrice: { usdPrice: { currencyCode: 'USD', units: '2', nanos: 990000000 }, eurPrice: { currencyCode: 'EUR', units: '2', nanos: 670000000 } }, regionVersion: { version: '2026/01' } }; const env = { ...process.env }; afterEach(() => { for (const k of ['STOREBOUND_SERVE', 'STOREBOUND_OWNER_CONFIRMED']) { if (env[k] === undefined) delete process.env[k]; else process.env[k] = env[k]; } }); async function runWith(product: Record, argv: string[] = []) { playKey(); const root = mkdtempSync(join(tmpdir(), 'sb-post-')); const config = { app: { name: 'Example Game' }, languages: [{ id: 'en', play: 'en-US' }], play: { packageName: PKG }, products: [{ id: 'com.example.game.no_ads', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No Ads', description: 'Removes ads.' } }, ...product }, { id: 'com.example.game.gems_new', type: 'consumable', usd: 2.99, listings: { en: { title: 'Gems', description: 'A bag of gems.' } } }] } as unknown as ShipConfig; const loaded: Loaded = { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) }; const calls = fake(c => { if (c.url.startsWith(`${API}/oneTimeProducts?`)) return json({ oneTimeProducts: [LISTED] }); if (c.url === `${API}/pricing:convertRegionPrices`) return json(CONVERTED); return json({}); }); const out: string[] = []; const [l, w, e] = [console.log, console.warn, console.error]; console.log = console.warn = console.error = (...a: unknown[]) => { out.push(a.join(' ')); }; const before = process.exitCode; process.exitCode = 0; let exit = 0; try { await productsRun({ loaded, args: parseArgs(argv) }); } finally { [console.log, console.warn, console.error] = [l, w, e]; exit = Number(process.exitCode ?? 0); process.exitCode = before; } const writes = calls.filter(c => c.method !== 'GET' && !c.url.endsWith(':convertRegionPrices')).map(c => `${c.method} ${c.url.replace(API, '').replace(/regionsVersion\.version=[^&]+&?/, '')}`); return { writes, out: out.join('\n'), exit }; } test('flow-style run (--yes, no terminal): the listing change and new products go, the price stays, exit 3 naming the change', async () => { for (const argv of [[], ['--yes']]) { const r = await runWith({ overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }], listings: { en: { title: 'Remove Ads', description: 'Removes ads.' } } }, argv); expect(r.out).toContain('update listings changed: en-US; prices in 1 region(s): TR TRY 129.99 -> TRY 99.99'); expect(r.writes).toEqual([ 'PATCH /onetimeproducts/com.example.game.no_ads?updateMask=listings', 'PATCH /onetimeproducts/com.example.game.gems_new?allowMissing=true&updateMask=listings,purchaseOptions', 'POST /oneTimeProducts/com.example.game.gems_new/purchaseOptions:batchUpdateStates', ]); expect(r.out).toContain('price change on com.example.game.no_ads (TR TRY 129.99 -> TRY 99.99) needs the owner: re-run with --allow-price-changes'); expect(r.out).toContain('declined: 1 price change(s) of existing products need the owner'); expect(r.exit).toBe(3); } }); test('a price-only change: never written with --yes; --allow-price-changes writes it (exit 0)', async () => { const over = { overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }] }; expect((await runWith(over)).writes.filter(w => w.includes('no_ads'))).toEqual([]); expect((await runWith(over, ['--yes'])).writes.filter(w => w.includes('no_ads'))).toEqual([]); const ok = await runWith(over, ['--yes', '--allow-price-changes']); expect(ok.writes.filter(w => w.includes('no_ads'))).toEqual(['PATCH /onetimeproducts/com.example.game.no_ads?allowMissing=true&updateMask=listings,purchaseOptions']); expect([ok.exit, ok.out.includes('needs the owner')]).toEqual([0, false]); }); test('the dry run marks the price row "owner"', async () => { const r = await runWith({ overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }] }, ['--dry']); expect(r.out).toMatch(/com\.example\.game\.no_ads\s+update \(owner: price change, --allow-price-changes\)/); expect(r.writes).toEqual([]); }); test('under serve: refused even with --yes; a verified desktop owner confirmation lets it through', async () => { const over = { overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }] }; process.env.STOREBOUND_SERVE = '1'; expect((await runWith(over, ['--yes'])).writes.filter(w => w.includes('no_ads'))).toEqual([]); process.env.STOREBOUND_OWNER_CONFIRMED = '1'; // serve adds --allow-price-changes to the owner action's real run (serve.ts OWNER_REAL_ONLY); without it nothing changes expect((await runWith(over)).writes.filter(w => w.includes('no_ads'))).toEqual([]); expect((await runWith(over, ['--allow-price-changes'])).writes.filter(w => w.includes('no_ads'))).toEqual(['PATCH /onetimeproducts/com.example.game.no_ads?allowMissing=true&updateMask=listings,purchaseOptions']); }); test('nothing to change: no write for the existing product', async () => { expect((await runWith({})).writes.filter(w => w.includes('no_ads'))).toEqual([]); }); });