direct-detail-navigation.test.tsx 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  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 { describe, expect, it, vi } from 'vitest';
  6. import { I18nProvider } from '../../src/frontend/i18n/react';
  7. import { api } from '../../src/frontend/services/api';
  8. import { mailhubTheme } from '../../src/frontend/theme';
  9. import type { SendEvent } from '../../src/frontend/types';
  10. import SendingLogs from '../../src/pages/SendingLogs';
  11. describe('Activity direct detail navigation', () => {
  12. it('decodes MIME subjects and keeps verbose SMTP responses inside the detail drawer on mobile', async () => {
  13. const user = userEvent.setup();
  14. const encodedEvent = {
  15. ...sendEvent,
  16. subject: '=?UTF-8?B?5L2g5aW977yM5LiW55WM?=',
  17. detail: '550 a deliberately verbose SMTP response that belongs in detail'
  18. };
  19. vi.spyOn(api, 'events').mockResolvedValue({ events: [encodedEvent], total: 1, page: 1, pageSize: 20 });
  20. vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
  21. vi.spyOn(api, 'event').mockResolvedValue({ event: encodedEvent });
  22. renderPage({ initialEntries: ['/activity'], initialIndex: 0 });
  23. expect(await screen.findByText('你好,世界')).not.toBeNull();
  24. expect(screen.queryByText(encodedEvent.subject)).toBeNull();
  25. expect(screen.queryByText(encodedEvent.detail)).toBeNull();
  26. await user.click(screen.getByRole('button', { name: '查看发送详情' }));
  27. expect(await screen.findByText(encodedEvent.detail)).not.toBeNull();
  28. expect(screen.getAllByText('你好,世界').length).toBeGreaterThan(0);
  29. });
  30. it('ignores a stale detail response after navigating to another event', async () => {
  31. let resolveFirst: ((value: { event: SendEvent }) => void) | undefined;
  32. const secondEvent: SendEvent = { ...sendEvent, id: 12, messageId: 'mh-12', subject: 'Current event' };
  33. vi.spyOn(api, 'events').mockResolvedValue({ events: [sendEvent, secondEvent], total: 2, page: 1, pageSize: 20 });
  34. vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
  35. vi.spyOn(api, 'event').mockImplementation((id) => {
  36. if (id === 11) return new Promise((resolve) => { resolveFirst = resolve; });
  37. return Promise.resolve({ event: secondEvent });
  38. });
  39. const router = renderPage({ initialEntries: ['/activity/11'], initialIndex: 0 });
  40. await waitFor(() => expect(api.event).toHaveBeenCalledWith(11));
  41. await act(async () => {
  42. await router.navigate('/activity/12');
  43. });
  44. const drawer = await screen.findByRole('dialog', { name: /mh-12/ });
  45. expect(await within(drawer).findByText('Current event')).not.toBeNull();
  46. await act(async () => {
  47. resolveFirst?.({ event: sendEvent });
  48. await Promise.resolve();
  49. });
  50. expect(within(drawer).queryByText('Delivery test')).toBeNull();
  51. expect(within(drawer).getByText('Current event')).not.toBeNull();
  52. });
  53. it('leaves the loading state when navigation changes to an invalid event id', async () => {
  54. vi.spyOn(api, 'events').mockResolvedValue({ events: [sendEvent], total: 1, page: 1, pageSize: 20 });
  55. vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
  56. vi.spyOn(api, 'event').mockImplementation(() => new Promise(() => undefined));
  57. const router = renderPage({ initialEntries: ['/activity/11'], initialIndex: 0 });
  58. await waitFor(() => expect(api.event).toHaveBeenCalledWith(11));
  59. await act(async () => {
  60. await router.navigate('/activity/not-a-number');
  61. });
  62. const drawer = await screen.findByRole('dialog', { name: '发送详情' });
  63. expect(await within(drawer).findByText('未找到该发送记录,或当前账户没有访问权限。')).not.toBeNull();
  64. expect(drawer.querySelector('.ant-skeleton')).toBeNull();
  65. });
  66. it('restores direct-link tabs with history and closes past them to the filtered list', async () => {
  67. const user = userEvent.setup();
  68. mockActivityApis();
  69. const router = renderPage({
  70. initialEntries: ['/overview', '/activity/11?page=2&pageSize=25&status=failed&tab=raw'],
  71. initialIndex: 1
  72. });
  73. expect(await screen.findByRole('tab', { name: '原始日志', selected: true })).not.toBeNull();
  74. await user.click(screen.getByRole('tab', { name: 'Webhook' }));
  75. await waitFor(() => expect(currentTab(router)).toBe('webhooks'));
  76. await user.click(screen.getByRole('tab', { name: '互动跟踪' }));
  77. await waitFor(() => expect(currentTab(router)).toBe('tracking'));
  78. await act(async () => {
  79. await router.navigate(-1);
  80. });
  81. expect(currentTab(router)).toBe('webhooks');
  82. expect(screen.getByRole('tab', { name: 'Webhook', selected: true })).not.toBeNull();
  83. await act(async () => {
  84. await router.navigate(-1);
  85. });
  86. expect(currentTab(router)).toBe('raw');
  87. expect(screen.getByRole('tab', { name: '原始日志', selected: true })).not.toBeNull();
  88. await act(async () => {
  89. await router.navigate(1);
  90. });
  91. expect(currentTab(router)).toBe('webhooks');
  92. await act(async () => {
  93. await router.navigate(1);
  94. });
  95. expect(currentTab(router)).toBe('tracking');
  96. await user.click(screen.getByRole('button', { name: 'Close' }));
  97. await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
  98. const listParams = new URLSearchParams(router.state.location.search);
  99. expect(listParams.get('page')).toBe('2');
  100. expect(listParams.get('pageSize')).toBe('25');
  101. expect(listParams.get('status')).toBe('failed');
  102. expect(listParams.has('tab')).toBe(false);
  103. await act(async () => {
  104. await router.navigate(-1);
  105. });
  106. expect(router.state.location.pathname).toBe('/overview');
  107. });
  108. it('replaces an untouched direct detail with its list even without prior history', async () => {
  109. const user = userEvent.setup();
  110. mockActivityApis();
  111. const router = renderPage({
  112. initialEntries: ['/activity/11?page=1&pageSize=25'],
  113. initialIndex: 0
  114. });
  115. await screen.findByRole('dialog', { name: '发送详情' });
  116. await user.click(screen.getByRole('button', { name: 'Close' }));
  117. await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
  118. await act(async () => {
  119. await router.navigate(-1);
  120. });
  121. expect(router.state.location.pathname).toBe('/activity');
  122. });
  123. });
  124. function mockActivityApis() {
  125. vi.spyOn(api, 'events').mockResolvedValue({ events: [sendEvent], total: 1, page: 2, pageSize: 25 });
  126. vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
  127. vi.spyOn(api, 'event').mockResolvedValue({ event: sendEvent });
  128. }
  129. function renderPage({ initialEntries, initialIndex }: { initialEntries: string[]; initialIndex: number }) {
  130. const router = createMemoryRouter([
  131. { path: '/activity', element: <SendingLogs /> },
  132. { path: '/activity/:eventId', element: <SendingLogs /> },
  133. { path: '*', element: <div>other</div> }
  134. ], { initialEntries, initialIndex });
  135. render(
  136. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  137. <AntApp>
  138. <I18nProvider>
  139. <RouterProvider router={router} />
  140. </I18nProvider>
  141. </AntApp>
  142. </ConfigProvider>
  143. );
  144. return router;
  145. }
  146. function currentTab(router: ReturnType<typeof createMemoryRouter>) {
  147. return new URLSearchParams(router.state.location.search).get('tab');
  148. }
  149. const sendEvent: SendEvent = {
  150. id: 11,
  151. userId: 1,
  152. domainId: null,
  153. smtpRelayId: null,
  154. domain: 'example.test',
  155. sender: 'sender@example.test',
  156. recipients: ['recipient@example.test'],
  157. subject: 'Delivery test',
  158. status: 'failed',
  159. detail: '550 mailbox unavailable',
  160. queueId: 'QUEUE-11',
  161. messageId: 'mh-11',
  162. createdAt: '2026-07-14T00:00:00.000Z'
  163. };