feat(admin): migrate system diagnostics and retain SMTP drafts

This commit is contained in:
cesnimda
2026-10-20 15:40:19 +02:00
parent 1d34406d1b
commit d824f59d2b
12 changed files with 1047 additions and 155 deletions
+227 -2
View File
@@ -1,11 +1,15 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import AdminSystemPage from './views/AdminSystemPage';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
let mockSessionContext = 'system-owner';
let mockSessionInvalidated = false;
jest.mock('./sessionBoundary', () => ({ checkSessionCookie: jest.fn(), getSessionContext: () => mockSessionContext, isSessionInvalidated: () => mockSessionInvalidated }));
jest.mock('./api', () => ({
api: {
get: jest.fn(),
@@ -19,9 +23,11 @@ jest.mock('./api', () => ({
}));
const mockedApi = api as jest.Mocked<typeof api>;
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; }
describe('AdminSystemPage', () => {
beforeEach(() => {
mockSessionContext = 'system-owner'; mockSessionInvalidated = false;
window.localStorage.clear();
mockedApi.get.mockImplementation((url: string) => {
if (url === '/admin/system') {
@@ -180,6 +186,223 @@ describe('AdminSystemPage', () => {
}
});
it('associates each tab with its named panel', async () => {
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
const statusTab = screen.getByRole('tab', { name: 'Status' });
const statusPanel = screen.getByRole('tabpanel', { name: 'Status' });
expect(statusTab).toHaveAttribute('aria-controls', statusPanel.id);
expect(statusPanel).toHaveAttribute('aria-labelledby', statusTab.id);
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
const settingsTab = screen.getByRole('tab', { name: 'Settings' });
expect(settingsTab).toHaveAttribute('aria-controls', screen.getByRole('tabpanel', { name: 'Settings' }).id);
expect(screen.queryByRole('tabpanel', { name: 'Status' })).not.toBeInTheDocument();
});
it('never fabricates offline metrics after an initial status failure and retries only the failed read', async () => {
const get = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/admin/system' ? Promise.reject(new Error('offline')) : get(url, config));
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
const alert = await screen.findByRole('alert');
expect(screen.queryByText('Offline')).not.toBeInTheDocument();
expect(screen.queryByText('Requests')).not.toBeInTheDocument();
mockedApi.get.mockImplementation(get);
mockedApi.get.mockClear();
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }));
expect(await screen.findByText('Production')).toBeVisible();
expect(mockedApi.get.mock.calls.map(call => call[0])).toEqual(['/admin/system']);
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
it('keeps SMTP edits and write-only password through explicit refresh', async () => {
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
fireEvent.change(screen.getByLabelText('Host'), { target: { value: 'unsaved.example.test' } });
fireEvent.change(screen.getByLabelText('SMTP password'), { target: { value: 'new-secret' } });
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled());
expect(screen.getByLabelText('Host')).toHaveValue('unsaved.example.test');
expect(screen.getByLabelText('SMTP password')).toHaveValue('new-secret');
expect(screen.queryByText('new-secret')).not.toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
});
it('submits one SMTP snapshot, retains newer edits and prevents competing operational actions', async () => {
const pending = deferred<any>();
mockedApi.put.mockReturnValueOnce(pending.promise);
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
expect(screen.queryByText('Unsaved SMTP changes.')).not.toBeInTheDocument();
await screen.findByText('Production');
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
expect(screen.queryByText('Unsaved SMTP changes.')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Host'), { target: { value: 'submitted.example.test' } });
fireEvent.change(screen.getByLabelText('SMTP password'), { target: { value: 'submitted-secret' } });
fireEvent.click(screen.getByRole('button', { name: 'Save settings' }));
fireEvent.change(screen.getByLabelText('Host'), { target: { value: 'newer.example.test' } });
fireEvent.change(screen.getByLabelText('SMTP password'), { target: { value: 'newer-secret' } });
expect(screen.getByRole('button', { name: 'Send test email' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Run probe now' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Refresh' })).toBeDisabled();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.put).toHaveBeenCalledWith('/admin/system/email-settings', expect.objectContaining({ host: 'submitted.example.test', password: 'submitted-secret', clearPassword: false }));
await act(async () => pending.resolve({ data: { enabled: true, host: 'submitted.example.test', hasPassword: true } }));
expect(screen.getByLabelText('Host')).toHaveValue('newer.example.test');
expect(screen.getByLabelText('SMTP password')).toHaveValue('newer-secret');
expect(screen.getByText('SMTP settings saved.')).toBeVisible();
expect(screen.getByText('Unsaved SMTP changes.')).toBeVisible();
expect(mockedApi.post).not.toHaveBeenCalled();
});
it.each([
['/admin/system/cv-benchmark', 'CV benchmark results could not be loaded.'],
['/admin/system/maintenance-jobs', 'Maintenance availability could not be loaded.'],
])('reports an optional %s failure and retries that GET without operational writes', async (path, message) => {
const get = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, config?: any) => url === path ? Promise.reject(new Error('offline')) : get(url, config));
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
expect(await screen.findByText(message)).toBeVisible();
expect(screen.getByText('Production')).toBeVisible();
expect(screen.queryByText('Maintenance dispatch is unavailable.')).not.toBeInTheDocument();
mockedApi.get.mockImplementation(get); mockedApi.get.mockClear();
fireEvent.click(within(screen.getByText(message).closest('[role="alert"]')!).getByRole('button', { name: 'Retry' }));
await waitFor(() => expect(screen.queryByText(message)).not.toBeInTheDocument());
expect(mockedApi.get.mock.calls.map(call => call[0])).toEqual([path]);
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('keeps explicit password clearing through refresh and acknowledges only the submitted secret', async () => {
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
const clear = screen.getByRole('checkbox', { name: /clear stored password/i });
fireEvent.click(clear);
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled());
expect(clear).toBeChecked();
fireEvent.click(screen.getByRole('button', { name: 'Save settings' }));
await screen.findByText('SMTP settings saved.');
expect(mockedApi.put).toHaveBeenCalledWith('/admin/system/email-settings', expect.objectContaining({ password: null, clearPassword: true }));
expect(clear).not.toBeChecked();
expect(screen.getByLabelText('SMTP password')).toHaveValue('');
expect(screen.queryByText('Unsaved SMTP changes.')).not.toBeInTheDocument();
expect(screen.getByLabelText('Host')).toHaveValue('smtp.changed.test');
});
it('retains failed SMTP edits and only writes again after an explicit save', async () => {
mockedApi.put.mockRejectedValueOnce({ response: { status: 503, data: { title: 'Private object must not stringify' } } });
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
fireEvent.change(screen.getByLabelText('Host'), { target: { value: 'keep.example.test' } });
fireEvent.change(screen.getByLabelText('SMTP password'), { target: { value: 'keep-secret' } });
fireEvent.click(screen.getByRole('button', { name: 'Save settings' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to save email settings.');
expect(screen.getByLabelText('Host')).toHaveValue('keep.example.test');
expect(screen.getByLabelText('SMTP password')).toHaveValue('keep-secret');
expect(screen.getByText('Unsaved SMTP changes.')).toBeVisible();
expect(screen.queryByText('[object Object]')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Save settings' })).toBeEnabled());
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('Host')).toHaveValue('keep.example.test');
});
it('waits for one probe before allowing another action and never runs on entry or tab changes', async () => {
const pending = deferred<any>(); mockedApi.post.mockReturnValueOnce(pending.promise);
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
expect(mockedApi.post).not.toHaveBeenCalled();
const probe = screen.getByRole('button', { name: 'Run probe now' });
fireEvent.click(probe); fireEvent.click(probe);
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
expect(screen.getByRole('button', { name: 'Send test email' })).toBeDisabled();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith('/admin/system/ai/probe');
await act(async () => pending.resolve({ data: {} }));
expect(await screen.findByText('AI probe completed.')).toBeVisible();
});
it('refreshes probe metrics after success and a failed follow-up read retries GET without replaying the probe', async () => {
mockedApi.post.mockResolvedValueOnce({ data: {} } as any);
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
const get = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/admin/system' ? Promise.reject(new Error('offline')) : get(url, config));
mockedApi.get.mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Run probe now' }));
expect(await screen.findByText('System status could not be refreshed. Previously loaded values remain.')).toBeVisible();
expect(mockedApi.get.mock.calls.map(call => call[0])).toEqual(['/admin/system']);
mockedApi.get.mockImplementation(get);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument());
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
it('does not acknowledge an SMTP write into a changed session', async () => {
const pending = deferred<any>(); mockedApi.put.mockReturnValueOnce(pending.promise);
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
fireEvent.change(screen.getByLabelText('Host'), { target: { value: 'owner-a.example.test' } });
fireEvent.click(screen.getByRole('button', { name: 'Save settings' }));
mockSessionContext = 'second-session';
await act(async () => pending.resolve({ data: { enabled: true, host: 'must-not-apply.example.test', hasPassword: true } }));
expect(screen.getByLabelText('Host')).toHaveValue('owner-a.example.test');
expect(screen.queryByText('SMTP settings saved.')).not.toBeInTheDocument();
});
it('does not refresh or announce a completed probe after unmount', async () => {
const pending = deferred<any>(); mockedApi.post.mockReturnValueOnce(pending.promise);
const view = render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
fireEvent.click(screen.getByRole('button', { name: 'Run probe now' }));
const count = mockedApi.get.mock.calls.length;
view.unmount();
await act(async () => pending.resolve({ data: {} }));
expect(mockedApi.get).toHaveBeenCalledTimes(count);
});
it('clears sensitive diagnostics after a denied refresh and offers no retry or operational action', async () => {
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
mockedApi.get.mockRejectedValue({ response: { status: 403 } });
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
await waitFor(() => expect(screen.queryByText('Production')).not.toBeInTheDocument());
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Run probe now' })).toBeDisabled();
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
expect(screen.queryByLabelText('SMTP password')).not.toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('keeps the last diagnostic values under a qualified refresh error', async () => {
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
const get = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/admin/system' ? Promise.reject({ response: { status: 503, data: 'Temporary failure' } }) : get(url, config));
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
expect(await screen.findByText('System status could not be refreshed. Previously loaded values remain.')).toBeVisible();
expect(screen.getByText('Production')).toBeVisible();
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('does not present unavailable SMTP settings as a blank editable configuration', async () => {
const get = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/admin/system/email-settings' ? Promise.reject(new Error('offline')) : get(url, config));
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
expect(await screen.findByText('SMTP settings could not be loaded.')).toBeVisible();
expect(screen.queryByLabelText('Host')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Send test email' })).toBeDisabled();
mockedApi.get.mockImplementation(get); mockedApi.get.mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByLabelText('Host')).toHaveValue('smtp.example.test');
expect(mockedApi.get.mock.calls.map(call => call[0])).toEqual(['/admin/system/email-settings']);
});
it('renders AI service health, latency, and OCR readiness', async () => {
render(
<I18nProvider>
@@ -230,7 +453,9 @@ describe('AdminSystemPage', () => {
mockedApi.post.mockResolvedValueOnce({ data: { jobId: 'cv-benchmark', status: 'queued' } } as any);
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
fireEvent.click(await screen.findByRole('button', { name: /run benchmark/i }));
const run = await screen.findByRole('button', { name: /run benchmark/i });
await waitFor(() => expect(run).toBeEnabled());
fireEvent.click(run);
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/admin/system/maintenance-jobs/cv-benchmark/runs');