Files
TREK/server/tests/unit/nest/storage/storage-stats.service.test.ts
T
Maurice e3982a8e2a test(server): seed the repository, MCP and e2e specs through the factories
Converts the raw prepare() fixtures and reads in the next set of specs to
the ORM factories and readers: the password reset, webauthn, dawarich,
budget, to-do and vacay repository specs, the journey, settings, addons and
share MCP specs, the plugin POI and operator egress specs, the feeds,
storage stats and query helper services, and the packing, collections,
day reorder, leg mode, upload context and trip access integration suites.

The notifications and google quota e2e suites now seed their users through
makeUser with pinned ids, and the notifications suite moves from hand-rolled
tables to the migrated snapshot. Legacy statements that serve as a parity
oracle and the raw rows the primary-relation spike holds toObject() against
stay raw, each with an allow marker naming why.
2026-10-08 18:46:47 +02:00

110 lines
4.7 KiB
TypeScript

import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
vi.mock('../../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../../helpers/db-mock');
const db = createSnapshotTestDb();
return { db, closeDb: () => {}, reinitialize: () => {} };
});
vi.mock('../../../../src/config', () => ({ ENCRYPTION_KEY: 'storage-stats-test-key' }));
import { db as testDb } from '../../../../src/db/database';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { StorageEventsService } from '../../../../src/nest/storage/storage-events.service';
import { StorageRegistryService } from '../../../../src/nest/storage/storage-registry.service';
import { StorageService } from '../../../../src/nest/storage/storage.service';
import { StatsBusyError, StorageStatsService } from '../../../../src/nest/storage/storage-stats.service';
import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../../../helpers/test-uow';
import { deleteRows } from '../../../helpers/factories/rows';
import { readAppSetting, setAppSetting } from '../../../helpers/factories/settings';
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
const tmpDirs: string[] = [];
function makeTmpDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-stats-'));
tmpDirs.push(dir);
return dir;
}
async function setSetting(key: string, value: string): Promise<void> {
await setAppSetting(await sharedTestOrm(testDb), key, value);
}
beforeEach(async () => {
await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'storage.%' } });
});
afterEach(() => {
vi.restoreAllMocks();
while (tmpDirs.length) fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true });
});
async function makeWorld() {
const uploadsRoot = makeTmpDir();
const backupsRoot = makeTmpDir();
await setSetting(
'storage.backends',
JSON.stringify([
{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } },
{ name: 'backups-local', type: 'local', options: { root: backupsRoot } },
]),
);
const env = { placePhotoDir: undefined };
const appSettings = await createTestAppSettingsRepo(testDb);
const registry = new StorageRegistryService(
appSettings,
env,
new StorageEventsService(),
await createTestUnitOfWork(testDb),
(await sharedTestOrm(testDb)).orm,
);
await registry.onModuleInit();
const storage = new StorageService(registry);
const stats = new StorageStatsService(storage, appSettings);
return { storage, stats, uploadsRoot };
}
describe('StorageStatsService', () => {
it('STATS-001 sums objects and bytes per category, legacy photos separate, and persists with computedAt', async () => {
const { storage, stats } = await makeWorld();
await storage.put('files', 'a.pdf', Readable.from('12345')); // 5 bytes
await storage.put('files', 'b.pdf', Readable.from('123')); // 3 bytes
await storage.put('covers', 'c.jpg', Readable.from('1234567')); // 7 bytes
await storage.put('photos', 'legacy.jpg', Readable.from('12')); // legacy dir
const usage = await stats.scan();
expect(usage.categories.files).toEqual({ objects: 2, bytes: 8 });
expect(usage.categories.covers).toEqual({ objects: 1, bytes: 7 });
expect(usage.categories.backups).toEqual({ objects: 0, bytes: 0 });
expect(usage.legacyPhotos).toEqual({ objects: 1, bytes: 2 });
expect(usage.computedAt).toBeGreaterThan(0);
// Persisted round-trip:
expect(await stats.readUsage()).toEqual(usage);
const raw = await readAppSetting(await sharedTestOrm(testDb), 'storage.usage');
expect(JSON.parse(raw ?? 'null')).toEqual(usage);
});
it('STATS-002 photos-google/photos-trek nested content is NOT double-counted into legacy photos', async () => {
const { storage, stats } = await makeWorld();
await storage.put('photos-google', 'g.jpg', Readable.from('gggg'));
const usage = await stats.scan();
expect(usage.categories['photos-google']).toEqual({ objects: 1, bytes: 4 });
expect(usage.legacyPhotos).toEqual({ objects: 0, bytes: 0 });
});
it('STATS-003 concurrent scans throw StatsBusyError', async () => {
const { storage, stats } = await makeWorld();
for (let i = 0; i < 30; i++) await storage.put('files', `f${i}.bin`, Readable.from('x'.repeat(500)));
const first = stats.scan();
await expect(stats.scan()).rejects.toThrow(StatsBusyError);
await first;
});
it('STATS-004 readUsage returns null on absent or unparseable rows', async () => {
const { stats } = await makeWorld();
expect(await stats.readUsage()).toBeNull();
await setSetting('storage.usage', 'not json');
expect(await stats.readUsage()).toBeNull();
});
});