127 lines
8.3 KiB
TypeScript
127 lines
8.3 KiB
TypeScript
// Screenshots that hold up a submit (409 ASSET_IN_POST_PROCESSING.APP_SCREENSHOT): classification (stuck vs still
|
|
// processing), the 409 explanation, validate findings, --repair-stuck in place, and the part upload rules (no
|
|
// `Expect: 100-continue`, a failed part deletes the reservation and is retried once, never committed). No network.
|
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { uploadAssetVia } from '../cli/apple/asc';
|
|
import { describePending, explain409, isScreenshotProcessing409, partHeaders, pendingScreenshots, readLedger, repairPlan, repairSet, setRows, STUCK_MS, updateLedger, type ShotRow } from '../cli/apple/screenshot-health';
|
|
import { appleRules } from '../cli/lib/validate-rules';
|
|
|
|
let dir = '';
|
|
beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'sb-shots-')); for (const n of ['01.png', '02.png', '03.png']) writeFileSync(join(dir, n), `png ${n}`); });
|
|
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
|
|
const shot = (id: string, fileName: string, state: string) => ({ id, type: 'appScreenshots', attributes: { fileName, assetDeliveryState: { state }, sourceFileChecksum: 'x' } });
|
|
const NOW = 10_000_000;
|
|
|
|
describe('classification', () => {
|
|
const rows: ShotRow[] = setRows([shot('a', '01.png', 'COMPLETE'), shot('b', '02.png', 'UPLOAD_COMPLETE'), shot('c', '03.png', 'UPLOAD_COMPLETE'), shot('d', '04.png', 'FAILED'), shot('e', '05.png', 'AWAITING_UPLOAD')], 'en-US', 'APP_IPHONE_67');
|
|
test('only non-COMPLETE rows; UPLOAD_COMPLETE over 5 min is stuck, younger or unknown is processing', () => {
|
|
const p = pendingScreenshots(rows, { b: NOW - STUCK_MS - 1, c: NOW - 60_000 }, NOW);
|
|
expect(p.map(x => [x.id, x.kind, x.position])).toEqual([['b', 'stuck', 2], ['c', 'processing', 3], ['d', 'failed', 4], ['e', 'awaiting-upload', 5]]);
|
|
expect(pendingScreenshots(rows, {}, NOW).find(x => x.id === 'b')!.kind).toBe('processing');
|
|
const lines = describePending(p);
|
|
expect(lines[0]).toBe('en-US APP_IPHONE_67 #2 02.png: UPLOAD_COMPLETE for 5 min -> STUCK (not processing)');
|
|
expect(lines[1]).toContain('still processing');
|
|
});
|
|
test('the 409 is recognised and explained with the repair command', () => {
|
|
const e = new Error('PATCH /v1/reviewSubmissions/1 -> 409 STATE_ERROR.ASSET_IN_POST_PROCESSING.APP_SCREENSHOT: screenshots are processing');
|
|
expect(isScreenshotProcessing409(e)).toBe(true);
|
|
expect(isScreenshotProcessing409(new Error('-> 409 not ready to be submitted'))).toBe(false);
|
|
const p = pendingScreenshots(rows, { b: NOW - STUCK_MS - 1 }, NOW);
|
|
expect(explain409(p)).toContain('--repair-stuck');
|
|
expect(explain409(p)).toContain('#2 02.png');
|
|
expect(explain409(pendingScreenshots(rows.slice(0, 3), {}, NOW))).toContain('still processing');
|
|
});
|
|
test('validate: stuck is a blocker, processing a warning', () => {
|
|
const p = pendingScreenshots(rows.slice(0, 3), { b: NOW - STUCK_MS - 1 }, NOW);
|
|
const f = appleRules({ version: null, build: null, expectedLocales: [], locales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true,
|
|
iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, screenshotsPending: p }).filter(x => x.code.startsWith('screenshot-'));
|
|
expect(f.map(x => [x.code, x.level])).toEqual([['screenshot-stuck', 'blocker'], ['screenshot-processing', 'warn']]);
|
|
expect(f[0]!.fix).toContain('--repair-stuck');
|
|
});
|
|
test('ledger keeps the first-seen time and drops screenshots that left UPLOAD_COMPLETE', () => {
|
|
updateLedger(dir, [{ id: 'b', state: 'UPLOAD_COMPLETE' }, { id: 'z', state: 'UPLOAD_COMPLETE' }], 100);
|
|
updateLedger(dir, [{ id: 'b', state: 'UPLOAD_COMPLETE' }, { id: 'z', state: 'COMPLETE' }], 900);
|
|
expect(readLedger(dir)).toEqual({ b: 100 });
|
|
});
|
|
});
|
|
|
|
/** A fake screenshot set: list, delete, upload (append), reorder. */
|
|
function fakeSet(start: ReturnType<typeof shot>[]) {
|
|
let items = [...start];
|
|
let n = 0;
|
|
const calls: string[] = [];
|
|
return {
|
|
calls, get items() { return items; },
|
|
all: async (_p: string) => items.map(x => ({ ...x })),
|
|
asc: async (method: string, path: string, b?: unknown) => {
|
|
const body = b as { data: { id: string }[] } | undefined;
|
|
calls.push(`${method} ${path}`);
|
|
if (method === 'DELETE') items = items.filter(x => !path.endsWith('/' + x.id));
|
|
if (method === 'PATCH') items = body!.data.map(d => items.find(x => x.id === d.id)!);
|
|
return {};
|
|
},
|
|
uploadAsset: async (_t: string, _r: unknown, file: string) => { const id = `new${++n}`; calls.push(`UPLOAD ${file.split('/').pop()}`); items.push(shot(id, file.split('/').pop()!, 'COMPLETE')); return id; },
|
|
};
|
|
}
|
|
|
|
describe('--repair-stuck', () => {
|
|
test('replaces only the stuck one, same file, same position, count unchanged; a re-run does nothing', async () => {
|
|
const set = fakeSet([shot('a', '01.png', 'COMPLETE'), shot('b', '02.png', 'UPLOAD_COMPLETE'), shot('c', '03.png', 'UPLOAD_COMPLETE')]);
|
|
const rows = setRows(set.items, 'en-US', 'APP_IPHONE_67');
|
|
const pending = pendingScreenshots(rows, { b: NOW - STUCK_MS - 1, c: NOW - 1000 }, NOW);
|
|
const files = ['01.png', '02.png', '03.png'].map(f => join(dir, f));
|
|
const steps = repairPlan(rows, pending, files);
|
|
expect(steps.map(s => [s.id, s.position, s.file])).toEqual([['b', 2, join(dir, '02.png')]]);
|
|
expect(await repairSet(set, 'S', steps)).toEqual({ replaced: 1 });
|
|
expect(set.items.map(x => [x.id, x.attributes.fileName])).toEqual([['a', '01.png'], ['new1', '02.png'], ['c', '03.png']]);
|
|
expect(set.calls.filter(c => !c.startsWith('PATCH'))).toEqual(['DELETE /v1/appScreenshots/b', 'UPLOAD 02.png']);
|
|
// re-run with the same (now stale) plan: the stuck id is gone, nothing is touched
|
|
const before = set.calls.length;
|
|
expect(await repairSet(set, 'S', steps)).toEqual({ replaced: 0 });
|
|
expect(set.calls.length).toBe(before);
|
|
});
|
|
test('no local file: nothing deleted', async () => {
|
|
const set = fakeSet([shot('b', 'gone.png', 'FAILED')]);
|
|
const rows = setRows(set.items, 'en-US', 'APP_IPHONE_67');
|
|
const steps = repairPlan(rows, pendingScreenshots(rows, {}, NOW), []);
|
|
await expect(repairSet(set, 'S', steps)).rejects.toThrow('no local file');
|
|
expect(set.calls).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('part uploads', () => {
|
|
test('Expect is never sent', () => {
|
|
expect(partHeaders({ requestHeaders: [{ name: 'Content-Type', value: 'image/png' }, { name: 'Expect', value: '100-continue' }, { name: 'expect', value: '100-continue' }] })).toEqual({ 'Content-Type': 'image/png' });
|
|
});
|
|
function store(partStatuses: number[]) {
|
|
const calls: string[] = [];
|
|
let r = 0;
|
|
return {
|
|
calls,
|
|
asc: async (method: string, path: string) => {
|
|
calls.push(`${method} ${path}`);
|
|
return method === 'POST' ? { data: { id: `r${++r}`, attributes: { uploadOperations: [{ method: 'PUT', url: 'https://upload.example.com/p', offset: 0, length: 4, requestHeaders: [{ name: 'Expect', value: '100-continue' }] }] } } } : {};
|
|
},
|
|
fetch: async (_u: string, i: RequestInit) => {
|
|
expect(Object.keys(i.headers as Record<string, string>).map(k => k.toLowerCase())).not.toContain('expect');
|
|
const s = partStatuses.shift()!; calls.push(`PART ${s}`); return new Response(null, { status: s });
|
|
},
|
|
};
|
|
}
|
|
const rel = { appScreenshotSet: { data: { type: 'appScreenshotSets', id: 'S' } } };
|
|
test('a non-2xx part (also 100 / 3xx): the reservation is deleted, retried once, committed only after a good part', async () => {
|
|
const s = store([302, 200]);
|
|
expect(await uploadAssetVia(s, 'appScreenshots', rel, join(dir, '01.png'))).toBe('r2');
|
|
expect(s.calls).toEqual(['POST /v1/appScreenshots', 'PART 302', 'DELETE /v1/appScreenshots/r1', 'POST /v1/appScreenshots', 'PART 200', 'PATCH /v1/appScreenshots/r2']);
|
|
});
|
|
test('two failed parts: throws, never commits', async () => {
|
|
const s = store([500, 403]);
|
|
await expect(uploadAssetVia(s, 'appScreenshots', rel, join(dir, '01.png'))).rejects.toThrow('nothing committed');
|
|
expect(s.calls.some(c => c.startsWith('PATCH'))).toBe(false);
|
|
expect(s.calls.filter(c => c.startsWith('DELETE'))).toEqual(['DELETE /v1/appScreenshots/r1', 'DELETE /v1/appScreenshots/r2']);
|
|
});
|
|
});
|