Three defects in the same pipeline, each of which loses a photo or misrepresents one. 1. A transient error destroyed the guest's only copy. `process`'s error arm unconditionally `remove_file`d the original. Every failure routed there: `create_dir_all`, both derivative `save_with_format` calls (disk full is the canonical case, and it arrives exactly when many guests upload at once), a panic inside the image codec, or a momentary DB-pool exhaustion. The row is only SOFT-deleted, so the bytes were the sole unrecoverable part — and they were the part we deleted. The author already knew this was wrong next door: `backfill_missing_display` says it "must NEVER soft-delete an upload that already has a working preview". Retry up to 3 times with backoff (re-checking the e2e generation guard after each sleep), and on final failure keep the refund + soft-delete but leave the original on disk, logging its path. A failed upload is now recoverable instead of gone. 2. Every portrait photo was stored sideways. Phones don't rotate sensor data — they record the camera orientation in EXIF and store the pixels as shot. `decode()` returns those raw pixels and the JPEG re-encode writes no EXIF, so the 800px preview, the 2048px diashow display and the keepsake were all rotated 90°, while "Original anzeigen" rendered upright because the original keeps its tag. That asymmetry is why it reads as a viewer bug. There was no EXIF handling anywhere in the repo and no exif crate. Read the tag via `into_decoder()` (which carries the decode Limits through, so the decompression-bomb cap is untouched) and apply it. Missing/malformed tags fall back to NoTransforms — most images have none. Existing derivatives are already baked wrong, so migration 018 adds `derivatives_rev` and `backfill_missing_display` becomes `backfill_stale_derivatives`: it now also picks up anything below the current rev and regenerates it once from the original, which still carries its EXIF. Videos are marked current in the migration — ffmpeg already honours the rotation matrix. Bump DERIVATIVES_REV for any future change that invalidates derivatives. 3. A rejected upload vanished without a word. `UploadQueue.svelte` — 162 lines holding the ONLY renderer of an item's error text, the only "Erneut" retry button and the only rate-limit countdown — was never imported anywhere, so `retryItem`, `removeItem` and `clearCompleted` were unreachable at runtime. On a terminal rejection the store purged the blob and wrote a clear German reason into `entry.error` "so the UI shows a clear reason". There was no such UI. And `uploadBadgeCount` counted only pending/uploading, so the badge decremented exactly as if the upload had succeeded. Mount the queue on /upload, toast the reason immediately (the flow sends the user to /feed straight after staging, so the list alone would still miss them), and count blocked/error in the badge so a failure can't read as success. Tests: 02-upload/exif-orientation uploads a 40x20 fixture tagged Orientation=6 and asserts both derivatives come back PORTRAIT, with a sanity check that the source really is stored landscape. 02-upload/rejection-visible bans the uploader between staging and sending, then asserts the toast, the queue row with the server's reason, and that the item is still counted. Note: 02-upload/quota's 4 failures are pre-existing and unrelated — see the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
85 lines
3.6 KiB
TypeScript
85 lines
3.6 KiB
TypeScript
/**
|
|
* Regression guard — a rejected upload must tell the user something.
|
|
*
|
|
* `UploadQueue.svelte` was 162 lines of complete, working UI — the only renderer of an
|
|
* item's error text, the only "Erneut" retry button, the only rate-limit countdown — and it
|
|
* was never imported anywhere, so `retryItem`, `removeItem` and `clearCompleted` were all
|
|
* unreachable at runtime. On a terminal rejection the store dropped the blob, wrote a clear
|
|
* German reason into `entry.error` with the comment "so the UI shows a clear reason", and
|
|
* there was no such UI.
|
|
*
|
|
* Meanwhile the FAB badge counted only pending/uploading, so a rejected photo decremented it
|
|
* exactly as if it had succeeded. Net effect: the photo silently vanished — no toast, no
|
|
* queue row, no error text, and it never appeared in the feed.
|
|
*/
|
|
import { test, expect } from '../../fixtures/test';
|
|
import { FeedPage, UploadSheet } from '../../page-objects';
|
|
import { join } from 'node:path';
|
|
|
|
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
|
|
|
test.describe('Upload — a rejected upload is surfaced', () => {
|
|
test('a terminally rejected upload toasts, and stays visible in the queue', async ({
|
|
page,
|
|
api,
|
|
host,
|
|
guest,
|
|
signIn,
|
|
}) => {
|
|
const g = await guest('RejectedUploader');
|
|
await signIn(page, g);
|
|
|
|
const feed = new FeedPage(page);
|
|
const sheet = new UploadSheet(page);
|
|
await feed.openUploadSheet();
|
|
await sheet.stageFiles([SAMPLE_JPG]);
|
|
await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
|
|
|
|
// Ban the uploader between staging and sending, so the POST comes back 403 — a
|
|
// terminal 4xx the server will keep rejecting, which is the path that purges the blob.
|
|
await api.banUser(host.jwt, g.userId);
|
|
|
|
await sheet.submit();
|
|
|
|
// 1. The user is told, wherever they are (the flow lands them on /feed).
|
|
const toast = page.getByRole('region', { name: 'Benachrichtigungen' });
|
|
await expect(toast).toContainText(/sample\.jpg/i, { timeout: 15_000 });
|
|
|
|
// 2. The queue row survives with its reason and is reachable on /upload — this is what
|
|
// the orphaned component made impossible.
|
|
await page.goto('/upload');
|
|
const queue = page.getByText('Upload-Warteschlange');
|
|
await expect(queue, 'the upload queue must be rendered somewhere').toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
// Both the status chip ("Gesperrt") and the server's reason ("Du bist gesperrt.") must
|
|
// render — the reason is the part that had no UI at all before.
|
|
await expect(page.getByText('Gesperrt', { exact: true })).toBeVisible();
|
|
await expect(page.getByText('Du bist gesperrt.')).toBeVisible();
|
|
|
|
// 3. The badge must not read as success. It counted only pending/uploading before, so a
|
|
// rejected item dropped it to 0 — indistinguishable from a completed upload.
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate(async () => {
|
|
return new Promise<number>((resolve, reject) => {
|
|
const req = indexedDB.open('eventsnap-uploads', 3);
|
|
req.onerror = () => reject(req.error);
|
|
req.onsuccess = () => {
|
|
const tx = req.result.transaction('queue', 'readonly');
|
|
const all = tx.objectStore('queue').getAll();
|
|
all.onsuccess = () =>
|
|
resolve(
|
|
all.result.filter((r: { status: string }) => r.status === 'blocked').length
|
|
);
|
|
all.onerror = () => reject(all.error);
|
|
};
|
|
});
|
|
}),
|
|
{ timeout: 10_000 }
|
|
)
|
|
.toBe(1);
|
|
});
|
|
});
|