Adversarial re-review of the persona-audit + audit-followup rounds (6411747..)
found one HIGH and one MED regression plus LOW gaps. All fixed with coverage.
HIGH — export stale-keepsake resurrected by an open_event race
A reopen landing in the window between a *current* export worker's finalize_job
and its ready-flag flip cleared export_released_at + the ready flags but left the
export_job row `done` at the same release_seq. The seq-guarded flip then still
matched and re-set export_{zip,html}_ready=TRUE on a pre-reopen snapshot; the next
re-release read that stale TRUE and skipped regeneration (`if ready { continue }`),
serving a keepsake missing every upload from the reopen window — the exact data
loss migration 012 exists to prevent. Both ready-flip UPDATEs are now additionally
anchored on `export_released_at IS NOT NULL`, so a landed reopen makes the flip a
no-op and the re-release regenerates cleanly.
MED — queue dedup broke for reloaded items
loadQueue rebuilt QueueItems from IndexedDB without copying lastModified, which the
new addToQueue dedup keys on. A file re-selected after a page reload / PWA relaunch
missed the duplicate check and uploaded twice. Rehydration now carries lastModified
(extracted to a pure, tested entryToQueueItem helper).
LOW
- diashow: clear the upload-processed debounce timer in onDestroy (no stray
post-unmount /feed fetch).
- USER_JOURNEYS §9.5: document the reconnect-delta ban replay (hidden_user_ids /
uploads_hidden_at, migration 013), not just the live user-hidden SSE.
- e2e api-client: drop the misleading hide_uploads param from banUser — the backend
takes no body and always hides; strip the dead boolean at all call sites.
Tests
- Extract isReversibleLock (the terminal-403 KEEP-vs-PURGE-blob discriminator) into a
pure exported helper + unit tests, so the data-loss-critical branch is covered
without an XHR harness.
- entryToQueueItem unit tests lock the lastModified-carry regression.
- Document the export flip-race guard in the reopen/re-release spec (the sub-ms
finalize↔flip interleave isn't deterministically forceable with fast fixtures;
covered by the SQL guard + the end-to-end completeness test).
Verified: backend 40 tests, frontend 44 unit tests, svelte-check 0 errors,
e2e 156 passed / 1 skipped on chromium-desktop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
110 lines
4.6 KiB
TypeScript
110 lines
4.6 KiB
TypeScript
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('');
|
|
});
|
|
});
|