137 lines
8.6 KiB
TypeScript
137 lines
8.6 KiB
TypeScript
// Google Play keeps one open edit per app: a second edits.insert for the same package deletes the first, and a read in
|
|
// the first edit then answers 400 "This Edit has been deleted." (seen live when the desktop's auto-read ran several Play
|
|
// reads of one app at once). The fake below does exactly that; parallel reads of one package must all succeed, other
|
|
// packages must still run in parallel, and a read whose edit was replaced is redone once with a new edit.
|
|
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 { playClient } from '../cli/play/client';
|
|
import { withMachineLock } from '../cli/lib/lock';
|
|
import { laneGap, storesDiffer, withLastGood } from '../cli/lib/train';
|
|
import type { AppStatus, StoreRelease } from '../cli/lib/desktop-protocol';
|
|
|
|
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 { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
const KEY = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' });
|
|
|
|
beforeEach(() => { process.env.PLAY_SERVICE_ACCOUNT = KEY; });
|
|
const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
|
|
const DELETED = { error: { code: 400, message: 'This Edit has been deleted.', status: 'INVALID_ARGUMENT' } };
|
|
const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
|
|
|
|
/** Fake androidpublisher: one open edit per package; an insert replaces it. Counts the edits open at once per package. */
|
|
function fakePlay(o: { delay?: number; replaceOnce?: string } = {}) {
|
|
const open = new Map<string, string>();
|
|
let n = 0;
|
|
/** total / now: edits open at once; reads / reading: requests inside an edit in flight at once. */
|
|
const peak = { total: 0, now: 0, reads: 0, reading: 0 };
|
|
let replaced = false;
|
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
const url = String(input);
|
|
const method = init?.method ?? 'GET';
|
|
if (url.includes('oauth2')) return json({ access_token: 't', expires_in: 3600 });
|
|
const m = /applications\/([^/]+)\/edits(?:\/([^/:]+))?(\/.*)?$/.exec(url);
|
|
if (!m) return json({}, 404);
|
|
const [, pkg, id, rest] = m as unknown as [string, string, string | undefined, string | undefined];
|
|
if (method === 'POST' && !id) {
|
|
const e = `e${++n}`; open.set(pkg, e);
|
|
peak.now++; peak.total = Math.max(peak.total, peak.now);
|
|
return json({ id: e });
|
|
}
|
|
if (method === 'DELETE') { peak.now--; if (open.get(pkg) === id) open.delete(pkg); return json({}); }
|
|
peak.reading++; peak.reads = Math.max(peak.reads, peak.reading);
|
|
try { await wait(o.delay ?? 15); } finally { peak.reading--; }
|
|
if (o.replaceOnce === pkg && !replaced) { replaced = true; open.set(pkg, `e${++n}`); } // e.g. a Console save
|
|
if (open.get(pkg) !== id) return json(DELETED, 400);
|
|
return json({ ok: rest ?? '' });
|
|
}) as typeof fetch;
|
|
return peak;
|
|
}
|
|
|
|
describe('Play edits: one open edit per package', () => {
|
|
test('the fake is faithful: two edits of one package without the lock kill the first', async () => {
|
|
fakePlay();
|
|
const ids: string[] = [];
|
|
const call = async (pkg: string) => { const r = await realFetchless(pkg); ids.push(r); };
|
|
async function realFetchless(pkg: string): Promise<string> {
|
|
const e = await (await fetch(`https://x/applications/${pkg}/edits`, { method: 'POST' })).json() as { id: string };
|
|
await wait(5);
|
|
const r = await fetch(`https://x/applications/${pkg}/edits/${e.id}/details`);
|
|
return String(r.status);
|
|
}
|
|
await Promise.all([call('com.example.a'), call('com.example.a')]);
|
|
expect(ids.sort()).toEqual(['200', '400']);
|
|
});
|
|
|
|
test('parallel reads of one package all succeed (serialized)', async () => {
|
|
const peak = fakePlay();
|
|
const play = [await playClient('/nonexistent', 'com.example.demo'), await playClient('/nonexistent', 'com.example.demo'), await playClient('/nonexistent', 'com.example.demo'), await playClient('/nonexistent', 'com.example.demo')];
|
|
const out = await Promise.all(play.map(p => p.read(async id => {
|
|
const a = await p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/details`);
|
|
const b = await p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/tracks`);
|
|
return [a.ok, b.ok];
|
|
})));
|
|
expect(out).toEqual(Array(4).fill(['/details', '/tracks']));
|
|
expect(peak.total).toBe(1);
|
|
});
|
|
|
|
test('different packages still read in parallel', async () => {
|
|
const peak = fakePlay({ delay: 40 });
|
|
const pkgs = ['com.example.a', 'com.example.b', 'com.example.c'];
|
|
const out = await Promise.all(pkgs.map(async pkg => { const p = await playClient('/nonexistent', pkg); return p.read(id => p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/details`)); }));
|
|
expect(out.map(o => o.ok)).toEqual(['/details', '/details', '/details']);
|
|
expect(peak.total).toBe(3);
|
|
// all three reads were in flight at the same time (not the wall time: a loaded machine stretches it)
|
|
expect(peak.reads).toBe(3);
|
|
});
|
|
|
|
test('a read whose edit was deleted is redone once with a new edit', async () => {
|
|
fakePlay({ replaceOnce: 'com.example.demo' });
|
|
const p = await playClient('/nonexistent', 'com.example.demo');
|
|
const seen: string[] = [];
|
|
const r = await p.read(async id => { seen.push(id); return p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/details`); });
|
|
expect(r.ok).toBe('/details');
|
|
expect(seen.length).toBe(2);
|
|
expect(seen[0]).not.toBe(seen[1]);
|
|
});
|
|
|
|
test('a lock held by another holder times out with who holds it', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sb-lock-'));
|
|
const prev = process.env.STOREBOUND_LOCK_DIR;
|
|
process.env.STOREBOUND_LOCK_DIR = dir;
|
|
try {
|
|
const beats: string[] = [];
|
|
let release!: () => void;
|
|
const first = withMachineLock('play-edit-x', 'play read x', () => new Promise<void>(r => { release = r; }));
|
|
await wait(10);
|
|
await expect(withMachineLock('play-edit-x', 'play read x', async () => 1, { timeoutMs: 60, pollMs: 10, heartbeatMs: 20, onWait: m => beats.push(m) })).rejects.toThrow(/timed out .*play read x/);
|
|
expect(beats.length).toBeGreaterThan(0);
|
|
release(); await first;
|
|
expect(await withMachineLock('play-edit-x', 'play read x', async () => 2, { timeoutMs: 100 })).toBe(2);
|
|
} finally { if (prev === undefined) delete process.env.STOREBOUND_LOCK_DIR; else process.env.STOREBOUND_LOCK_DIR = prev; }
|
|
});
|
|
});
|
|
|
|
describe('a store that could not be read is unknown, never empty', () => {
|
|
const r = (store: 'ios' | 'android', version: string, phase: StoreRelease['phase']): StoreRelease => ({ store, version, build: '1', state: 's', phase, fraction: null, track: store === 'android' ? 'production' : null });
|
|
test("laneGap: unreadable, the store's own other version, missing, none", () => {
|
|
expect(laneGap('android', [r('ios', '0.8.1', 'live')], '400 This Edit has been deleted.').kind).toBe('unreadable');
|
|
expect(laneGap('ios', [r('ios', '4.1.2', 'live'), r('android', '4.1.9', 'live')])).toMatchObject({ kind: 'other', now: { version: '4.1.2' } });
|
|
expect(laneGap('ios', [r('ios', '1.0', 'review')]).kind).toBe('missing');
|
|
expect(laneGap('ios', [r('android', '1.0', 'live')]).kind).toBe('none');
|
|
expect(storesDiffer([r('ios', '4.1.2', 'live'), r('android', '4.1.9', 'live')])).toEqual({ ios: '4.1.2', android: '4.1.9' });
|
|
expect(storesDiffer([r('ios', '4.1', 'live'), r('android', '4.1.0', 'live')])).toBeNull();
|
|
});
|
|
test('withLastGood keeps the last good Play releases with their time after a failed refresh', () => {
|
|
const prev = { release: { source: 'live', current: null, next: null, all: [r('ios', '0.8.1', 'live'), r('android', '0.8.1', 'live')] }, storeErrors: {} } as unknown as AppStatus;
|
|
const next = { release: { source: 'live', current: null, next: null, all: [r('ios', '0.8.1', 'live')] }, storeErrors: { android: 'GET /edits/x/details -> 400 This Edit has been deleted.' } } as unknown as AppStatus;
|
|
const m = withLastGood(prev, next, '2026-10-13T10:00:00Z');
|
|
expect(m.staleStores).toEqual({ android: '2026-10-13T10:00:00Z' });
|
|
expect(m.release.current).toMatchObject({ version: '0.8.1', android: { phase: 'live' } });
|
|
expect(withLastGood(undefined, next, 'x')).toBe(next);
|
|
});
|
|
});
|