operations-navigation.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  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 } 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 { AdminUser, AuditLogEntry, InboundMailbox, InboundMessage, RuntimeConfig, SendEvent } from '../../src/frontend/types';
  11. import AdminPage from '../../src/pages/Admin';
  12. import Inbox from '../../src/pages/Inbox';
  13. import SendingLogs from '../../src/pages/SendingLogs';
  14. describe('Operational navigation state', () => {
  15. afterEach(() => vi.restoreAllMocks());
  16. it('keeps inbox body tabs in the URL and does not reopen a closed detail drawer on back', async () => {
  17. const user = userEvent.setup();
  18. vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
  19. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
  20. vi.spyOn(api, 'inboundFolders').mockResolvedValue({
  21. folders: [{ name: 'INBOX', specialUse: null, messageCount: 1, unreadCount: 0 }]
  22. });
  23. vi.spyOn(api, 'inboundMessages').mockResolvedValue({ messages: [inboundMessage], total: 1, page: 1, pageSize: 25 });
  24. vi.spyOn(api, 'inboundMessage').mockResolvedValue({ message: inboundMessage });
  25. const router = createMemoryRouter([
  26. { path: '/inbox', element: <Inbox /> },
  27. { path: '/inbox/messages/:messageId', element: <Inbox /> },
  28. { path: '*', element: <div>other</div> }
  29. ], {
  30. initialEntries: ['/overview', '/inbox?mailboxId=1&folder=INBOX'],
  31. initialIndex: 1
  32. });
  33. renderRouter(router);
  34. await user.click(await screen.findByRole('button', { name: 'Quarterly report · sender@example.test' }));
  35. await waitFor(() => expect(router.state.location.pathname).toBe('/inbox/messages/9'));
  36. await user.click(await screen.findByRole('tab', { name: 'HTML 预览' }));
  37. await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('html'));
  38. await act(async () => {
  39. await router.navigate(-1);
  40. });
  41. expect(router.state.location.pathname).toBe('/inbox/messages/9');
  42. expect(new URLSearchParams(router.state.location.search).has('tab')).toBe(false);
  43. await act(async () => {
  44. await router.navigate(1);
  45. });
  46. expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('html');
  47. await user.click(screen.getByRole('button', { name: 'Close' }));
  48. await waitFor(() => expect(router.state.location.pathname).toBe('/inbox'));
  49. expect(new URLSearchParams(router.state.location.search).has('tab')).toBe(false);
  50. await act(async () => {
  51. await router.navigate(-1);
  52. });
  53. expect(router.state.location.pathname).toBe('/overview');
  54. expect(router.state.location.pathname).not.toContain('/messages/');
  55. });
  56. it('does not reopen a closed activity drawer after switching its detail tab', async () => {
  57. const user = userEvent.setup();
  58. vi.spyOn(api, 'events').mockResolvedValue({ events: [sendEvent], total: 1, page: 1, pageSize: 25 });
  59. vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
  60. vi.spyOn(api, 'event').mockResolvedValue({ event: sendEvent });
  61. const router = createMemoryRouter([
  62. { path: '/activity', element: <SendingLogs /> },
  63. { path: '/activity/:eventId', element: <SendingLogs /> },
  64. { path: '*', element: <div>other</div> }
  65. ], {
  66. initialEntries: ['/overview', '/activity?page=1&pageSize=25'],
  67. initialIndex: 1
  68. });
  69. renderRouter(router);
  70. await user.click(await screen.findByRole('button', { name: '查看发送详情' }));
  71. await waitFor(() => expect(router.state.location.pathname).toBe('/activity/11'));
  72. await user.click(await screen.findByRole('tab', { name: 'Webhook' }));
  73. await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('webhooks'));
  74. await act(async () => {
  75. await router.navigate(-1);
  76. });
  77. expect(router.state.location.pathname).toBe('/activity/11');
  78. expect(new URLSearchParams(router.state.location.search).has('tab')).toBe(false);
  79. await act(async () => {
  80. await router.navigate(1);
  81. });
  82. expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('webhooks');
  83. await user.click(screen.getByRole('button', { name: 'Close' }));
  84. await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
  85. await act(async () => {
  86. await router.navigate(-1);
  87. });
  88. expect(router.state.location.pathname).toBe('/overview');
  89. });
  90. it('keeps other admin rows interactive while a user mutation is pending', async () => {
  91. const user = userEvent.setup();
  92. const pending = deferred<{ message: string }>();
  93. vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
  94. vi.spyOn(api, 'resendAdminVerification').mockReturnValue(pending.promise);
  95. const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
  96. initialEntries: ['/admin/users']
  97. });
  98. renderRouter(router);
  99. const firstCard = (await screen.findByText('first@example.test')).closest('.admin-user-card');
  100. const secondCard = screen.getByText('second@example.test').closest('.admin-user-card');
  101. expect(firstCard).not.toBeNull();
  102. expect(secondCard).not.toBeNull();
  103. const firstActions = within(firstCard!).getByRole('button', { name: /用户操作.*first/ });
  104. const secondActions = within(secondCard!).getByRole('button', { name: /用户操作.*second/ });
  105. await user.click(firstActions);
  106. await user.click(await screen.findByRole('menuitem', { name: /重发验证/ }));
  107. await waitFor(() => expect((firstActions as HTMLButtonElement).disabled).toBe(true));
  108. expect((secondActions as HTMLButtonElement).disabled).toBe(false);
  109. pending.resolve({ message: 'ok' });
  110. await waitFor(() => expect((firstActions as HTMLButtonElement).disabled).toBe(false));
  111. });
  112. it('labels admin user controls and keeps risky actions in a confirmed overflow menu', async () => {
  113. const user = userEvent.setup();
  114. vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
  115. const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
  116. initialEntries: ['/admin/users']
  117. });
  118. renderRouter(router);
  119. expect(await screen.findByRole('heading', { name: '管理中心' })).toBeTruthy();
  120. const firstCard = screen.getByText('first@example.test').closest('.admin-user-card');
  121. expect(firstCard).not.toBeNull();
  122. expect(within(firstCard!).getByRole('combobox', { name: /用户状态.*first/ })).toBeTruthy();
  123. expect(within(firstCard!).getByRole('combobox', { name: /用户角色.*first/ })).toBeTruthy();
  124. expect(within(firstCard!).queryByRole('button', { name: '重置邮件' })).toBeNull();
  125. await user.click(within(firstCard!).getByRole('button', { name: /用户操作.*first/ }));
  126. await user.click(await screen.findByRole('menuitem', { name: /重置邮件/ }));
  127. const confirmation = await screen.findByRole('dialog');
  128. expect(confirmation.textContent).toContain('确认给 first@example.test 发送密码重置邮件?');
  129. });
  130. it('restores audit filters from URL history', async () => {
  131. const user = userEvent.setup();
  132. vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
  133. const auditLogs = vi.spyOn(api, 'adminAuditLogs').mockResolvedValue({ logs: [] });
  134. const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
  135. initialEntries: ['/admin/audit-logs?action=admin.old&actorUserId=1']
  136. });
  137. renderRouter(router);
  138. const actionInput = await screen.findByLabelText('动作');
  139. await waitFor(() => expect((actionInput as HTMLInputElement).value).toBe('admin.old'));
  140. expect(auditLogs).toHaveBeenCalledWith('action=admin.old&actorUserId=1');
  141. await user.clear(actionInput);
  142. await user.type(actionInput, 'admin.new');
  143. await user.click(screen.getByRole('button', { name: /查.*询/ }));
  144. await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('action')).toBe('admin.new'));
  145. await act(async () => {
  146. await router.navigate(-1);
  147. });
  148. await waitFor(() => expect((screen.getByLabelText('动作') as HTMLInputElement).value).toBe('admin.old'));
  149. await waitFor(() => expect(auditLogs).toHaveBeenLastCalledWith('action=admin.old&actorUserId=1'));
  150. });
  151. it('ignores a late audit response after browser back restores an earlier query', async () => {
  152. const user = userEvent.setup();
  153. const delayedNewQuery = deferred<{ logs: AuditLogEntry[] }>();
  154. const oldLog = auditLog(1, 'admin.old.result');
  155. const lateLog = auditLog(2, 'admin.new.late-result');
  156. vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
  157. const auditLogs = vi.spyOn(api, 'adminAuditLogs').mockImplementation((query) => (
  158. query === 'action=admin.new' ? delayedNewQuery.promise : Promise.resolve({ logs: [oldLog] })
  159. ));
  160. const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
  161. initialEntries: ['/admin/audit-logs?action=admin.old']
  162. });
  163. renderRouter(router);
  164. const actionInput = await screen.findByLabelText('动作');
  165. expect(await screen.findByText(oldLog.action)).toBeTruthy();
  166. await user.clear(actionInput);
  167. await user.type(actionInput, 'admin.new');
  168. await user.click(screen.getByRole('button', { name: /查.*询/ }));
  169. await waitFor(() => expect(auditLogs).toHaveBeenCalledWith('action=admin.new'));
  170. await act(async () => {
  171. await router.navigate(-1);
  172. });
  173. await waitFor(() => expect((screen.getByLabelText('动作') as HTMLInputElement).value).toBe('admin.old'));
  174. await waitFor(() => expect(auditLogs).toHaveBeenLastCalledWith('action=admin.old'));
  175. expect(screen.getByText(oldLog.action)).toBeTruthy();
  176. await act(async () => {
  177. delayedNewQuery.resolve({ logs: [lateLog] });
  178. await delayedNewQuery.promise;
  179. });
  180. await waitFor(() => expect(screen.queryByText(lateLog.action)).toBeNull());
  181. expect(screen.getByText(oldLog.action)).toBeTruthy();
  182. });
  183. });
  184. function renderRouter(router: ReturnType<typeof createMemoryRouter>) {
  185. return render(
  186. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  187. <AntApp>
  188. <I18nProvider>
  189. <AppContext.Provider value={appContext}>
  190. <RouterProvider router={router} />
  191. </AppContext.Provider>
  192. </I18nProvider>
  193. </AntApp>
  194. </ConfigProvider>
  195. );
  196. }
  197. function deferred<T>() {
  198. let resolve!: (value: T) => void;
  199. const promise = new Promise<T>((done) => {
  200. resolve = done;
  201. });
  202. return { promise, resolve };
  203. }
  204. function auditLog(id: number, action: string): AuditLogEntry {
  205. return {
  206. id,
  207. actorUserId: 1,
  208. action,
  209. targetType: 'user',
  210. targetId: '2',
  211. targetUserId: 2,
  212. summary: {},
  213. createdAt: '2026-07-14T00:00:00.000Z'
  214. };
  215. }
  216. const runtimeConfig: RuntimeConfig = {
  217. appBaseUrl: 'https://mail.example.test',
  218. mailHostname: 'mail.example.test',
  219. sendingIp: '192.0.2.10',
  220. defaultSpfMechanisms: '',
  221. dmarcPolicy: 'none',
  222. dmarcRua: '',
  223. registrationRequiresApproval: false,
  224. sendRequiresVerified: true,
  225. engagementTrackingEnabled: true,
  226. listUnsubscribeMailto: '',
  227. listUnsubscribeUrl: '',
  228. listUnsubscribePostEnabled: false,
  229. feedbackIdEnabled: false,
  230. reportAbuseTo: '',
  231. csaComplaintsTo: '',
  232. bounceAddress: '',
  233. bounceEnvelopeEnabled: false
  234. };
  235. const appContext: AppContextValue = {
  236. user: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin', status: 'active' },
  237. config: runtimeConfig,
  238. refreshBootstrap: vi.fn(async () => undefined),
  239. logout: vi.fn(async () => undefined)
  240. };
  241. const mailbox: InboundMailbox = {
  242. id: 1,
  243. userId: 1,
  244. ownerUserId: 1,
  245. domainId: 1,
  246. domain: 'example.test',
  247. address: 'inbox@example.test',
  248. localPart: 'inbox',
  249. displayName: 'Inbox',
  250. aliases: [],
  251. forwardTo: [],
  252. keepForwarded: true,
  253. quotaMb: 1024,
  254. passwordSet: true,
  255. passwordRecoverable: false,
  256. status: 'active',
  257. messageCount: 1,
  258. unreadCount: 0,
  259. access: { type: 'owner', permissions: { view: true, receive: true, send: true } },
  260. createdAt: '2026-07-14T00:00:00.000Z',
  261. updatedAt: '2026-07-14T00:00:00.000Z'
  262. };
  263. const inboundMessage: InboundMessage = {
  264. id: 9,
  265. mailboxId: 1,
  266. userId: 1,
  267. domainId: 1,
  268. domain: 'example.test',
  269. mailboxAddress: 'inbox@example.test',
  270. folder: 'INBOX',
  271. sender: 'sender@example.test',
  272. recipients: ['inbox@example.test'],
  273. subject: 'Quarterly report',
  274. messageId: '<message-9@example.test>',
  275. preview: 'Report preview',
  276. read: true,
  277. receivedAt: '2026-07-14T00:00:00.000Z',
  278. createdAt: '2026-07-14T00:00:00.000Z',
  279. updatedAt: '2026-07-14T00:00:00.000Z',
  280. textBody: 'Plain text',
  281. htmlBody: '<p>HTML</p>',
  282. rawMessage: 'Raw MIME'
  283. };
  284. const sendEvent: SendEvent = {
  285. id: 11,
  286. userId: 1,
  287. domainId: null,
  288. smtpRelayId: null,
  289. domain: 'example.test',
  290. sender: 'sender@example.test',
  291. recipients: ['recipient@example.test'],
  292. subject: 'Delivery test',
  293. status: 'delivered',
  294. detail: '250 accepted',
  295. queueId: 'QUEUE-11',
  296. messageId: 'mh-11',
  297. createdAt: '2026-07-14T00:00:00.000Z'
  298. };
  299. const resourceCounts = {
  300. domains: 0,
  301. dnsCredentials: 0,
  302. apiTokens: 0,
  303. inboundMailboxes: 0,
  304. inboundMessages: 0,
  305. sendEvents: 0,
  306. smtpCredential: 0
  307. };
  308. const adminUsers: AdminUser[] = [
  309. { id: 1, username: 'first', email: 'first@example.test', role: 'user', status: 'pending_email', resourceCounts },
  310. { id: 2, username: 'second', email: 'second@example.test', role: 'user', status: 'active', resourceCounts }
  311. ];