Files
Mangalord/frontend/src/lib/upload-validation.test.ts
MechaCat02 790549636f
Some checks failed
deploy / test-backend (push) Waiting to run
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled
feat(storage): admin storage-usage stats + per-manga/chapter sizes
Persist blob sizes (covers + chapter pages) and surface upload storage
usage to admins in two places:

- Admin System tab: total/covers/chapters totals, ratio of disk, average
  image sizes, and top-5 largest mangas/chapters leaderboards, behind a
  new GET /api/v1/admin/storage endpoint (separate from /admin/system so
  the cheap DB aggregates aren't coupled to its 250ms CPU sample).
- Manga detail page: total chapter-content size and per-chapter size,
  with an em-dash for uncrawled or not-yet-measured chapters.

Sizes are captured at write time (crawler put_stream return, uploaded
page/cover byte length) into nullable pages.size_bytes /
mangas.cover_size_bytes columns; NULL means "not yet measured", distinct
from a real 0. A one-shot, idempotent admin "Backfill sizes" action
(POST /api/v1/admin/storage/backfill) stats pre-existing blobs in
keyset-paginated, capped batches and reports more_remaining so a large
legacy library can be drained over multiple runs.

Aggregates and leaderboards treat NULL honestly (only fully-measured
entities are ranked); a dashboard banner flags unmeasured rows so partial
figures aren't read as complete. persist_pages now prunes stale page rows
on a shrinking re-crawl so totals and page_count stay consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:18:41 +02:00

58 lines
2.1 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { validateImageFile, MAX_FILE_BYTES, formatBytes } from './upload-validation';
function makeFile(name: string, type: string, size: number): File {
// jsdom's File doesn't honour `size` from a tiny Blob, so we expose
// the desired size by overriding the getter on the constructed File.
const f = new File([new Uint8Array(0)], name, { type });
Object.defineProperty(f, 'size', { value: size });
return f;
}
describe('validateImageFile', () => {
it('returns null for a small image', () => {
const f = makeFile('cover.png', 'image/png', 1024);
expect(validateImageFile(f)).toBeNull();
});
it('rejects oversized files with a sized message', () => {
const f = makeFile('cover.png', 'image/png', MAX_FILE_BYTES + 1);
const err = validateImageFile(f);
expect(err).toContain('cover.png');
expect(err).toContain('too large');
});
it('rejects non-image MIME types', () => {
const f = makeFile('doc.pdf', 'application/pdf', 1024);
const err = validateImageFile(f);
expect(err).toContain('unsupported image type');
expect(err).toContain('application/pdf');
});
it('allows files with unknown MIME (lets the backend sniff decide)', () => {
const f = makeFile('mystery.bin', '', 1024);
expect(validateImageFile(f)).toBeNull();
});
it.each(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif'])(
'allows %s',
(type) => {
const f = makeFile('x', type, 1024);
expect(validateImageFile(f)).toBeNull();
}
);
});
describe('formatBytes', () => {
it('formats KiB and MiB sensibly', () => {
expect(formatBytes(512)).toBe('512 B');
expect(formatBytes(2048)).toBe('2 KiB');
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MiB');
});
it('scales up to GiB and TiB for large storage totals', () => {
expect(formatBytes(6 * 1024 ** 3)).toBe('6.0 GiB');
expect(formatBytes(Math.round(1.5 * 1024 ** 4))).toBe('1.5 TiB');
});
});