storebound/tests/validate-play-edit.test.ts

91 lines
5.3 KiB
TypeScript

// `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([]);
});
});