// 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[]) { 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).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']); }); });