mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
df7bb9a5e1
On the phone storage panel, Test on a mirrored backend sent the mirror itself to POST /api/admin/storage/test. The server expands a mirror by looking its primary and replicas up by name in the SAVED storage config, so the phone tested the saved settings instead of what the operator had just edited, and a replica added but not saved yet made the whole test fail with "references unknown backend". The desktop probes the primary and each replica with their draft options and merges the results. Cause: the phone called admin.test with the mirror candidate, while the desktop called admin.testMirror with mirrorProbeTargets. The choice lived in each shell's markup. Fix: useStoragePanel now offers loaded.testRow(row), which tests a plain row as it is and a mirrored row through admin.testMirror with mirrorProbeTargets from the draft. Both shells call it. Red test: FE-MOB-MSTOR-018 (new, MAdminStoragePanel.test.tsx) failed with "AssertionError: expected [ 'mirror' ] to deeply equal [ 'backups-local', 'off-box' ]". FE-ADMIN-STOR-PANEL-017 (new, useStoragePanel.test.ts) covers testRow itself. No existing assertion changed.
285 lines
13 KiB
TypeScript
285 lines
13 KiB
TypeScript
// FE-ADMIN-STOR-PANEL-001 to -017: 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,
|
|
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),
|
|
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"}');
|
|
});
|
|
});
|