// Pitfall 72: after a rejection (or with other pending changes / managed publishing) Play refuses to send an edit for // review automatically: validate / commit answer 400 "Changes cannot be sent for review automatically. Please set the // query parameter changesNotSentForReview to true". storebound validates and commits the same edit again with that // parameter, prints the Publishing overview link (the owner's button), records it in .storebound/ and `play status` // shows it. The fake Play store (tests/helpers/fake-stores.ts) refuses as often as `refuseAutoReview` says. import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { setConsoleIds } from '../cli/lib/console-links'; import { run as playRelease } from '../cli/play/release'; import { run as playStatus } from '../cli/play/status'; import { isReviewRefused, readPending, recordPending } from '../cli/play/review-pending'; import { BASE, capture, playEnv, project, restore } from './helpers/store-mock'; import { installStores, type Stores, uninstallStores } from './helpers/fake-stores'; const LINK = 'https://play.google.com/console/u/0/developers/1234567890123456789/app/4970000000000000001/publishing'; const LISTING = '# Play\n\n## English\n\n\n### Play short description\n```\nNotes.\n```\n\n### Play full description\n```\nNotes for everyone.\n```\n\n### What\'s new\n```\nFixes.\n```\n'; let s: Stores; beforeAll(() => { playEnv(); setConsoleIds({ playDeveloperId: '1234567890123456789', playAppId: '4970000000000000001' }); }); afterAll(() => { restore(); setConsoleIds({}); }); beforeEach(() => { s = installStores(); s.play.live.bundles = [5]; }); afterEach(() => { uninstallStores(); }); const edits = (what: string): string[] => s.calls.filter(c => c.method === 'POST' && c.path.includes(`:${what}`)).map(c => c.path.replace(/^.*\/edits\//, '')); const release = (p: ReturnType, extra: string[] = []) => capture(() => playRelease(p.ctx(['--version-code', '5', '--internal', 'completed', '--production', 'completed', '--name', '1.0.1 (5)', '--yes', ...extra]))); describe('Play refuses the automatic send for review', () => { test('the 400 is recognised by its message (and a bad-request reason), nothing else', () => { const msg = 'POST /edits/e1:validate -> 400 {"error":{"code":400,"message":"Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true.","errors":[{"reason":"badRequest"}]}}'; expect(isReviewRefused(new Error(msg))).toBe(true); expect(isReviewRefused(new Error(msg.replace('badRequest', 'forbidden')))).toBe(false); expect(isReviewRefused(new Error(msg.replace('-> 400', '-> 403')))).toBe(false); expect(isReviewRefused(new Error('POST /edits/e1:commit -> 400 {"error":{"message":"Version code 5 has already been used."}}'))).toBe(false); }); test('real run: the commit is refused once, then the same edit is committed with changesNotSentForReview=true (validate never takes it)', async () => { const p = project(BASE, { 'store/listing.md': LISTING }); s.play.refuseAutoReview = 1; const r = await release(p); expect(r.exit ?? 0).toBe(0); expect(edits('commit')).toEqual(['edit-1:commit', 'edit-1:commit?changesNotSentForReview=true']); expect(edits('validate').filter(v => v.includes('changesNotSentForReview'))).toEqual([]); expect(s.play.validateWithNotSent).toBe(0); expect(s.calls.filter(c => c.method === 'POST' && /\/edits$/.test(c.path))).toHaveLength(1); // one edit, never reopened expect(s.play.committed).toEqual(['edit-1']); expect(s.play.live.tracks.production).toBeDefined(); const all = r.out + r.err; expect(all).toContain(`committed; Play did not send it for review: press Send changes for review in Publishing overview: ${LINK}`); const rec = readPending(p.root, 'com.example.notes'); expect(rec).toMatchObject({ editId: 'edit-1', forced: false }); expect(rec!.summary).toContain('1.0.1 (5)'); expect(rec!.summary).toContain('production completed'); }); test('dry run: validate is refused once, never sent the parameter, no commit, says how the real run commits', async () => { const p = project(BASE, { 'store/listing.md': LISTING }); s.play.refuseAutoReview = 1; const r = await release(p, ['--dry']); expect(r.exit ?? 0).toBe(0); expect(edits('validate')).toEqual(['edit-1:validate']); expect(s.play.validateWithNotSent).toBe(0); expect(edits('commit')).toEqual([]); expect(s.play.committed).toEqual([]); expect(s.play.edits.size).toBe(0); expect(r.out + r.err).toContain('the real run commits with changesNotSentForReview=true'); expect(r.out + r.err).toContain(LINK); expect(existsSync(join(p.root, '.storebound', 'play-review-pending.json'))).toBe(false); }); test('--not-sent-for-review sets the parameter from the start (no refused request first)', async () => { const p = project(BASE, { 'store/listing.md': LISTING }); s.play.refuseAutoReview = 5; const r = await release(p, ['--not-sent-for-review']); expect(r.exit ?? 0).toBe(0); expect(edits('commit')).toEqual(['edit-1:commit?changesNotSentForReview=true']); expect(edits('validate')).toEqual([]); expect(readPending(p.root, 'com.example.notes')).toMatchObject({ forced: true }); expect(r.out + r.err).toContain(LINK); }); test('a normal commit (sent for review by Play) clears the record', async () => { const p = project(BASE, { 'store/listing.md': LISTING }); recordPending(p.root, { packageName: 'com.example.notes', editId: 'edit-0', committedAt: '2026-10-16T09:30:00.000Z', summary: 'listing text', forced: false }); const r = await release(p); expect(r.exit ?? 0).toBe(0); expect(edits('commit')).toEqual(['edit-1:commit']); expect(readPending(p.root, 'com.example.notes')).toBeNull(); }); test('play status shows the recorded state; --review-sent forgets it', async () => { const p = project(BASE); recordPending(p.root, { packageName: 'com.example.notes', editId: 'edit-7', committedAt: '2026-10-16T09:30:00.000Z', summary: 'release 1.0.1 (5): production completed', forced: false }); let r = await capture(() => playStatus(p.ctx([]))); expect(r.out).toContain(`changes committed 2026-10-16 09:30 UTC (release 1.0.1 (5): production completed), waiting for Send changes for review: ${LINK}`); r = await capture(() => playStatus(p.ctx(['--json']))); expect(r.out).toContain('"reviewPending"'); expect(r.out).toContain('"editId": "edit-7"'); r = await capture(() => playStatus(p.ctx(['--review-sent']))); expect(r.out).toContain('cleared'); expect(readPending(p.root, 'com.example.notes')).toBeNull(); r = await capture(() => playStatus(p.ctx([]))); expect(r.out).not.toContain('waiting for Send changes for review'); }); });