// `storebound validate --play` read its screenshots with a bare POST /edits and a DELETE in a finally: not under the // machine lock and never retried. When Play (or another storebound process) ended that edit meanwhile, the next read // answered 400 "This Edit has been deleted" and validate printed it as a store blocker ("GET .../phoneScreenshots 400"). // Now the reads run in play.read (one locked edit, deleted only after all reads, redone once with a new edit), and a // vanished edit that stays gone is a tool warning, never a blocker. import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { generateKeyPairSync } from 'node:crypto'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parseArgs } from '../cli/lib/args'; import type { Loaded, ShipConfig } from '../cli/lib/config'; import { collectPlay, run } from '../cli/validate'; import { installStores, type Stores, uninstallStores } from './helpers/fake-stores'; const PKG = 'com.example.game'; const GONE = { error: { code: 400, status: 'FAILED_PRECONDITION', message: 'This Edit has been deleted.' } }; let s: Stores; let savedKey: string | undefined; beforeEach(() => { savedKey = process.env.PLAY_SERVICE_ACCOUNT; 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' }); s = installStores(); // the fake has no countryAvailability route (the console owns it): a plain 404, which validate reads as "not readable" s.faults.push({ method: 'GET', match: /countryAvailability/, status: 404, when: 'before', times: 99 }); s.play.live.listings['en-US'] = { language: 'en-US', title: 'Example Game', shortDescription: 'Short.', fullDescription: 'Full.' }; }); afterEach(() => { uninstallStores(); if (savedKey === undefined) delete process.env.PLAY_SERVICE_ACCOUNT; else process.env.PLAY_SERVICE_ACCOUNT = savedKey; }); function loaded(): Loaded { const root = mkdtempSync(join(tmpdir(), 'sb-vpe-')); const config = { app: { name: 'Example Game', privacyPolicyUrl: '' }, languages: [{ id: 'en', play: 'en-US' }], play: { packageName: PKG } } as unknown as ShipConfig; return { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) }; } /** Play ends the edit under the reader: the DELETE happens on the store, the read that follows answers 400. */ function endEditOnImageRead(times: number): { hits: () => number } { const inner = globalThis.fetch; let left = times, hits = 0; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = String(input instanceof Request ? input.url : input); const m = /\/edits\/([^/]+)\/listings\/[^/]+\/phoneScreenshots$/.exec(url); if (m && (init?.method ?? 'GET').toUpperCase() === 'GET' && left > 0) { left--; hits++; s.play.edits.delete(m[1]!); return new Response(JSON.stringify(GONE), { status: 400, headers: { 'content-type': 'application/json' } }); } return inner(input, init); }) as typeof fetch; return { hits: () => hits }; } describe('validate --play: the read-only edit', () => { test('an edit ended during the screenshot reads is reopened once; the snapshot is complete and every edit is closed', async () => { const fault = endEditOnImageRead(1); const snap = await collectPlay({ loaded: loaded(), args: parseArgs(['--play']) }); expect(fault.hits()).toBe(1); expect(snap.listings.map(l => l.language)).toEqual(['en-US']); expect(Object.keys(snap.listings[0]!.images)).toContain('phoneScreenshots'); expect(s.play.edits.size).toBe(0); const edits = s.calls.filter(c => c.method === 'POST' && /\/edits$/.test(c.path)); expect(edits.length).toBe(2); }); test('no image read runs after the edit was deleted', async () => { await collectPlay({ loaded: loaded(), args: parseArgs(['--play']) }); const del = s.calls.findIndex(c => c.method === 'DELETE' && /\/edits\/[^/]+$/.test(c.path)); expect(del).toBeGreaterThan(-1); expect(s.calls.slice(del + 1).filter(c => /\/edits\/[^/]+\//.test(c.path))).toEqual([]); }); test('an edit that stays gone is a tool warning, never a store blocker', async () => { endEditOnImageRead(10); const out: string[] = []; const log = console.log; console.log = (...a: unknown[]) => { out.push(a.join(' ')); }; const prev = process.exitCode; try { await run({ loaded: loaded(), args: parseArgs(['--play', '--json', '--no-scan', '--no-health', '--no-local']) }); } finally { console.log = log; } process.exitCode = prev; const res = JSON.parse(out.join('\n').slice(out.join('\n').indexOf('{'))) as { blockers: number; findings: { level: string; code: string; msg: string }[] }; expect(res.findings.filter(f => f.level === 'blocker' && /Edit has been deleted|phoneScreenshots/.test(f.msg))).toEqual([]); expect(res.findings).toContainEqual(expect.objectContaining({ level: 'warn', code: 'edit-gone' })); // (the template-answers blocker of the example config is unrelated: only an api blocker would be the bug) expect(res.findings.filter(f => f.level === 'blocker' && f.code === 'api')).toEqual([]); }); });