Files
EventSnap/e2e/specs/04-host/low-disk-warning.spec.ts
fabi 43c2a0d09c feat(host): warn about low disk before it becomes unrecoverable
Storage visibility existed in exactly one place: a passive Speicherauslastung widget
on the ADMIN dashboard. A host who isn't the admin had no view of it, and nothing
warned anyone. README carried "Low-disk alert (< 10 GB free)" under Planned since v1.

Two things make this a safety net rather than a nice-to-have. postgres_data,
media_data and exports_data are all Docker named volumes on ONE filesystem, so
running out doesn't degrade a subsystem -- Postgres stops being able to write and the
whole event goes down. And the keepsake needs room for two gallery-sized archives,
which the export preflight can only ever refuse AFTER the release, when the event is
over and every remedy is harder.

So the threshold is not a fixed number alone. It fires on the 10 GB floor the README
always named, OR on "you could not build the keepsake right now" -- the trigger a
host can still act on, computed with the same arithmetic the preflight uses. Unknown
free space is NOT low: it fails open like the upload quota and the preflight do,
because a banner that cries wolf on an unreadable mount is a banner nobody reads.

Carried on GET /host/event, which the dashboard already fetches on load and on every
reload -- no new endpoint, no new poll. Rendered above everything else including the
PIN-reset queue, and it names the consequence (the event, not just the download)
rather than only the number.

Also fixes the host page's formatBytes, which topped out at MB: 30 GB free would have
rendered as "30720.0 MB", and a guest with 2 GB of uploads was already being shown
that way in the user list.

Tests: 5 unit on the threshold (including that plenty of free space is still low when
the keepsake wouldn't fit -- the case a fixed threshold misses entirely), 3 e2e.
The e2e drives it through `original_size_bytes` rather than a genuinely full disk:
the estimate is pure SQL over that column, so overstating one row moves the
accounting without touching a byte on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:48:06 +02:00

98 lines
4.1 KiB
TypeScript

/**
* Regression guard — the host is warned about storage BEFORE it becomes unrecoverable.
*
* Storage visibility used to exist in exactly one place: a passive "Speicherauslastung" widget on
* the ADMIN dashboard. A host who isn't the admin had no view of it at all, and nothing anywhere
* warned anyone. README listed a low-disk alert under "Planned (v1.x)".
*
* Two things make that a safety net rather than a nice-to-have:
*
* - `postgres_data`, `media_data` and `exports_data` are all Docker named volumes on ONE
* filesystem. A full disk doesn't degrade a subsystem; Postgres stops being able to write and
* the whole event goes down.
* - The keepsake needs room for TWO gallery-sized archives (both write their media
* `Compression::Stored`; `Memories.zip` streams the original for every video and every image
* at or under 5 MB). The export preflight can refuse cleanly, but only AFTER the release —
* when the event is over, the gallery is full, and every remedy is harder.
*
* So the threshold is deliberately NOT a fixed number alone. It fires on an absolute floor (10 GB,
* the figure the README always carried) OR on "you could not build the keepsake right now", which
* is the trigger a host can still act on.
*
* These drive it through `original_size_bytes` rather than a genuinely full disk: the estimate is
* pure SQL over that column, so overstating one row moves the accounting the warning reads without
* touching a byte on disk.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
import { BASE } from '../../helpers/env';
/** Comfortably larger than any disk this suite could run on. */
const ABSURD_BYTES = 500_000_000_000_000;
test.describe('Host — low-disk warning', () => {
test('a gallery too big to export warns the host, with the numbers', async ({
page,
host,
guest,
signIn,
db,
}) => {
const g = await guest('BigShooter');
const uploadId = await seedUpload(g.jwt);
await db.setUploadSizeBytes(uploadId, ABSURD_BYTES);
await signIn(page, host);
await page.goto('/host');
const warning = page.getByTestId('low-disk-warning');
await expect(warning, 'the host must be warned before releasing').toBeVisible({
timeout: 15_000,
});
// The actionable half: not just "low", but "the keepsake cannot be built".
await expect(warning).toContainText(/nicht.*erstellt werden/i);
// And the consequence that makes it urgent — the event, not just the download.
await expect(warning).toContainText(/gesamte Event/i);
});
test('the API reports the requirement and the verdict together', async ({ host, guest, db }) => {
const g = await guest('BigShooter2');
const uploadId = await seedUpload(g.jwt);
await db.setUploadSizeBytes(uploadId, ABSURD_BYTES);
const res = await fetch(`${BASE}/api/v1/host/event`, {
headers: { Authorization: `Bearer ${host.jwt}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
disk_low: boolean;
disk_free_bytes: number | null;
keepsake_required_bytes: number;
};
expect(body.disk_low).toBe(true);
expect(
body.keepsake_required_bytes,
'both halves are armed by a release, so the requirement covers two archives'
).toBeGreaterThan(ABSURD_BYTES);
expect(body.disk_free_bytes).not.toBeNull();
expect(body.keepsake_required_bytes).toBeGreaterThan(body.disk_free_bytes!);
});
test('an ordinary gallery shows no warning at all', async ({ page, host, guest, signIn }) => {
// The mirror that keeps the above honest. A warning that is always on is a warning nobody
// reads — and it would sit at the very top of the dashboard, above the PIN-reset queue.
const g = await guest('NormalShooter');
await seedUpload(g.jwt);
await signIn(page, host);
await page.goto('/host');
// Wait for the dashboard to actually be loaded before asserting on an absence.
await expect(page.getByRole('heading', { name: 'Host-Dashboard' })).toBeVisible({
timeout: 15_000,
});
await expect(page.getByTestId('low-disk-warning')).toHaveCount(0);
});
});