settings-navigation-guard.test.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. import { App as AntApp, ConfigProvider } from 'antd';
  2. import { act, render, screen, waitFor, within } from '@testing-library/react';
  3. import userEvent from '@testing-library/user-event';
  4. import { createMemoryRouter, RouterProvider, useLocation } from 'react-router-dom';
  5. import { afterEach, describe, expect, it, vi } from 'vitest';
  6. import { AppContext, type AppContextValue } from '../../src/frontend/app-context';
  7. import { I18nProvider } from '../../src/frontend/i18n/react';
  8. import { api } from '../../src/frontend/services/api';
  9. import { mailhubTheme } from '../../src/frontend/theme';
  10. import type { RuntimeConfig } from '../../src/frontend/types';
  11. import { AdminLayout } from '../../src/layouts/AdminLayout';
  12. import Settings from '../../src/pages/Settings';
  13. describe('Settings navigation guard', () => {
  14. afterEach(() => vi.restoreAllMocks());
  15. it('guards sidebar and history navigation, beforeunload, and stops guarding after a successful save', async () => {
  16. const user = userEvent.setup();
  17. vi.spyOn(api, 'adminSettings').mockResolvedValue({ settings: runtimeConfig });
  18. const saveSettings = vi.spyOn(api, 'saveAdminSettings').mockImplementation(async (values) => ({
  19. settings: { ...runtimeConfig, ...values }
  20. }));
  21. const router = renderSettingsRouter();
  22. let baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
  23. const cleanUnload = new Event('beforeunload', { cancelable: true });
  24. window.dispatchEvent(cleanUnload);
  25. expect(cleanUnload.defaultPrevented).toBe(false);
  26. await user.clear(baseUrlInput);
  27. await user.type(baseUrlInput, 'https://changed.example.test');
  28. const dirtyUnload = new Event('beforeunload', { cancelable: true });
  29. window.dispatchEvent(dirtyUnload);
  30. expect(dirtyUnload.defaultPrevented).toBe(true);
  31. await user.click(screen.getAllByText('发送活动')[0]);
  32. let dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
  33. expect(router.state.location.pathname).toBe('/settings');
  34. await user.click(within(dialog).getByRole('button', { name: '继续编辑' }));
  35. await waitFor(() => expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull());
  36. expect(router.state.location.pathname).toBe('/settings');
  37. await user.click(screen.getAllByText('发送活动')[0]);
  38. dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
  39. await user.click(within(dialog).getByRole('button', { name: '离开页面' }));
  40. await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
  41. await act(async () => {
  42. await router.navigate(-1);
  43. });
  44. baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
  45. await user.clear(baseUrlInput);
  46. await user.type(baseUrlInput, 'https://history.example.test');
  47. await act(async () => {
  48. await router.navigate(-1);
  49. });
  50. dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
  51. expect(router.state.location.pathname).toBe('/settings');
  52. await user.click(within(dialog).getByRole('button', { name: '继续编辑' }));
  53. await waitFor(() => expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull());
  54. await act(async () => {
  55. await router.navigate(1);
  56. });
  57. dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
  58. await user.click(within(dialog).getByRole('button', { name: '离开页面' }));
  59. await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
  60. await act(async () => {
  61. await router.navigate(-1);
  62. });
  63. baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
  64. await user.clear(baseUrlInput);
  65. await user.type(baseUrlInput, 'https://saved.example.test');
  66. const saveButton = screen.getByRole('button', { name: /保存设置/ });
  67. await user.click(saveButton);
  68. await waitFor(() => expect(saveSettings).toHaveBeenCalledTimes(1));
  69. await waitFor(() => expect((saveButton as HTMLButtonElement).disabled).toBe(true));
  70. const savedUnload = new Event('beforeunload', { cancelable: true });
  71. window.dispatchEvent(savedUnload);
  72. expect(savedUnload.defaultPrevented).toBe(false);
  73. await user.click(screen.getAllByText('概览')[0]);
  74. await waitFor(() => expect(router.state.location.pathname).toBe('/overview'));
  75. expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull();
  76. });
  77. });
  78. function renderSettingsRouter() {
  79. const context: AppContextValue = {
  80. user: {
  81. id: 1,
  82. username: 'operator',
  83. email: 'operator@example.test',
  84. role: 'admin',
  85. status: 'active'
  86. },
  87. config: runtimeConfig,
  88. refreshBootstrap: vi.fn(async () => undefined),
  89. logout: vi.fn(async () => undefined)
  90. };
  91. const router = createMemoryRouter([
  92. {
  93. element: <AdminLayout />,
  94. children: [
  95. { path: '/settings', element: <Settings /> },
  96. { path: '*', element: <LocationProbe /> }
  97. ]
  98. }
  99. ], {
  100. initialEntries: ['/overview', '/settings', '/activity'],
  101. initialIndex: 1
  102. });
  103. render(
  104. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  105. <AntApp>
  106. <I18nProvider>
  107. <AppContext.Provider value={context}>
  108. <RouterProvider router={router} />
  109. </AppContext.Provider>
  110. </I18nProvider>
  111. </AntApp>
  112. </ConfigProvider>
  113. );
  114. return router;
  115. }
  116. function LocationProbe() {
  117. const location = useLocation();
  118. return <div data-testid="location">{location.pathname}</div>;
  119. }
  120. const runtimeConfig: RuntimeConfig = {
  121. appBaseUrl: 'https://mail.example.test',
  122. mailHostname: 'mail.example.test',
  123. sendingIp: '192.0.2.10',
  124. defaultSpfMechanisms: '',
  125. dmarcPolicy: 'none',
  126. dmarcRua: '',
  127. sendRequiresVerified: true,
  128. engagementTrackingEnabled: true,
  129. listUnsubscribeMailto: '',
  130. listUnsubscribeUrl: '',
  131. listUnsubscribePostEnabled: false,
  132. feedbackIdEnabled: false,
  133. reportAbuseTo: '',
  134. csaComplaintsTo: '',
  135. bounceAddress: '',
  136. bounceEnvelopeEnabled: false
  137. };