inbox-navigation.test.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import { App as AntApp, ConfigProvider } from 'antd';
  2. import { act, fireEvent, render, screen, waitFor } 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 { 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. });
  83. function createInboxRouter(initialEntries: string[], initialIndex: number) {
  84. return createMemoryRouter([
  85. { path: '/inbox', element: <Inbox /> },
  86. { path: '/inbox/messages/:messageId', element: <Inbox /> },
  87. { path: '*', element: <div>other</div> }
  88. ], { initialEntries, initialIndex });
  89. }
  90. function renderRouter(router: ReturnType<typeof createInboxRouter>) {
  91. return render(
  92. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  93. <AntApp>
  94. <I18nProvider>
  95. <AppContext.Provider value={appContext}>
  96. <RouterProvider router={router} />
  97. </AppContext.Provider>
  98. </I18nProvider>
  99. </AntApp>
  100. </ConfigProvider>
  101. );
  102. }
  103. function mockInboxApis(mailboxes: InboundMailbox[], messages: InboundMessage[]) {
  104. vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
  105. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes });
  106. vi.spyOn(api, 'inboundFolders').mockImplementation(async (mailboxId) => ({
  107. folders: [
  108. { name: 'INBOX', specialUse: null, messageCount: 0, unreadCount: 0 },
  109. { name: 'Sent', specialUse: '\\Sent', messageCount: mailboxId === 1 ? messages.length : 0, unreadCount: 0 },
  110. { name: 'Archive', specialUse: '\\Archive', messageCount: mailboxId === 2 ? messages.length : 0, unreadCount: 0 }
  111. ]
  112. }));
  113. vi.spyOn(api, 'inboundMessages').mockResolvedValue({ messages, total: messages.length, page: 1, pageSize: 25 });
  114. }
  115. function mailboxFixture(id: number): InboundMailbox {
  116. return {
  117. id,
  118. userId: 1,
  119. domainId: id,
  120. domain: `example-${id}.test`,
  121. address: `inbox-${id}@example-${id}.test`,
  122. localPart: `inbox-${id}`,
  123. displayName: `Inbox ${id}`,
  124. aliases: [],
  125. forwardTo: [],
  126. keepForwarded: true,
  127. quotaMb: 1024,
  128. passwordSet: true,
  129. passwordRecoverable: false,
  130. status: 'active',
  131. messageCount: 2,
  132. unreadCount: 0,
  133. createdAt: '2026-07-14T00:00:00.000Z',
  134. updatedAt: '2026-07-14T00:00:00.000Z'
  135. };
  136. }
  137. function messageFixture(id: number, mailboxId: number, folder: string, subject: string): InboundMessage {
  138. return {
  139. id,
  140. mailboxId,
  141. userId: 1,
  142. domainId: mailboxId,
  143. domain: `example-${mailboxId}.test`,
  144. mailboxAddress: `inbox-${mailboxId}@example-${mailboxId}.test`,
  145. folder,
  146. sender: 'sender@example.test',
  147. recipients: [`inbox-${mailboxId}@example-${mailboxId}.test`],
  148. subject,
  149. messageId: `<message-${id}@example.test>`,
  150. preview: `${subject} preview`,
  151. read: true,
  152. receivedAt: '2026-07-14T00:00:00.000Z',
  153. createdAt: '2026-07-14T00:00:00.000Z',
  154. updatedAt: '2026-07-14T00:00:00.000Z',
  155. textBody: `${subject} body`,
  156. htmlBody: `<p>${subject}</p>`,
  157. rawMessage: `Subject: ${subject}`
  158. };
  159. }
  160. const runtimeConfig: RuntimeConfig = {
  161. appBaseUrl: 'https://mail.example.test',
  162. mailHostname: 'mail.example.test',
  163. sendingIp: '192.0.2.10',
  164. defaultSpfMechanisms: '',
  165. dmarcPolicy: 'none',
  166. dmarcRua: '',
  167. sendRequiresVerified: true,
  168. engagementTrackingEnabled: true,
  169. listUnsubscribeMailto: '',
  170. listUnsubscribeUrl: '',
  171. listUnsubscribePostEnabled: false,
  172. feedbackIdEnabled: false,
  173. reportAbuseTo: '',
  174. csaComplaintsTo: '',
  175. bounceAddress: '',
  176. bounceEnvelopeEnabled: false
  177. };
  178. const appContext: AppContextValue = {
  179. user: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin', status: 'active' },
  180. config: runtimeConfig,
  181. refreshBootstrap: vi.fn(async () => undefined),
  182. logout: vi.fn(async () => undefined)
  183. };