A phone-only project aborted with 'no sevenInchScreenshots'. Play requires the 512 icon, the 1024x500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151). play images uploads what exists and prints an info line for missing optional sets; play.tablets true makes missing tablet sets an error in shots render/check/gallery, validate and play images. Pitfall 19.
394 lines
29 KiB
TypeScript
394 lines
29 KiB
TypeScript
// 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, 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([]);
|
|
expect(h.issuesError).toStartWith(`GET /v1beta1/apps/${PKG}/errorIssues:search -> 400 {"error":{"code":400,"message":"Unsupported timezone`);
|
|
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<!-- play: en-US -->\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<string, string> = { '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<!-- play: en-US -->\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<string, string[]> = { 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<!-- play: en-US -->\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<string, unknown> = {}): Loaded {
|
|
const root = mkdtempSync(join(tmpdir(), 'sb-tabs-'));
|
|
const files: Record<string, string> = { '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<string, string[]>) {
|
|
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<void>): Promise<string> {
|
|
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<string, string[]> = { 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('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<ShipConfig, 'apple' | 'play'>, false)).toEqual(['play', 'feature']);
|
|
expect(defaultSizes(cfg as Pick<ShipConfig, 'apple' | 'play'>, 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 }, { text: 'regions dropped: JP', owner: true }]);
|
|
const inactive = { ...CUR, purchaseOptions: [{ ...CUR.purchaseOptions![0]!, state: 'INACTIVE' }] };
|
|
expect(productChanges(inactive, want)).toEqual([{ text: 'purchase option INACTIVE -> ACTIVE', owner: true }]);
|
|
});
|
|
});
|
|
|
|
describe('play products: price, region and activation changes of existing products ask first', () => {
|
|
// 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<string, unknown>, 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(' ')); };
|
|
try { await productsRun({ loaded, args: parseArgs(argv) }); } finally { [console.log, console.warn, console.error] = [l, w, e]; }
|
|
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') };
|
|
}
|
|
|
|
test('without a terminal: an existing product\'s price change is skipped, its listing change and new products still go', async () => {
|
|
const r = await runWith({ overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }], listings: { en: { title: 'Remove Ads', description: 'Removes ads.' } } });
|
|
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('skipped: confirm in a terminal');
|
|
});
|
|
|
|
test('a price-only change is not written at all without a confirmation; --yes confirms in a terminal-less run', 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(['PATCH /onetimeproducts/com.example.game.no_ads?allowMissing=true&updateMask=listings,purchaseOptions']);
|
|
});
|
|
|
|
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';
|
|
expect((await runWith(over)).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([]);
|
|
});
|
|
});
|