Files
EventSnap/e2e/fixtures/db.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

188 lines
6.3 KiB
TypeScript

/**
* Direct PostgreSQL escape hatch for setting up states the public API doesn't
* expose — e.g. forcing a user into the locked-PIN state to assert the 429
* recovery path, or expiring sessions for chaos tests.
*
* Most tests should NOT use this: prefer `ApiClient` so the tests exercise
* the same code paths real users do. Reach for direct SQL only when the API
* can't get you where you need to go.
*/
import { Client } from 'pg';
const CONN = {
host: process.env.E2E_DB_HOST ?? 'localhost',
port: Number(process.env.E2E_DB_PORT ?? '55432'),
user: process.env.E2E_DB_USER ?? 'eventsnap_test',
password: process.env.E2E_DB_PASSWORD ?? 'eventsnap_test',
database: process.env.E2E_DB_NAME ?? 'eventsnap_test',
};
async function withClient<T>(fn: (c: Client) => Promise<T>): Promise<T> {
const client = new Client(CONN);
await client.connect();
try {
return await fn(client);
} finally {
await client.end();
}
}
export const db = {
async lockUserPin(userId: string, minutesFromNow = 15) {
await withClient((c) =>
c.query(
`UPDATE "user" SET pin_locked_until = NOW() + ($2 || ' minutes')::interval, failed_pin_attempts = 3 WHERE id = $1`,
[userId, String(minutesFromNow)]
)
);
},
async expireSession(userId: string) {
await withClient((c) =>
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [
userId,
])
);
},
async setUploadCompressionStatus(
uploadId: string,
status: 'pending' | 'processing' | 'done' | 'failed'
) {
await withClient((c) =>
c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status])
);
},
async compressionStatus(uploadId: string): Promise<string | null> {
return withClient(async (c) => {
const r = await c.query<{ compression_status: string }>(
`SELECT compression_status FROM upload WHERE id = $1`,
[uploadId]
);
return r.rows[0]?.compression_status ?? null;
});
},
/** Which revision of the derivative pipeline produced this row's preview/display. */
async derivativesRev(uploadId: string): Promise<number | null> {
return withClient(async (c) => {
const r = await c.query<{ derivatives_rev: number }>(
`SELECT derivatives_rev FROM upload WHERE id = $1`,
[uploadId]
);
return r.rows[0]?.derivatives_rev ?? null;
});
},
async countUploadsForUser(userId: string): Promise<number> {
return withClient(async (c) => {
const r = await c.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM upload WHERE user_id = $1 AND deleted_at IS NULL`,
[userId]
);
return Number(r.rows[0].count);
});
},
async countSessionsForUser(userId: string): Promise<number> {
return withClient(async (c) => {
const r = await c.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM session WHERE user_id = $1`,
[userId]
);
return Number(r.rows[0].count);
});
},
async countPinResetRequestsForUser(userId: string): Promise<number> {
return withClient(async (c) => {
const r = await c.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM pin_reset_request WHERE user_id = $1`,
[userId]
);
return Number(r.rows[0].count);
});
},
/**
* Overstate an upload's recorded size.
*
* The keepsake size estimate and the low-disk threshold are pure SQL over
* `original_size_bytes` — no file is read — so this is the lever for driving "the keepsake
* would not fit" without a genuinely full disk. The bytes on disk are unchanged; only the
* accounting the warning reads from moves.
*/
async setUploadSizeBytes(uploadId: string, bytes: number) {
await withClient((c) =>
c.query(`UPDATE upload SET original_size_bytes = $2 WHERE id = $1`, [uploadId, bytes])
);
},
async setExportReleased(slug: string, released: boolean) {
await withClient((c) =>
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [
slug,
released ? new Date() : null,
])
);
},
/**
* Make an export "ready" (or not) in the epoch model. There is no `export_zip_ready` column any
* more — readiness is DERIVED (`released AND job.epoch = event.export_epoch AND status='done'`),
* so a job is ready exactly when its row carries the event's live epoch. To make a `done` job NOT
* ready we retire it to a dead epoch (-1), which is what a reopen effectively does.
*
* `file_path` is deliberately left NULL, so a "ready" job with no file on disk still exercises
* the download's missing-file 404 branch.
*/
async setExportZipReady(slug: string, ready: boolean) {
await withClient((c) =>
c.query(
`UPDATE export_job ej
SET epoch = CASE WHEN $2 THEN e.export_epoch ELSE -1 END
FROM event e
WHERE e.id = ej.event_id AND e.slug = $1 AND ej.type = 'zip'`,
[slug, ready]
)
);
},
/**
* Insert a pre-baked export job row to skip the (slow) real compression path. Stamped with the
* event's CURRENT epoch so it counts as the live generation.
*/
async fakeExportJob(
eventSlug: string,
type: 'zip' | 'html',
status: 'pending' | 'running' | 'done' | 'failed',
errorMessage: string | null = null
) {
await withClient(async (c) => {
const ev = await c.query<{ id: string; export_epoch: string }>(
`SELECT id, export_epoch FROM event WHERE slug = $1`,
[eventSlug]
);
if (ev.rows.length === 0) throw new Error(`No event with slug ${eventSlug}`);
await c.query(
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at, epoch,
error_message)
VALUES ($1, $2::export_type, $3::export_status, $4, $5, $6, $7)
ON CONFLICT (event_id, type) DO UPDATE
SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct,
epoch = EXCLUDED.epoch, error_message = EXCLUDED.error_message`,
[
ev.rows[0].id,
type,
status,
status === 'done' ? 100 : 0,
status === 'done' ? new Date() : null,
ev.rows[0].export_epoch,
errorMessage,
]
);
});
},
};