Files
TREK/client/src/components/Admin/storage/useStoragePanel.test.ts
T
Maurice 9d2ffc91af feat(storage): confirm the count shown, and say a backup or a missing install id held the cleanup
Confirm sends the count the admin saw and waits for its answer; a stale
count reloads the state. A running backup is named as a skip reason, a
missing installation id and a stale count as errors, and the switch's
hint no longer promises removal right away: deleted files go a day
later, once no undo can bring them back. Strings in all 28 languages.
2026-11-01 18:14:49 +01:00

334 lines
15 KiB
TypeScript

// FE-ADMIN-STOR-PANEL-001 to -020: the storage panel logic behind both admin shells.
import { act, renderHook, waitFor } from '@testing-library/react';
import { STORAGE_CATEGORIES, type StorageAdminState, type StorageBackend, type StorageConfig } from '@trek/shared';
import { removeBackend, settingsDocumentOf } from './storageModel';
import type { StorageAdmin } from './useStorageAdmin';
import { migrationPromptLine, useStoragePanel } from './useStoragePanel';
const fake = vi.hoisted(() => ({ admin: null as unknown as StorageAdmin }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('./useStorageAdmin', () => ({ useStorageAdmin: () => fake.admin }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../../i18n', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key),
}),
}));
function baseState(): StorageAdminState {
return {
backends: [
{ name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data' }, categories: [] },
{ name: 'off-box', type: 'local', source: 'settings', options: { root: '/mnt' }, categories: [] },
],
categories: Object.fromEntries(
STORAGE_CATEGORIES.map((category) => [category, { backend: 'uploads-local', source: 'default' }])
) as StorageAdminState['categories'],
health: { replicaFailures: [] },
seedFilePresent: false,
reclaim: null,
usage: null,
backfills: [],
migrations: [],
version: 3,
configError: null,
};
}
function makeAdmin(state: StorageAdminState | null, draft?: StorageConfig): StorageAdmin {
return {
state,
storageBusy: vi.fn(() => false),
draft: state ? { ...(draft ?? settingsDocumentOf(state)), version: state.version } : null,
dirty: false,
loading: false,
loadError: null,
saveError: null,
saveConflict: false,
saving: false,
testResults: {},
setDraft: vi.fn(),
save: vi.fn(async () => true),
test: vi.fn(async () => {}),
testMirror: vi.fn(async () => {}),
startBackfill: vi.fn(async () => null),
cancelBackfill: vi.fn(async () => null),
startMigration: vi.fn(async () => null),
cancelMigration: vi.fn(async () => null),
refreshStats: vi.fn(async () => null),
setReclaimDelete: vi.fn(async () => null),
checkReclaim: vi.fn(async () => null),
confirmReclaim: vi.fn(async () => null),
refreshState: vi.fn(async () => {}),
discardDraft: vi.fn(async () => {}),
};
}
/** A draft that moves the given categories onto `off-box`. */
function movingDraft(...categories: string[]): StorageConfig {
const state = baseState();
return {
...settingsDocumentOf(state),
categories: Object.fromEntries(categories.map((c) => [c, 'off-box'])) as StorageConfig['categories'],
};
}
beforeEach(() => {
toast.success.mockReset();
toast.error.mockReset();
});
describe('useStoragePanel', () => {
it('FE-ADMIN-STOR-PANEL-001: nothing is loaded until the state and the draft exist', () => {
fake.admin = makeAdmin(null);
const { result } = renderHook(() => useStoragePanel());
expect(result.current.loaded).toBeNull();
});
it('FE-ADMIN-STOR-PANEL-002: a loaded panel folds the rows and lists every backend name', () => {
fake.admin = makeAdmin(baseState());
const { result } = renderHook(() => useStoragePanel());
expect(result.current.loaded?.rows.map((r) => r.name)).toEqual(['uploads-local', 'off-box']);
expect(result.current.loaded?.backendNames).toEqual(['uploads-local', 'off-box']);
expect(result.current.loaded?.migrationCandidates).toEqual([]);
});
it('FE-ADMIN-STOR-PANEL-003: add and edit open the editor with the right mirror candidates', () => {
fake.admin = makeAdmin(baseState());
const { result } = renderHook(() => useStoragePanel());
act(() => result.current.loaded!.startAdd());
expect(result.current.editing).toMatchObject({ initial: null, originalName: null, mirror: { initialTargets: [] } });
const row = result.current.loaded!.rows[1]!;
act(() => result.current.loaded!.startEdit(row));
expect(result.current.editing).toMatchObject({ initial: row.backend, originalName: 'off-box' });
});
it('FE-ADMIN-STOR-PANEL-004: a rename commits the renamed backend and closes the editor', () => {
fake.admin = makeAdmin(baseState());
const { result } = renderHook(() => useStoragePanel());
act(() => result.current.loaded!.startEdit(result.current.loaded!.rows[1]!));
const renamed: StorageBackend = { name: 'off-site', type: 'local', options: { root: '/mnt' } };
act(() => result.current.loaded!.commitBackend(renamed));
const next = vi.mocked(fake.admin.setDraft).mock.calls[0]![0];
expect(next.backends.map((b) => b.name)).toEqual(['off-site']);
expect(result.current.editing).toBeNull();
});
it('FE-ADMIN-STOR-PANEL-005: a category back on its default drops the override, any other sets it', () => {
fake.admin = makeAdmin(baseState(), movingDraft('files'));
const { result } = renderHook(() => useStoragePanel());
act(() => result.current.loaded!.setCategory('files', 'uploads-local'));
expect(vi.mocked(fake.admin.setDraft).mock.calls[0]![0].categories).toEqual({});
act(() => result.current.loaded!.setCategory('covers', 'off-box'));
expect(vi.mocked(fake.admin.setDraft).mock.calls[1]![0].categories).toEqual({
files: 'off-box',
covers: 'off-box',
});
});
it('FE-ADMIN-STOR-PANEL-006: confirming a removal drops the backend and closes the confirm', () => {
fake.admin = makeAdmin(baseState());
const { result } = renderHook(() => useStoragePanel());
act(() => result.current.setConfirmRemove({ name: 'off-box', degenerate: true }));
expect(result.current.loaded!.removeMessage('off-box', true)).toBe('storage.remove.body {"name":"off-box"}');
act(() => result.current.loaded!.confirmRemoval());
expect(fake.admin.setDraft).toHaveBeenCalledWith(removeBackend(fake.admin.draft!, 'off-box'));
expect(result.current.confirmRemove).toBeNull();
});
it('FE-ADMIN-STOR-PANEL-007: a save with nothing to move saves straight away and says so', async () => {
fake.admin = makeAdmin(baseState());
const { result } = renderHook(() => useStoragePanel());
await act(async () => {
await result.current.loaded!.save();
});
expect(fake.admin.save).toHaveBeenCalledWith();
expect(toast.success).toHaveBeenCalledWith('storage.saved');
expect(result.current.migratePrompt).toBeNull();
});
it('FE-ADMIN-STOR-PANEL-008: a save that moves a category asks first, then saves without it and queues the move', async () => {
fake.admin = makeAdmin(baseState(), movingDraft('files'));
const { result } = renderHook(() => useStoragePanel());
await act(async () => {
await result.current.loaded!.save();
});
expect(fake.admin.save).not.toHaveBeenCalled();
expect(result.current.migratePrompt?.map((c) => c.category)).toEqual(['files']);
await act(async () => {
await result.current.loaded!.moveAndSave();
});
expect(vi.mocked(fake.admin.save).mock.calls[0]![0]!.categories).toEqual({});
expect(result.current.migratePrompt).toBeNull();
await waitFor(() => expect(fake.admin.startMigration).toHaveBeenCalledWith('files', 'off-box'));
expect(toast.success).toHaveBeenCalledWith('storage.saved');
});
it('FE-ADMIN-STOR-PANEL-009: moveAndSave moves exactly the candidates a shell hands it', async () => {
fake.admin = makeAdmin(baseState(), movingDraft('files', 'covers'));
const { result } = renderHook(() => useStoragePanel());
const [files] = result.current.loaded!.migrationCandidates;
await act(async () => {
await result.current.loaded!.moveAndSave([files!]);
});
expect(vi.mocked(fake.admin.save).mock.calls[0]![0]!.categories).toEqual({ covers: 'off-box' });
await waitFor(() => expect(fake.admin.startMigration).toHaveBeenCalledTimes(1));
});
it('FE-ADMIN-STOR-PANEL-010: route only closes the prompt and saves the draft as it is', async () => {
fake.admin = makeAdmin(baseState(), movingDraft('files'));
const { result } = renderHook(() => useStoragePanel());
await act(async () => {
await result.current.loaded!.save();
});
await act(async () => {
await result.current.loaded!.routeOnlySave();
});
expect(fake.admin.save).toHaveBeenCalledWith();
expect(result.current.migratePrompt).toBeNull();
act(() => result.current.closeMigratePrompt());
expect(result.current.migratePrompt).toBeNull();
});
it('FE-ADMIN-STOR-PANEL-011: a failed migration start toasts, drops the rest of the queue, and refreshes', async () => {
fake.admin = makeAdmin(baseState(), movingDraft('files', 'covers'));
vi.mocked(fake.admin.startMigration).mockResolvedValueOnce('busy');
const { result } = renderHook(() => useStoragePanel());
await act(async () => {
await result.current.loaded!.moveAndSave();
});
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('busy'));
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2));
expect(result.current.migrationQueue).toHaveLength(0);
expect(fake.admin.refreshState).toHaveBeenCalled();
});
it('FE-ADMIN-STOR-PANEL-012: a failed start drops the rest, names it, and refreshes', async () => {
fake.admin = makeAdmin(baseState(), movingDraft('files', 'covers'));
vi.mocked(fake.admin.startMigration).mockResolvedValueOnce('busy');
const { result } = renderHook(() => useStoragePanel());
await act(async () => {
await result.current.loaded!.moveAndSave();
});
await waitFor(() => expect(fake.admin.refreshState).toHaveBeenCalled());
expect(toast.error).toHaveBeenNthCalledWith(1, 'busy');
expect(toast.error).toHaveBeenNthCalledWith(
2,
'storage.migrate.queueDropped {"categories":"storage.category.covers"}'
);
expect(result.current.migrationQueue).toEqual([]);
expect(fake.admin.startMigration).toHaveBeenCalledTimes(1);
});
it('FE-ADMIN-STOR-PANEL-017: testRow probes a plain row as it is and a mirrored row per draft target', () => {
const state = baseState();
const draft: StorageConfig = {
...settingsDocumentOf(state),
backends: [
...settingsDocumentOf(state).backends,
{ name: 'off-box-mirror', type: 'mirror', options: { primary: 'off-box', replicas: ['uploads-local'] } },
],
};
fake.admin = makeAdmin(state, draft);
const { result } = renderHook(() => useStoragePanel());
const rows = result.current.loaded!.rows;
void result.current.loaded!.testRow(rows.find((r) => r.name === 'uploads-local')!);
expect(fake.admin.test).toHaveBeenCalledWith(expect.objectContaining({ name: 'uploads-local' }));
void result.current.loaded!.testRow(rows.find((r) => r.name === 'off-box')!);
expect(fake.admin.testMirror).toHaveBeenCalledWith('off-box-mirror', [
expect.objectContaining({ name: 'off-box', type: 'local' }),
expect.objectContaining({ name: 'uploads-local', type: 'local' }),
]);
});
it('FE-ADMIN-STOR-PANEL-013: backfill and stats actions toast the server error', async () => {
fake.admin = makeAdmin(baseState());
vi.mocked(fake.admin.refreshStats).mockResolvedValueOnce('scan failed');
vi.mocked(fake.admin.startBackfill).mockResolvedValueOnce('no mirror');
const { result } = renderHook(() => useStoragePanel());
const row = result.current.loaded!.rows[0]!;
await act(async () => {
await result.current.handleRefreshStats();
await result.current.handleStartBackfill(row);
await result.current.handleStartBackfill({ ...row, mirrorName: 'm' });
});
expect(fake.admin.startBackfill).toHaveBeenCalledTimes(1);
expect(fake.admin.startBackfill).toHaveBeenCalledWith('m');
expect(toast.error.mock.calls).toEqual([['scan failed'], ['no mirror']]);
});
});
describe('migrationPromptLine', () => {
const t = (key: string, params?: Record<string, string | number>) =>
params ? `${key} ${JSON.stringify(params)}` : key;
it('FE-ADMIN-STOR-PANEL-014: a counted category names its object count and size', () => {
expect(
migrationPromptLine(t, { category: 'covers', from: 'a', to: 'b', toWire: 'b', objects: 3, bytes: 2048 })
).toBe(
'storage.migrate.promptLine {"category":"storage.category.covers","count":3,"size":"2.0 KB","from":"a","to":"b"}'
);
});
it('FE-ADMIN-STOR-PANEL-015: a counted category with unknown bytes reports zero bytes', () => {
expect(
migrationPromptLine(t, { category: 'covers', from: 'a', to: 'b', toWire: 'b', objects: 0, bytes: null })
).toBe(
'storage.migrate.promptLine {"category":"storage.category.covers","count":0,"size":"0 B","from":"a","to":"b"}'
);
});
it('FE-ADMIN-STOR-PANEL-016: an uncounted category gets the line without count and size', () => {
expect(
migrationPromptLine(t, { category: 'covers', from: 'a', to: 'b', toWire: 'b', objects: null, bytes: null })
).toBe('storage.migrate.promptLineUnknown {"category":"storage.category.covers","from":"a","to":"b"}');
});
});
describe('useStoragePanel: unreferenced files', () => {
it('FE-ADMIN-STOR-PANEL-018: the reclaim switch and the check go through the admin hook, a failure as a toast', async () => {
fake.admin = makeAdmin(baseState());
vi.mocked(fake.admin.checkReclaim).mockResolvedValueOnce({ code: 'reclaim_busy' });
vi.mocked(fake.admin.setReclaimDelete).mockResolvedValueOnce({ message: 'disk full' });
const { result } = renderHook(() => useStoragePanel());
await act(async () => {
await result.current.handleReclaimDelete(true);
});
expect(fake.admin.setReclaimDelete).toHaveBeenCalledWith(true);
// A failure without a code the panel knows shows the server's message.
expect(toast.error).toHaveBeenCalledWith('disk full');
expect(result.current.reclaimSaving).toBe(false);
await act(async () => {
await result.current.handleReclaimCheck();
});
expect(fake.admin.checkReclaim).toHaveBeenCalledTimes(1);
// A code the panel knows is shown in the operator's language.
expect(toast.error).toHaveBeenCalledWith('storage.reclaim.error.reclaim_busy');
expect(result.current.reclaimChecking).toBe(false);
});
it('FE-ADMIN-STOR-PANEL-020: confirming a category goes through the admin hook and says so; a running check keeps the button busy', async () => {
const state = baseState();
state.reclaim = { deleteEnabled: true, running: true, lastRun: null, lastDeleteRun: null };
fake.admin = makeAdmin(state);
const { result } = renderHook(() => useStoragePanel());
expect(result.current.reclaimChecking).toBe(true);
await act(async () => {
await result.current.handleReclaimConfirm({ category: 'places', unreferenced: 3 });
});
expect(fake.admin.confirmReclaim).toHaveBeenCalledWith({ category: 'places', unreferenced: 3 });
expect(result.current.reclaimConfirming).toBeNull();
expect(toast.success).toHaveBeenCalledWith('storage.reclaim.confirmed');
});
it('FE-ADMIN-STOR-PANEL-019: a server without the reclaim state reads as deletion off and never checked', () => {
fake.admin = makeAdmin(baseState());
const { result } = renderHook(() => useStoragePanel());
expect(result.current.reclaim.deleteEnabled).toBe(false);
expect(result.current.reclaim.checkedAt).toBeNull();
expect(result.current.reclaim.lines).toEqual([]);
});
});