inbox-navigation.test.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import { App as AntApp, ConfigProvider } from 'antd';
  2. import { act, fireEvent, 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 { detailHistoryState } from '../../src/frontend/navigation-state';
  9. import { api } from '../../src/frontend/services/api';
  10. import { mailhubTheme } from '../../src/frontend/theme';
  11. import type { Domain, InboundMailbox, InboundMessage, RuntimeConfig } from '../../src/frontend/types';
  12. import Inbox from '../../src/pages/Inbox';
  13. describe('Inbox detail return path', () => {
  14. afterEach(() => vi.restoreAllMocks());
  15. it('keeps the original list history when another message is selected from an open detail', async () => {
  16. const user = userEvent.setup();
  17. const first = messageFixture(9, 1, 'Sent', 'First message');
  18. const second = messageFixture(10, 1, 'Sent', 'Second message');
  19. mockInboxApis([mailboxFixture(1)], [first, second]);
  20. vi.spyOn(api, 'inboundMessage').mockImplementation(async (id) => ({ message: id === second.id ? second : first }));
  21. const listPath = '/inbox?mailboxId=1&folder=Sent&page=2';
  22. const router = createInboxRouter(['/overview', listPath], 1);
  23. renderRouter(router);
  24. await user.click(await screen.findByRole('button', { name: 'First message · sender@example.test' }));
  25. await waitFor(() => expect(router.state.location.pathname).toBe('/inbox/messages/9'));
  26. expect(detailHistoryState(router.state.location.state)).toEqual({ listPath, depth: 1, origin: 'list' });
  27. fireEvent.click(screen.getByRole('button', { name: 'Second message · sender@example.test' }));
  28. await waitFor(() => expect(router.state.location.pathname).toBe('/inbox/messages/10'));
  29. expect(detailHistoryState(router.state.location.state)).toEqual({ listPath, depth: 2, origin: 'list' });
  30. fireEvent.click(screen.getByRole('button', { name: 'Close' }));
  31. await waitFor(() => expect(`${router.state.location.pathname}${router.state.location.search}`).toBe(listPath));
  32. await act(async () => {
  33. await router.navigate(-1);
  34. });
  35. expect(router.state.location.pathname).toBe('/overview');
  36. });
  37. it('keeps direct detail tabs navigable and closes them without reopening on Back', async () => {
  38. const user = userEvent.setup();
  39. const deepLinked = messageFixture(20, 2, 'Archive', 'Archived message');
  40. const messages = vi.fn(async () => ({ messages: [deepLinked], total: 1, page: 1, pageSize: 25 }));
  41. mockInboxApis([mailboxFixture(1), mailboxFixture(2)], [deepLinked]);
  42. vi.spyOn(api, 'inboundMessages').mockImplementation(messages);
  43. vi.spyOn(api, 'inboundMessage').mockResolvedValue({ message: deepLinked });
  44. const router = createInboxRouter(['/overview', '/inbox/messages/20'], 1);
  45. renderRouter(router);
  46. expect(await screen.findByText('Archived message')).toBeTruthy();
  47. await waitFor(() => {
  48. const params = new URLSearchParams(router.state.location.search);
  49. expect(params.get('mailboxId')).toBe('2');
  50. expect(params.get('folder')).toBe('Archive');
  51. });
  52. await waitFor(() => expect(messages).toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 2, folder: 'Archive' })));
  53. await user.click(screen.getByRole('tab', { name: 'HTML 预览' }));
  54. await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('html'));
  55. expect(detailHistoryState(router.state.location.state)).toEqual({
  56. listPath: '/inbox?mailboxId=2&folder=Archive',
  57. depth: 1,
  58. origin: 'direct'
  59. });
  60. await act(async () => {
  61. await router.navigate(-1);
  62. });
  63. expect(router.state.location.pathname).toBe('/inbox/messages/20');
  64. expect(new URLSearchParams(router.state.location.search).has('tab')).toBe(false);
  65. expect(detailHistoryState(router.state.location.state)).toBeNull();
  66. await act(async () => {
  67. await router.navigate(1);
  68. });
  69. expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('html');
  70. expect(detailHistoryState(router.state.location.state)?.origin).toBe('direct');
  71. fireEvent.click(screen.getByRole('button', { name: 'Close' }));
  72. await waitFor(() => expect(router.state.location.pathname).toBe('/inbox'));
  73. const params = new URLSearchParams(router.state.location.search);
  74. expect(params.get('mailboxId')).toBe('2');
  75. expect(params.get('folder')).toBe('Archive');
  76. await act(async () => {
  77. await router.navigate(-1);
  78. });
  79. expect(router.state.location.pathname).toBe('/overview');
  80. expect(router.state.location.pathname).not.toContain('/messages/');
  81. });
  82. it('uses a detail drawer below the 1280px product breakpoint', async () => {
  83. vi.spyOn(window, 'matchMedia').mockImplementation((query) => mediaQueryList(query === '(min-width: 992px)', query));
  84. const deepLinked = messageFixture(30, 1, 'INBOX', 'Compact detail');
  85. mockInboxApis([mailboxFixture(1)], [deepLinked]);
  86. vi.spyOn(api, 'inboundMessage').mockResolvedValue({ message: deepLinked });
  87. const router = createInboxRouter(['/inbox/messages/30'], 0);
  88. renderRouter(router);
  89. expect(await screen.findByRole('button', { name: 'Close' })).toBeTruthy();
  90. });
  91. it('renders HTML-only messages in a sandboxed preview while keeping the source tab available', async () => {
  92. const user = userEvent.setup();
  93. const htmlOnly = {
  94. ...messageFixture(31, 1, 'INBOX', 'HTML only'),
  95. textBody: '',
  96. htmlBody: '<html><body><h1>Rendered invoice</h1><script>window.parent.pwned = true</script></body></html>'
  97. };
  98. mockInboxApis([mailboxFixture(1)], [htmlOnly]);
  99. vi.spyOn(api, 'inboundMessage').mockResolvedValue({ message: htmlOnly });
  100. const router = createInboxRouter(['/inbox/messages/31?mailboxId=1&folder=INBOX'], 0);
  101. renderRouter(router);
  102. const preview = await screen.findByTitle('HTML 预览') as HTMLIFrameElement;
  103. expect(screen.getByRole('tab', { name: 'HTML 预览' }).getAttribute('aria-selected')).toBe('true');
  104. expect(preview.getAttribute('sandbox')).toBe('');
  105. expect(preview.getAttribute('referrerpolicy')).toBe('no-referrer');
  106. expect(preview.getAttribute('srcdoc')).toContain('<h1>Rendered invoice</h1>');
  107. expect(preview.getAttribute('srcdoc')).toContain("script-src 'none'");
  108. await user.click(screen.getByRole('tab', { name: 'HTML 源码' }));
  109. expect(screen.getByText(/Rendered invoice/)).toBeTruthy();
  110. await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('source'));
  111. });
  112. });
  113. describe('Inbox request and mailbox behavior', () => {
  114. afterEach(() => vi.restoreAllMocks());
  115. it('keeps the search draft synchronized with browser history', async () => {
  116. mockInboxApis([mailboxFixture(1)], []);
  117. const router = createInboxRouter([
  118. '/inbox?mailboxId=1&folder=INBOX&q=first%20query',
  119. '/inbox?mailboxId=1&folder=INBOX&q=second%20query'
  120. ], 1);
  121. renderRouter(router);
  122. const searchInput = await screen.findByPlaceholderText('搜索发件人、主题或正文预览') as HTMLInputElement;
  123. expect(searchInput.value).toBe('second query');
  124. await act(async () => {
  125. await router.navigate(-1);
  126. });
  127. await waitFor(() => expect(searchInput.value).toBe('first query'));
  128. await act(async () => {
  129. await router.navigate(1);
  130. });
  131. await waitFor(() => expect(searchInput.value).toBe('second query'));
  132. });
  133. it('does not let an older message-list response replace the current mailbox', async () => {
  134. const first = messageFixture(41, 1, 'INBOX', 'Stale mailbox message');
  135. const second = messageFixture(42, 2, 'INBOX', 'Current mailbox message');
  136. let resolveFirst: ((value: { messages: InboundMessage[]; total: number; page: number; pageSize: number }) => void) | undefined;
  137. const firstResponse = new Promise<{ messages: InboundMessage[]; total: number; page: number; pageSize: number }>((resolve) => {
  138. resolveFirst = resolve;
  139. });
  140. mockInboxApis([mailboxFixture(1), mailboxFixture(2)], []);
  141. const list = vi.spyOn(api, 'inboundMessages').mockImplementation(async (filters) => {
  142. const mailboxId = typeof filters === 'number' ? filters : filters?.mailboxId;
  143. if (mailboxId === 1) return firstResponse;
  144. return { messages: [second], total: 1, page: 1, pageSize: 25 };
  145. });
  146. const router = createInboxRouter(['/inbox?mailboxId=1&folder=INBOX'], 0);
  147. renderRouter(router);
  148. await waitFor(() => expect(list).toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 1 })));
  149. await act(async () => {
  150. await router.navigate('/inbox?mailboxId=2&folder=INBOX');
  151. });
  152. expect(await screen.findByText('Current mailbox message')).toBeTruthy();
  153. await act(async () => {
  154. resolveFirst?.({ messages: [first], total: 1, page: 1, pageSize: 25 });
  155. await Promise.resolve();
  156. });
  157. expect(screen.queryByText('Stale mailbox message')).toBeNull();
  158. expect(screen.getByText('Current mailbox message')).toBeTruthy();
  159. });
  160. it('keeps message content visible when marking it as read fails', async () => {
  161. const unread = { ...messageFixture(50, 1, 'INBOX', 'Unread detail'), read: false };
  162. mockInboxApis([mailboxFixture(1, { unreadCount: 1 })], [unread]);
  163. vi.spyOn(api, 'inboundMessage').mockResolvedValue({ message: unread });
  164. vi.spyOn(api, 'markInboundMessageRead').mockRejectedValue(new Error('mark read failed'));
  165. const router = createInboxRouter(['/inbox/messages/50?mailboxId=1&folder=INBOX'], 0);
  166. renderRouter(router);
  167. expect(await screen.findByText('Unread detail body')).toBeTruthy();
  168. expect(await screen.findByText('mark read failed')).toBeTruthy();
  169. });
  170. it('shows unavailable folder counts and retries instead of displaying fake zeroes', async () => {
  171. vi.spyOn(window, 'matchMedia').mockImplementation((query) => mediaQueryList(query === '(min-width: 992px)' || query === '(min-width: 768px)', query));
  172. mockInboxApis([mailboxFixture(1)], []);
  173. const folders = vi.spyOn(api, 'inboundFolders')
  174. .mockRejectedValueOnce(new Error('folder request failed'))
  175. .mockResolvedValue({ folders: [{ name: 'INBOX', specialUse: null, messageCount: 3, unreadCount: 2 }] });
  176. const router = createInboxRouter(['/inbox?mailboxId=1&folder=INBOX'], 0);
  177. renderRouter(router);
  178. const warning = await screen.findByText('文件夹计数暂不可用。');
  179. expect(warning).toBeTruthy();
  180. expect(screen.getAllByLabelText('计数不可用').length).toBeGreaterThan(0);
  181. const alert = warning.closest('.ant-alert');
  182. expect(alert).toBeTruthy();
  183. await userEvent.click(within(alert as HTMLElement).getByRole('button', { name: /刷\s*新/ }));
  184. await waitFor(() => expect(folders).toHaveBeenCalledTimes(2));
  185. await waitFor(() => expect(screen.queryByText('文件夹计数暂不可用。')).toBeNull());
  186. });
  187. it('selects the mailbox with the most recent activity when the URL has no mailbox', async () => {
  188. const older = mailboxFixture(1, { lastMessageAt: '2026-07-14T00:00:00.000Z' });
  189. const recent = mailboxFixture(2, { lastMessageAt: '2026-07-15T00:00:00.000Z' });
  190. mockInboxApis([older, recent], []);
  191. const list = vi.spyOn(api, 'inboundMessages');
  192. const router = createInboxRouter(['/inbox'], 0);
  193. renderRouter(router);
  194. await waitFor(() => expect(list).toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 2 })));
  195. expect(new URLSearchParams(router.state.location.search).get('mailboxId')).toBe('2');
  196. });
  197. it('uses effective mailbox access and never loads messages for view-only assignments', async () => {
  198. const viewOnly = mailboxFixture(1, {
  199. userId: 2,
  200. ownerUserId: 2,
  201. access: { type: 'assigned', permissions: { view: true, receive: false, send: false } },
  202. messageCount: null,
  203. unreadCount: null,
  204. lastMessageAt: null
  205. });
  206. const receiving = mailboxFixture(2, {
  207. userId: 2,
  208. ownerUserId: 2,
  209. access: { type: 'assigned', permissions: { view: true, receive: true, send: false } }
  210. });
  211. mockInboxApis([viewOnly, receiving], []);
  212. const loadMailboxes = vi.spyOn(api, 'inboundMailboxes');
  213. const listMessages = vi.spyOn(api, 'inboundMessages');
  214. const router = createInboxRouter(['/inbox?mailboxId=1&folder=INBOX'], 0);
  215. renderRouter(router);
  216. await waitFor(() => expect(loadMailboxes).toHaveBeenCalledWith('effective'));
  217. await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('mailboxId')).toBe('2'));
  218. await waitFor(() => expect(listMessages).toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 2 })));
  219. expect(listMessages).not.toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 1 }));
  220. });
  221. it('updates mailbox settings from the routing workspace edit drawer', async () => {
  222. const user = userEvent.setup();
  223. const mailbox = mailboxFixture(1);
  224. mockInboxApis([mailbox], [], [domainFixture(1)]);
  225. const update = vi.spyOn(api, 'updateInboundMailbox').mockResolvedValue({
  226. mailbox: { ...mailbox, displayName: 'Updated inbox', aliases: ['sales'] }
  227. });
  228. const router = createInboxRouter(['/inbox?workspace=routing'], 0);
  229. renderRouter(router);
  230. const mailboxTitle = await screen.findByText(mailbox.address);
  231. const card = mailboxTitle.closest('.ant-card');
  232. expect(card).toBeTruthy();
  233. await user.click(within(card as HTMLElement).getByRole('button', { name: /修改/ }));
  234. const displayName = await screen.findByLabelText('显示名称');
  235. await user.clear(displayName);
  236. await user.type(displayName, 'Updated inbox');
  237. await user.type(screen.getByLabelText('新密码'), 'new-password-123');
  238. await user.clear(screen.getByLabelText('别名'));
  239. await user.type(screen.getByLabelText('别名'), 'sales');
  240. await user.click(screen.getByRole('button', { name: /保\s*存/ }));
  241. await waitFor(() => expect(update).toHaveBeenCalledWith(1, expect.objectContaining({
  242. displayName: 'Updated inbox',
  243. password: 'new-password-123',
  244. aliases: 'sales',
  245. status: 'active'
  246. })));
  247. });
  248. it('groups owned and shared domains when creating a mailbox', async () => {
  249. const user = userEvent.setup();
  250. const ownedDomain = domainFixture(1);
  251. const sharedDomain = { ...domainFixture(2), userId: 2, mailboxSignupEnabled: true };
  252. mockInboxApis([], [], [ownedDomain], [ownedDomain, sharedDomain]);
  253. const router = createInboxRouter(['/inbox?workspace=routing'], 0);
  254. renderRouter(router);
  255. const createButtons = await screen.findAllByRole('button', { name: /新增收信邮箱/ });
  256. await user.click(createButtons[0]);
  257. await user.click(screen.getByRole('combobox', { name: '域名' }));
  258. expect((await screen.findAllByText('我的域名')).length).toBeGreaterThan(0);
  259. expect(screen.getAllByText('共享域名').length).toBeGreaterThan(0);
  260. expect(screen.getAllByText(`@${ownedDomain.domain}`).length).toBeGreaterThan(0);
  261. expect(screen.getAllByText(`@${sharedDomain.domain}`).length).toBeGreaterThan(0);
  262. });
  263. });
  264. function mediaQueryList(matches: boolean, media: string): MediaQueryList {
  265. return {
  266. matches,
  267. media,
  268. onchange: null,
  269. addListener: () => undefined,
  270. removeListener: () => undefined,
  271. addEventListener: () => undefined,
  272. removeEventListener: () => undefined,
  273. dispatchEvent: () => false
  274. };
  275. }
  276. function createInboxRouter(initialEntries: string[], initialIndex: number) {
  277. return createMemoryRouter([
  278. { path: '/inbox', element: <Inbox /> },
  279. { path: '/inbox/messages/:messageId', element: <Inbox /> },
  280. { path: '*', element: <div>other</div> }
  281. ], { initialEntries, initialIndex });
  282. }
  283. function renderRouter(router: ReturnType<typeof createInboxRouter>) {
  284. return render(
  285. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  286. <AntApp>
  287. <I18nProvider>
  288. <AppContext.Provider value={appContext}>
  289. <RouterProvider router={router} />
  290. </AppContext.Provider>
  291. </I18nProvider>
  292. </AntApp>
  293. </ConfigProvider>
  294. );
  295. }
  296. function mockInboxApis(
  297. mailboxes: InboundMailbox[],
  298. messages: InboundMessage[],
  299. domains: Domain[] = [],
  300. mailboxDomains: Domain[] = domains
  301. ) {
  302. vi.spyOn(api, 'domains').mockResolvedValue({ domains });
  303. vi.spyOn(api, 'inboundMailboxDomains').mockResolvedValue({
  304. domains: mailboxDomains.map(({ id, userId, domain, mailboxSignupEnabled }) => ({
  305. id,
  306. userId,
  307. domain,
  308. mailboxSignupEnabled
  309. }))
  310. });
  311. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes });
  312. vi.spyOn(api, 'inboundFolders').mockImplementation(async (mailboxId) => ({
  313. folders: [
  314. { name: 'INBOX', specialUse: null, messageCount: 0, unreadCount: 0 },
  315. { name: 'Sent', specialUse: '\\Sent', messageCount: mailboxId === 1 ? messages.length : 0, unreadCount: 0 },
  316. { name: 'Archive', specialUse: '\\Archive', messageCount: mailboxId === 2 ? messages.length : 0, unreadCount: 0 }
  317. ]
  318. }));
  319. vi.spyOn(api, 'inboundMessages').mockResolvedValue({ messages, total: messages.length, page: 1, pageSize: 25 });
  320. }
  321. function domainFixture(id: number): Domain {
  322. return {
  323. id,
  324. userId: 1,
  325. ownerUserId: 1,
  326. dnsCredentialId: null,
  327. smtpRelayId: null,
  328. domain: `example-${id}.test`,
  329. selector: 'mail',
  330. verificationToken: 'verification',
  331. dkimPublic: 'public-key',
  332. senderHost: `mail.example-${id}.test`,
  333. sendingIp: '192.0.2.10',
  334. spfExtra: '',
  335. dmarcPolicy: 'none',
  336. dmarcRua: '',
  337. catchAllAddress: '',
  338. mailboxSignupEnabled: false,
  339. status: {},
  340. createdAt: '2026-07-14T00:00:00.000Z',
  341. updatedAt: '2026-07-14T00:00:00.000Z'
  342. };
  343. }
  344. function mailboxFixture(id: number, overrides: Partial<InboundMailbox> = {}): InboundMailbox {
  345. return {
  346. id,
  347. userId: 1,
  348. domainId: id,
  349. domain: `example-${id}.test`,
  350. address: `inbox-${id}@example-${id}.test`,
  351. localPart: `inbox-${id}`,
  352. displayName: `Inbox ${id}`,
  353. aliases: [],
  354. forwardTo: [],
  355. keepForwarded: true,
  356. quotaMb: 1024,
  357. passwordSet: true,
  358. passwordRecoverable: false,
  359. status: 'active',
  360. messageCount: 2,
  361. unreadCount: 0,
  362. access: { type: 'owner', permissions: { view: true, receive: true, send: true } },
  363. createdAt: '2026-07-14T00:00:00.000Z',
  364. updatedAt: '2026-07-14T00:00:00.000Z',
  365. ...overrides
  366. };
  367. }
  368. function messageFixture(id: number, mailboxId: number, folder: string, subject: string): InboundMessage {
  369. return {
  370. id,
  371. mailboxId,
  372. userId: 1,
  373. domainId: mailboxId,
  374. domain: `example-${mailboxId}.test`,
  375. mailboxAddress: `inbox-${mailboxId}@example-${mailboxId}.test`,
  376. folder,
  377. sender: 'sender@example.test',
  378. recipients: [`inbox-${mailboxId}@example-${mailboxId}.test`],
  379. subject,
  380. messageId: `<message-${id}@example.test>`,
  381. preview: `${subject} preview`,
  382. read: true,
  383. receivedAt: '2026-07-14T00:00:00.000Z',
  384. createdAt: '2026-07-14T00:00:00.000Z',
  385. updatedAt: '2026-07-14T00:00:00.000Z',
  386. textBody: `${subject} body`,
  387. htmlBody: `<p>${subject}</p>`,
  388. rawMessage: `Subject: ${subject}`
  389. };
  390. }
  391. const runtimeConfig: RuntimeConfig = {
  392. appBaseUrl: 'https://mail.example.test',
  393. mailHostname: 'mail.example.test',
  394. sendingIp: '192.0.2.10',
  395. defaultSpfMechanisms: '',
  396. dmarcPolicy: 'none',
  397. dmarcRua: '',
  398. registrationRequiresApproval: false,
  399. sendRequiresVerified: true,
  400. engagementTrackingEnabled: true,
  401. listUnsubscribeMailto: '',
  402. listUnsubscribeUrl: '',
  403. listUnsubscribePostEnabled: false,
  404. feedbackIdEnabled: false,
  405. reportAbuseTo: '',
  406. csaComplaintsTo: '',
  407. bounceAddress: '',
  408. bounceEnvelopeEnabled: false
  409. };
  410. const appContext: AppContextValue = {
  411. user: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin', status: 'active' },
  412. config: runtimeConfig,
  413. refreshBootstrap: vi.fn(async () => undefined),
  414. logout: vi.fn(async () => undefined)
  415. };