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>
58 lines
2.1 KiB
TypeScript
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');
|
|
});
|
|
});
|