admin-layout.test.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. import { App as AntApp, ConfigProvider } from 'antd';
  2. import { render, screen, waitFor } from '@testing-library/react';
  3. import userEvent from '@testing-library/user-event';
  4. import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
  5. import { describe, expect, it, vi } from 'vitest';
  6. import { AppContext } from '../../src/frontend/app-context';
  7. import { I18nProvider } from '../../src/frontend/i18n/react';
  8. import { mailhubTheme } from '../../src/frontend/theme';
  9. import { AdminLayout, navigationSelection, visibleNavigation } from '../../src/layouts/AdminLayout';
  10. import type { AppContextValue } from '../../src/frontend/app-context';
  11. describe('AdminLayout navigation', () => {
  12. it('hides all system management destinations from a normal user', () => {
  13. const paths = visibleNavigation(false).flatMap((group) => group.items.map((item) => item.path));
  14. expect(paths).not.toContain('/admin/users');
  15. expect(paths).not.toContain('/settings');
  16. });
  17. it('maps detail routes back to their owning navigation item', () => {
  18. expect(navigationSelection('/activity/42')).toBe('/activity');
  19. expect(navigationSelection('/domains/7/dns')).toBe('/domains');
  20. expect(navigationSelection('/inbox/messages/9')).toBe('/inbox');
  21. expect(navigationSelection('/account')).toBe('/account');
  22. });
  23. it('opens the account center for normal users without adding it to the main sidebar', async () => {
  24. const user = userEvent.setup();
  25. renderShell('/overview', { role: 'user' });
  26. expect(screen.queryByText('账号与邮箱权限')).toBeNull();
  27. await user.click(screen.getByRole('button', { name: '账户菜单' }));
  28. await user.click(await screen.findByRole('menuitem', { name: '账号与邮箱权限' }));
  29. expect(screen.getByTestId('location').textContent).toBe('/account');
  30. });
  31. it('navigates with semantic menu items and exposes a skip link', async () => {
  32. const user = userEvent.setup();
  33. renderShell('/overview', { role: 'admin' });
  34. expect(screen.getByRole('link', { name: '跳至主要内容' }).getAttribute('href')).toBe('#main-content');
  35. const activityItems = screen.getAllByText('发送活动');
  36. await user.click(activityItems[0]);
  37. expect(screen.getByTestId('location').textContent).toBe('/activity');
  38. await waitFor(() => expect(document.activeElement?.id).toBe('main-content'));
  39. });
  40. it('provides an explicit mobile navigation escape action', async () => {
  41. const user = userEvent.setup();
  42. renderShell('/overview', { role: 'user' });
  43. await user.click(screen.getByRole('button', { name: '打开主导航' }));
  44. const close = await screen.findByRole('button', { name: '关闭主导航' });
  45. expect(close.style.minHeight).toBe('44px');
  46. expect(close.style.minWidth).toBe('44px');
  47. await user.click(close);
  48. await waitFor(() => expect(screen.queryByRole('button', { name: '关闭主导航' })).toBeNull());
  49. });
  50. it('keeps mobile navigation available between Ant Design lg and the 1024px product breakpoint', async () => {
  51. vi.spyOn(window, 'matchMedia').mockImplementation((query) => mediaQueryList(query === '(min-width: 992px)', query));
  52. const user = userEvent.setup();
  53. renderShell('/overview', { role: 'user' });
  54. await user.click(screen.getByRole('button', { name: '打开主导航' }));
  55. expect(await screen.findByRole('button', { name: '关闭主导航' })).toBeTruthy();
  56. });
  57. });
  58. function mediaQueryList(matches: boolean, media: string): MediaQueryList {
  59. return {
  60. matches,
  61. media,
  62. onchange: null,
  63. addListener: () => undefined,
  64. removeListener: () => undefined,
  65. addEventListener: () => undefined,
  66. removeEventListener: () => undefined,
  67. dispatchEvent: () => false
  68. };
  69. }
  70. function renderShell(path: string, { role }: { role: 'admin' | 'user' }) {
  71. const context: AppContextValue = {
  72. user: {
  73. id: 1,
  74. username: 'operator',
  75. email: 'operator@example.test',
  76. role,
  77. status: 'active'
  78. },
  79. config: {
  80. appBaseUrl: 'https://mail.example.test',
  81. mailHostname: 'mail.example.test',
  82. sendingIp: '192.0.2.10',
  83. defaultSpfMechanisms: '',
  84. dmarcPolicy: 'none',
  85. dmarcRua: '',
  86. registrationRequiresApproval: false,
  87. sendRequiresVerified: true,
  88. engagementTrackingEnabled: true,
  89. listUnsubscribeMailto: '',
  90. listUnsubscribeUrl: '',
  91. listUnsubscribePostEnabled: false,
  92. feedbackIdEnabled: false,
  93. reportAbuseTo: '',
  94. csaComplaintsTo: '',
  95. bounceAddress: '',
  96. bounceEnvelopeEnabled: false
  97. },
  98. refreshBootstrap: vi.fn(async () => undefined),
  99. logout: vi.fn(async () => undefined)
  100. };
  101. return render(
  102. <ConfigProvider theme={mailhubTheme}>
  103. <AntApp>
  104. <I18nProvider>
  105. <AppContext.Provider value={context}>
  106. <MemoryRouter initialEntries={[path]}>
  107. <Routes>
  108. <Route element={<AdminLayout />}>
  109. <Route path="*" element={<LocationProbe />} />
  110. </Route>
  111. </Routes>
  112. </MemoryRouter>
  113. </AppContext.Provider>
  114. </I18nProvider>
  115. </AntApp>
  116. </ConfigProvider>
  117. );
  118. }
  119. function LocationProbe() {
  120. const location = useLocation();
  121. return <div data-testid="location">{location.pathname}</div>;
  122. }