play: an edit Play will not send for review (400 'set changesNotSentForReview to true', a game 1.0.1 (5) after a rejection) is validated and committed again on the same edit with changesNotSentForReview=true; dry runs validate that way and say so; prints the Publishing overview link (owner's button), records it in .storebound/ for play status; --not-sent-for-review on release / upload / rollout / listing / images and MCP / desktop; pitfall 72
This commit is contained in:
parent
dcca4e86e4
commit
220e9bb072
19 changed files with 305 additions and 41 deletions
|
|
@ -356,6 +356,9 @@ export class FakePlay {
|
|||
dataSafety: string[] = [];
|
||||
/** versionCode the next bundle upload reports */
|
||||
bundleCode = 42;
|
||||
/** How many validate / commit calls without changesNotSentForReview=true Play refuses with its 400 "Changes cannot
|
||||
* be sent for review automatically" (after a rejection, pending changes, managed publishing: pitfall 72). */
|
||||
refuseAutoReview = 0;
|
||||
|
||||
handle(method: string, url: string, body: unknown): Response {
|
||||
const u = new URL(url);
|
||||
|
|
@ -368,6 +371,10 @@ export class FakePlay {
|
|||
const e = this.edits.get(id!);
|
||||
if (!e) return err(404, 'NOT_FOUND', 'Edit has been deleted');
|
||||
if (method === 'DELETE' && rest === '') { this.edits.delete(id!); return new Response(null, { status: 204 }); }
|
||||
if (method === 'POST' && (rest === ':validate' || rest === ':commit') && this.refuseAutoReview > 0 && u.searchParams.get('changesNotSentForReview') !== 'true') {
|
||||
this.refuseAutoReview--;
|
||||
return json({ error: { code: 400, status: 'INVALID_ARGUMENT', message: 'Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true. Once committed, the changes in this edit can be sent for review from the Google Play Console UI.', errors: [{ message: 'Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true.', domain: 'global', reason: 'badRequest' }] } }, 400);
|
||||
}
|
||||
if (method === 'POST' && rest === ':validate') return json({ id });
|
||||
if (method === 'POST' && rest === ':commit') { this.live = e; this.edits.delete(id!); this.committed.push(id!); return json({ id }); }
|
||||
if (rest === '/details') { if (method === 'PUT') e.details = body as Record<string, unknown>; return json(e.details); }
|
||||
|
|
|
|||
104
tests/play-not-sent-for-review.test.ts
Normal file
104
tests/play-not-sent-for-review.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// 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<!-- play: en-US -->\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<typeof project>, 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 validated and committed with changesNotSentForReview=true', 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')).toEqual(['edit-1:validate?changesNotSentForReview=true']);
|
||||
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, validated again with 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', 'edit-1:validate?changesNotSentForReview=true']);
|
||||
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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue