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: , children: [ { path: '/settings', element: }, { path: '*', element: } ] } ], { initialEntries: ['/overview', '/settings', '/activity'], initialIndex: 1 }); render( ); return router; } function LocationProbe() { const location = useLocation(); return
{location.pathname}
; } const runtimeConfig: RuntimeConfig = { appBaseUrl: 'https://mail.example.test', mailHostname: 'mail.example.test', sendingIp: '192.0.2.10', defaultSpfMechanisms: '', dmarcPolicy: 'none', dmarcRua: '', registrationRequiresApproval: false, sendRequiresVerified: true, engagementTrackingEnabled: true, listUnsubscribeMailto: '', listUnsubscribeUrl: '', listUnsubscribePostEnabled: false, feedbackIdEnabled: false, reportAbuseTo: '', csaComplaintsTo: '', bounceAddress: '', bounceEnvelopeEnabled: false };