import { describe, it, expect } from 'vitest'; import { classifyUploadStatus, isReversibleLock, entryToQueueItem } from './upload-queue'; /** * Regression guard for the upload-queue retry policy (H2 + M1). The bug being locked out: * every 4xx except 429 was classified terminal, and a terminal item has its blob PURGED * from IndexedDB. A 401 (a sliding session that lapsed, or a host PIN-reset) is a 4xx, so a * guest's queued photos were irrecoverably destroyed the moment the `online` auto-resume * fired against a dead session. 401 must be `auth` (blob kept, re-auth), NEVER `terminal`. */ describe('classifyUploadStatus', () => { it('2xx → success', () => { expect(classifyUploadStatus(200)).toBe('success'); expect(classifyUploadStatus(201)).toBe('success'); expect(classifyUploadStatus(299)).toBe('success'); }); it('401 → auth, NOT terminal (never purge the blob on a dead session)', () => { expect(classifyUploadStatus(401)).toBe('auth'); }); it('429 → rate_limit (back off, auto-resume)', () => { expect(classifyUploadStatus(429)).toBe('rate_limit'); }); it('408 → transient (request timeout is retryable, not terminal)', () => { expect(classifyUploadStatus(408)).toBe('transient'); }); it('genuinely permanent 4xx → terminal (locked / banned / released / quota)', () => { expect(classifyUploadStatus(403)).toBe('terminal'); // banned / locked / released expect(classifyUploadStatus(413)).toBe('terminal'); // quota exhausted expect(classifyUploadStatus(400)).toBe('terminal'); expect(classifyUploadStatus(404)).toBe('terminal'); }); it('5xx / unexpected → transient (retryable, blob kept)', () => { expect(classifyUploadStatus(500)).toBe('transient'); expect(classifyUploadStatus(502)).toBe('transient'); expect(classifyUploadStatus(503)).toBe('transient'); }); }); /** * Regression guard for the reversible-lock discrimination inside the `terminal` bucket — the * branch that decides whether a 4xx KEEPS the blob (event closed / gallery released: a host can * reopen and the photo resumes) or PURGES it (permanent ban / quota). Getting this wrong either * loses a photo the guest expected to survive a reopen, or lets a banned device retry forever. */ describe('isReversibleLock', () => { it('an `uploads_locked` code is reversible at any status (event closed / released)', () => { expect(isReversibleLock(403, 'uploads_locked')).toBe(true); expect(isReversibleLock(409, 'uploads_locked')).toBe(true); }); it('a `forbidden` 403 (banned) is PERMANENT — purge, never resume', () => { expect(isReversibleLock(403, 'forbidden')).toBe(false); }); it('an unidentifiable 403 (unparseable proxy/WAF/captive-portal body) is treated reversible', () => { // Losing a photo is the worst outcome; 403 is the reversible-lock status here. expect(isReversibleLock(403, undefined)).toBe(true); expect(isReversibleLock(403, null)).toBe(true); expect(isReversibleLock(403, '')).toBe(true); }); it('a non-403 permanent 4xx (e.g. 413 quota) is NOT reversible unless explicitly locked', () => { expect(isReversibleLock(413, undefined)).toBe(false); expect(isReversibleLock(400, 'bad_request')).toBe(false); expect(isReversibleLock(413, 'uploads_locked')).toBe(true); // explicit tag still wins }); }); /** * Regression guard for the queue-rehydration mapping. The bug this locks out: `loadQueue` * rebuilt items from IndexedDB WITHOUT copying `lastModified`, so a reloaded item had * `lastModified === undefined`. addToQueue's dedup keys on (name, size, lastModified), so * re-selecting the same file after a reload would MISS the duplicate and queue it twice. */ describe('entryToQueueItem', () => { const base = { id: 'e1', userId: 'u1', fileName: 'photo.jpg', fileSize: 1234, lastModified: 1_700_000_000_000, mimeType: 'image/jpeg', status: 'pending' as const }; it('carries lastModified across rehydration (dedup depends on it)', () => { expect(entryToQueueItem(base).lastModified).toBe(1_700_000_000_000); }); it('downgrades an interrupted `uploading` entry to `pending` so it resumes', () => { expect(entryToQueueItem({ ...base, status: 'uploading' }).status).toBe('pending'); }); it('a `done` entry reports 100% progress; others start at 0', () => { expect(entryToQueueItem({ ...base, status: 'done' }).progress).toBe(100); expect(entryToQueueItem(base).progress).toBe(0); }); it('defaults caption/hashtags to empty strings', () => { const item = entryToQueueItem(base); expect(item.caption).toBe(''); expect(item.hashtags).toBe(''); }); });