/** * 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 { skipIfNoIdbBlobs } from '../../helpers/webkit'; 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, browserName, }) => { skipIfNoIdbBlobs(browserName); 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, }); // The server's reason ("Du bist gesperrt.") must render — it had no UI at all before. await expect(page.getByText('Du bist gesperrt.')).toBeVisible(); // The chip reads "Fehler", NOT "Gesperrt", and that is the fix rather than a regression. // A ban used to come back as a generic `forbidden`, which purged the blob and moved the row // to `blocked` — a terminal state with no retry button. So an unban restored everything // except the photo that was actually in flight, which is the one the guest cares about. // It is now a distinct `user_banned` code that PARKS the row (status `error`, blob kept, // `parkedFor: 'unban'`) and resumes it when `user-shown` arrives. await expect(page.getByText('Gesperrt', { exact: true })).toHaveCount(0); // Positively, not just negatively: a chip that rendered empty would satisfy the line above. await expect(page.getByText('Fehler', { exact: true }).first()).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. // The row is now parked (`error` + `parkedFor: 'unban'`) rather than terminally `blocked`, // so it is the parked count that must be exactly one — and critically the blob must still // be there, since that is what an unban replays. await expect .poll( () => page.evaluate(async () => { return new Promise((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; parkedFor?: string; blob?: Blob }) => r.status === 'error' && r.parkedFor === 'unban' && !!r.blob ).length ); all.onerror = () => reject(all.error); }; }); }), { timeout: 10_000 } ) .toBe(1); }); });