Files
EventSnap/e2e/specs/06-export/export.spec.ts
fabi e79e020566 feat(eventsnap): UX review batch-3 — feed virtualization, a11y/UX fixes, shared primitives
Frontend
- Feed: DOM-windowing via @tanstack/svelte-virtual (new VirtualFeed). The window
  virtualizer keeps the sticky header, pull-to-refresh, infinite-scroll sentinel and
  bottom nav working. List = dynamic measured heights keyed by upload id +
  anchorTo:start so an SSE prepend doesn't jump a scrolled reader; grid = measured
  square rows. Drops the content-visibility band-aid and the old FeedGrid.
  measureElement(null) on row unmount prevents ResizeObserver retention; stable
  option callbacks + guarded setOptions avoid O(n) re-measure on like/comment patches.
- Global: viewport-fit=cover (activates safe-area insets), lang=de, PWA manifest +
  maskable icon + apple-touch metas, prefers-reduced-motion, safe-area-top headers.
- Feed reactivity: like/comment SSE patch the single card in place; upload-processed
  debounced in-place merge; IntersectionObserver leak fixed; shared 60s clock.
- CLS: list cards reserve the skeleton aspect box.
- Destructive actions (promote/demote/unban, gallery release) routed through
  ConfirmSheet (host + admin).
- Export OOM: streamed download via single-use ticket instead of res.blob().
- Shared primitives: IconButton (44px hit area), scrollLock action; UploadSheet a11y.
- Polish: api timeout + non-JSON guard, focus-trap offsetParent fix, pull-to-refresh
  passive:false + guards, diashow keyboard a11y, Toaster assertive errors, dark-mode
  gaps, aria-pressed on like/chip state, CameraCapture mic-on-video + retry,
  ConfirmSheet spinner, upload beforeunload warning, emoji->SVG icons.

Backend
- social.rs: like/comment SSE broadcasts now carry the fresh count so feed clients
  patch one card in place instead of refetching page 1.
- admin.rs / main.rs: supporting changes for the above.

Verified: svelte-check 0 errors, frontend build clean, /feed SSR 200. Runtime feed
scroll-feel validation still owed (documented in FOLLOWUPS.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:16:48 +02:00

64 lines
2.8 KiB
TypeScript

/**
* USER_JOURNEYS.md §12 — release the export, see status, download.
*
* We don't drive a real export job here (the compression takes too long
* for E2E timing). Instead we forge the export-job rows via the db helper
* and assert the API behavior + UI banner state.
*/
import { test, expect } from '../../fixtures/test';
import { ExportPage } from '../../page-objects';
const SLUG = 'e2e-test-event';
test.describe('Export — release and download', () => {
test('/export shows the "not yet available" state before release', async ({ page, guest, signIn }) => {
const g = await guest('PreRelease');
await signIn(page, g);
const exportPage = new ExportPage(page);
await exportPage.goto();
// The page shouldn't show download buttons before release.
await expect(page.getByRole('button', { name: /^herunterladen$/i })).not.toBeVisible();
});
test('export status API reflects released flag', async ({ guest, db }) => {
const g = await guest('ReleaseQuery');
let res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status', {
headers: { Authorization: `Bearer ${g.jwt}` },
});
let body: any = await res.json();
expect(body.released).toBe(false);
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'done');
await db.fakeExportJob(SLUG, 'html', 'done');
res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status', {
headers: { Authorization: `Bearer ${g.jwt}` },
});
body = await res.json();
expect(body.released).toBe(true);
expect(body.zip.status).toBe('done');
expect(body.html.status).toBe('done');
});
test('ZIP download returns 404 when no file is on disk (export released but never compressed)', async ({ guest, db }) => {
const g = await guest('NoFile');
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'done');
const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
// Browser downloads stream straight to disk via a top-level navigation, so the
// download endpoint authenticates with a single-use ticket (no Bearer header).
const ticketRes = await fetch(base + '/api/v1/export/ticket', {
method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}` },
});
const { ticket } = await ticketRes.json();
// Real backend additionally checks event.export_zip_ready. The faked row is
// enough for /status; the download path needs the boolean flag too.
const res = await fetch(base + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
// Either 404 ("not available" OR "file not found") — both are valid states for this setup.
expect([404, 200]).toContain(res.status);
});
});