import { App as AntApp, ConfigProvider } from 'antd';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router-dom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AppContext, type AppContextValue } from '../../src/frontend/app-context';
import { I18nProvider } from '../../src/frontend/i18n/react';
import { api } from '../../src/frontend/services/api';
import { mailhubTheme } from '../../src/frontend/theme';
import type { RuntimeConfig } from '../../src/frontend/types';
import { AdminLayout } from '../../src/layouts/AdminLayout';
import Settings from '../../src/pages/Settings';
describe('Settings navigation guard', () => {
afterEach(() => vi.restoreAllMocks());
it('guards sidebar and history navigation, beforeunload, and stops guarding after a successful save', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'adminSettings').mockResolvedValue({ settings: runtimeConfig });
const saveSettings = vi.spyOn(api, 'saveAdminSettings').mockImplementation(async (values) => ({
settings: { ...runtimeConfig, ...values }
}));
const router = renderSettingsRouter();
let baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
const cleanUnload = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(cleanUnload);
expect(cleanUnload.defaultPrevented).toBe(false);
await user.clear(baseUrlInput);
await user.type(baseUrlInput, 'https://changed.example.test');
const dirtyUnload = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(dirtyUnload);
expect(dirtyUnload.defaultPrevented).toBe(true);
await user.click(screen.getAllByText('发送活动')[0]);
let dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
expect(router.state.location.pathname).toBe('/settings');
await user.click(within(dialog).getByRole('button', { name: '继续编辑' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull());
expect(router.state.location.pathname).toBe('/settings');
await user.click(screen.getAllByText('发送活动')[0]);
dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
await user.click(within(dialog).getByRole('button', { name: '离开页面' }));
await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
await act(async () => {
await router.navigate(-1);
});
baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
await user.clear(baseUrlInput);
await user.type(baseUrlInput, 'https://history.example.test');
await act(async () => {
await router.navigate(-1);
});
dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
expect(router.state.location.pathname).toBe('/settings');
await user.click(within(dialog).getByRole('button', { name: '继续编辑' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull());
await act(async () => {
await router.navigate(1);
});
dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
await user.click(within(dialog).getByRole('button', { name: '离开页面' }));
await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
await act(async () => {
await router.navigate(-1);
});
baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
await user.clear(baseUrlInput);
await user.type(baseUrlInput, 'https://saved.example.test');
const saveButton = screen.getByRole('button', { name: /保存设置/ });
await user.click(saveButton);
await waitFor(() => expect(saveSettings).toHaveBeenCalledTimes(1));
await waitFor(() => expect((saveButton as HTMLButtonElement).disabled).toBe(true));
const savedUnload = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(savedUnload);
expect(savedUnload.defaultPrevented).toBe(false);
await user.click(screen.getAllByText('概览')[0]);
await waitFor(() => expect(router.state.location.pathname).toBe('/overview'));
expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull();
}, 20_000);
it('blocks a public HTTP base URL until it is changed to HTTPS', async () => {
const user = userEvent.setup();
const insecureConfig = { ...runtimeConfig, appBaseUrl: 'http://mail.example.test' };
vi.spyOn(api, 'adminSettings').mockResolvedValue({ settings: insecureConfig });
const saveSettings = vi.spyOn(api, 'saveAdminSettings').mockImplementation(async (values) => ({
settings: { ...insecureConfig, ...values }
}));
renderSettingsRouter(insecureConfig);
expect(await screen.findByText('公开访问地址不安全')).not.toBeNull();
const input = screen.getByLabelText('APP_BASE_URL');
await user.clear(input);
await user.type(input, 'http://public.example.test');
await user.click(screen.getByRole('button', { name: /保存设置/ }));
expect((await screen.findAllByText('公网实例必须使用 HTTPS,避免 API 密钥通过明文 HTTP 传输。')).length).toBeGreaterThan(0);
expect(saveSettings).not.toHaveBeenCalled();
await user.clear(input);
await user.type(input, 'https://public.example.test');
await user.click(screen.getByRole('button', { name: /保存设置/ }));
await waitFor(() => expect(saveSettings).toHaveBeenCalledTimes(1));
});
it('saves the registration approval policy with clear state and helper copy', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'adminSettings').mockResolvedValue({ settings: runtimeConfig });
const saveSettings = vi.spyOn(api, 'saveAdminSettings').mockImplementation(async (values) => ({
settings: { ...runtimeConfig, ...values }
}));
renderSettingsRouter();
const approvalSwitch = await screen.findByRole('switch', { name: '邮箱验证后需要管理员审核' });
expect(approvalSwitch.getAttribute('aria-checked')).toBe('false');
expect(screen.getByText('关闭时,用户验证邮箱后立即启用;开启时,账号进入待审核状态,需管理员批准后才能登录。')).not.toBeNull();
await user.click(approvalSwitch);
expect(approvalSwitch.getAttribute('aria-checked')).toBe('true');
await user.click(screen.getByRole('button', { name: /保存设置/ }));
await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({
registrationRequiresApproval: true
})));
});
});
function renderSettingsRouter(config = runtimeConfig) {
const context: AppContextValue = {
user: {
id: 1,
username: 'operator',
email: 'operator@example.test',
role: 'admin',
status: 'active'
},
config,
refreshBootstrap: vi.fn(async () => undefined),
logout: vi.fn(async () => undefined)
};
const router = createMemoryRouter([
{
element: