operations-navigation.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  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 firstRow = (await screen.findByText('first@example.test')).closest('tr');
  100. const secondRow = screen.getByText('second@example.test').closest('tr');
  101. expect(firstRow).not.toBeNull();
  102. expect(secondRow).not.toBeNull();
  103. await user.click(within(firstRow!).getByRole('button', { name: /重发验证/ }));
  104. await waitFor(() => expect((within(firstRow!).getByRole('button', { name: /重置邮件/ }) as HTMLButtonElement).disabled).toBe(true));
  105. expect((within(secondRow!).getByRole('button', { name: /重置邮件/ }) as HTMLButtonElement).disabled).toBe(false);
  106. pending.resolve({ message: 'ok' });
  107. await waitFor(() => expect((within(firstRow!).getByRole('button', { name: /重置邮件/ }) as HTMLButtonElement).disabled).toBe(false));
  108. });
  109. it('restores audit filters from URL history', async () => {
  110. const user = userEvent.setup();
  111. vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
  112. const auditLogs = vi.spyOn(api, 'adminAuditLogs').mockResolvedValue({ logs: [] });
  113. const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
  114. initialEntries: ['/admin/audit-logs?action=admin.old&actorUserId=1']
  115. });
  116. renderRouter(router);
  117. const actionInput = await screen.findByLabelText('动作');
  118. await waitFor(() => expect((actionInput as HTMLInputElement).value).toBe('admin.old'));
  119. expect(auditLogs).toHaveBeenCalledWith('action=admin.old&actorUserId=1');
  120. await user.clear(actionInput);
  121. await user.type(actionInput, 'admin.new');
  122. await user.click(screen.getByRole('button', { name: /查.*询/ }));
  123. await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('action')).toBe('admin.new'));
  124. await act(async () => {
  125. await router.navigate(-1);
  126. });
  127. await waitFor(() => expect((screen.getByLabelText('动作') as HTMLInputElement).value).toBe('admin.old'));
  128. await waitFor(() => expect(auditLogs).toHaveBeenLastCalledWith('action=admin.old&actorUserId=1'));
  129. });
  130. it('ignores a late audit response after browser back restores an earlier query', async () => {
  131. const user = userEvent.setup();
  132. const delayedNewQuery = deferred<{ logs: AuditLogEntry[] }>();
  133. const oldLog = auditLog(1, 'admin.old.result');
  134. const lateLog = auditLog(2, 'admin.new.late-result');
  135. vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
  136. const auditLogs = vi.spyOn(api, 'adminAuditLogs').mockImplementation((query) => (
  137. query === 'action=admin.new' ? delayedNewQuery.promise : Promise.resolve({ logs: [oldLog] })
  138. ));
  139. const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
  140. initialEntries: ['/admin/audit-logs?action=admin.old']
  141. });
  142. renderRouter(router);
  143. const actionInput = await screen.findByLabelText('动作');
  144. expect(await screen.findByText(oldLog.action)).toBeTruthy();
  145. await user.clear(actionInput);
  146. await user.type(actionInput, 'admin.new');
  147. await user.click(screen.getByRole('button', { name: /查.*询/ }));
  148. await waitFor(() => expect(auditLogs).toHaveBeenCalledWith('action=admin.new'));
  149. await act(async () => {
  150. await router.navigate(-1);
  151. });
  152. await waitFor(() => expect((screen.getByLabelText('动作') as HTMLInputElement).value).toBe('admin.old'));
  153. await waitFor(() => expect(auditLogs).toHaveBeenLastCalledWith('action=admin.old'));
  154. expect(screen.getByText(oldLog.action)).toBeTruthy();
  155. await act(async () => {
  156. delayedNewQuery.resolve({ logs: [lateLog] });
  157. await delayedNewQuery.promise;
  158. });
  159. await waitFor(() => expect(screen.queryByText(lateLog.action)).toBeNull());
  160. expect(screen.getByText(oldLog.action)).toBeTruthy();
  161. });
  162. });
  163. function renderRouter(router: ReturnType<typeof createMemoryRouter>) {
  164. return render(
  165. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  166. <AntApp>
  167. <I18nProvider>
  168. <AppContext.Provider value={appContext}>
  169. <RouterProvider router={router} />
  170. </AppContext.Provider>
  171. </I18nProvider>
  172. </AntApp>
  173. </ConfigProvider>
  174. );
  175. }
  176. function deferred<T>() {
  177. let resolve!: (value: T) => void;
  178. const promise = new Promise<T>((done) => {
  179. resolve = done;
  180. });
  181. return { promise, resolve };
  182. }
  183. function auditLog(id: number, action: string): AuditLogEntry {
  184. return {
  185. id,
  186. actorUserId: 1,
  187. action,
  188. targetType: 'user',
  189. targetId: '2',
  190. targetUserId: 2,
  191. summary: {},
  192. createdAt: '2026-07-14T00:00:00.000Z'
  193. };
  194. }
  195. const runtimeConfig: RuntimeConfig = {
  196. appBaseUrl: 'https://mail.example.test',
  197. mailHostname: 'mail.example.test',
  198. sendingIp: '192.0.2.10',
  199. defaultSpfMechanisms: '',
  200. dmarcPolicy: 'none',
  201. dmarcRua: '',
  202. sendRequiresVerified: true,
  203. engagementTrackingEnabled: true,
  204. listUnsubscribeMailto: '',
  205. listUnsubscribeUrl: '',
  206. listUnsubscribePostEnabled: false,
  207. feedbackIdEnabled: false,
  208. reportAbuseTo: '',
  209. csaComplaintsTo: '',
  210. bounceAddress: '',
  211. bounceEnvelopeEnabled: false
  212. };
  213. const appContext: AppContextValue = {
  214. user: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin', status: 'active' },
  215. config: runtimeConfig,
  216. refreshBootstrap: vi.fn(async () => undefined),
  217. logout: vi.fn(async () => undefined)
  218. };
  219. const mailbox: InboundMailbox = {
  220. id: 1,
  221. userId: 1,
  222. domainId: 1,
  223. domain: 'example.test',
  224. address: 'inbox@example.test',
  225. localPart: 'inbox',
  226. displayName: 'Inbox',
  227. aliases: [],
  228. forwardTo: [],
  229. keepForwarded: true,
  230. quotaMb: 1024,
  231. passwordSet: true,
  232. passwordRecoverable: false,
  233. status: 'active',
  234. messageCount: 1,
  235. unreadCount: 0,
  236. createdAt: '2026-07-14T00:00:00.000Z',
  237. updatedAt: '2026-07-14T00:00:00.000Z'
  238. };
  239. const inboundMessage: InboundMessage = {
  240. id: 9,
  241. mailboxId: 1,
  242. userId: 1,
  243. domainId: 1,
  244. domain: 'example.test',
  245. mailboxAddress: 'inbox@example.test',
  246. folder: 'INBOX',
  247. sender: 'sender@example.test',
  248. recipients: ['inbox@example.test'],
  249. subject: 'Quarterly report',
  250. messageId: '<message-9@example.test>',
  251. preview: 'Report preview',
  252. read: true,
  253. receivedAt: '2026-07-14T00:00:00.000Z',
  254. createdAt: '2026-07-14T00:00:00.000Z',
  255. updatedAt: '2026-07-14T00:00:00.000Z',
  256. textBody: 'Plain text',
  257. htmlBody: '<p>HTML</p>',
  258. rawMessage: 'Raw MIME'
  259. };
  260. const sendEvent: SendEvent = {
  261. id: 11,
  262. userId: 1,
  263. domainId: null,
  264. smtpRelayId: null,
  265. domain: 'example.test',
  266. sender: 'sender@example.test',
  267. recipients: ['recipient@example.test'],
  268. subject: 'Delivery test',
  269. status: 'delivered',
  270. detail: '250 accepted',
  271. queueId: 'QUEUE-11',
  272. messageId: 'mh-11',
  273. createdAt: '2026-07-14T00:00:00.000Z'
  274. };
  275. const resourceCounts = {
  276. domains: 0,
  277. dnsCredentials: 0,
  278. apiTokens: 0,
  279. inboundMailboxes: 0,
  280. inboundMessages: 0,
  281. sendEvents: 0,
  282. smtpCredential: 0
  283. };
  284. const adminUsers: AdminUser[] = [
  285. { id: 1, username: 'first', email: 'first@example.test', role: 'user', status: 'pending_email', resourceCounts },
  286. { id: 2, username: 'second', email: 'second@example.test', role: 'user', status: 'active', resourceCounts }
  287. ];