dashboard.test.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. import { App as AntApp, ConfigProvider } from 'antd';
  2. import { 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, beforeEach, 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 { Analytics, ApiToken, RuntimeConfig, WebhookDelivery } from '../../src/frontend/types';
  11. import Dashboard from '../../src/pages/Dashboard';
  12. vi.mock('../../src/pages/DashboardCharts', () => ({
  13. default: () => <div data-testid="dashboard-charts" />
  14. }));
  15. describe('Dashboard operational states', () => {
  16. beforeEach(() => {
  17. window.localStorage.removeItem('mailhub.locale');
  18. });
  19. afterEach(() => vi.restoreAllMocks());
  20. it('keeps dependency failures visible without reporting a missing credential or all-clear state', async () => {
  21. const user = userEvent.setup();
  22. mockCoreApis();
  23. vi.spyOn(api, 'smtpCredential')
  24. .mockRejectedValueOnce(new Error('SMTP timeout'))
  25. .mockResolvedValue({ credential: null });
  26. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [sendToken] });
  27. const deliveries = vi.spyOn(api, 'webhookDeliveries')
  28. .mockRejectedValueOnce(new Error('Webhook timeout'))
  29. .mockResolvedValue({ deliveries: [] });
  30. renderDashboard();
  31. expect(await screen.findByText('部分运维状态获取失败')).toBeTruthy();
  32. expect(screen.getByText(/SMTP 凭据.*SMTP timeout/)).toBeTruthy();
  33. expect(screen.getByText(/Webhook 失败投递.*Webhook timeout/)).toBeTruthy();
  34. expect(screen.queryByText('尚未创建可用的发送凭据')).toBeNull();
  35. expect(screen.queryByText('状态正常')).toBeNull();
  36. expect(screen.getByText('状态未知')).toBeTruthy();
  37. await user.click(screen.getByRole('button', { name: /重\s*试/ }));
  38. await waitFor(() => expect(screen.queryByText('部分运维状态获取失败')).toBeNull());
  39. expect(await screen.findByText('状态正常')).toBeTruthy();
  40. expect(deliveries).toHaveBeenCalledTimes(2);
  41. });
  42. it('accepts SMTP or a send-scoped API key and exposes both setup entrances', async () => {
  43. const user = userEvent.setup();
  44. mockCoreApis();
  45. vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: null });
  46. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
  47. vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
  48. const router = renderDashboard();
  49. expect(await screen.findByText('尚未创建可用的发送凭据')).toBeTruthy();
  50. expect(screen.getByRole('button', { name: /配置 SMTP/ })).toBeTruthy();
  51. expect(screen.getByRole('button', { name: /创建 API 密钥/ })).toBeTruthy();
  52. expect(screen.getByRole('button', { name: 'SMTP' })).toBeTruthy();
  53. expect(screen.getByRole('button', { name: 'API 密钥' })).toBeTruthy();
  54. await user.click(screen.getByRole('button', { name: /创建 API 密钥/ }));
  55. await waitFor(() => expect(router.state.location.pathname).toBe('/integrations/api-keys'));
  56. });
  57. it('does not flag credentials as missing when an active send-scoped API key exists', async () => {
  58. mockCoreApis();
  59. vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: null });
  60. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [sendToken] });
  61. vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
  62. renderDashboard();
  63. expect(await screen.findByText('状态正常')).toBeTruthy();
  64. expect(screen.queryByText('尚未创建可用的发送凭据')).toBeNull();
  65. });
  66. it('explains the deployment-level ADMIN_PASSWORD change without linking to system settings', async () => {
  67. mockHealthyDependencies();
  68. renderDashboard({ usingDefaultAdminPassword: true });
  69. const warning = (await screen.findByText('当前仍在使用默认管理员密码')).closest('.ant-alert');
  70. expect(warning).not.toBeNull();
  71. expect(within(warning as HTMLElement).getByText(/ADMIN_PASSWORD/)).toBeTruthy();
  72. expect(within(warning as HTMLElement).getByText(/重启 MailHub/)).toBeTruthy();
  73. expect(within(warning as HTMLElement).queryByRole('link')).toBeNull();
  74. });
  75. it('links a failed webhook warning to that webhook dead-delivery filter', async () => {
  76. const user = userEvent.setup();
  77. mockCoreApis();
  78. vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: { username: 'smtp-user', passwordSet: true } });
  79. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
  80. const deliveries = vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [deadDelivery] });
  81. const router = renderDashboard();
  82. await user.click(await screen.findByRole('button', { name: /检查 Webhooks/ }));
  83. await waitFor(() => expect(router.state.location.pathname).toBe('/integrations/webhooks'));
  84. const params = new URLSearchParams(router.state.location.search);
  85. expect(params.get('webhookId')).toBe('42');
  86. expect(params.get('deliveryStatus')).toBe('dead');
  87. expect(deliveries).toHaveBeenCalledWith({ status: 'dead', limit: 5 });
  88. });
  89. it('never labels analytics from the previous range as the newly selected range after a failed refresh', async () => {
  90. const user = userEvent.setup();
  91. const previousRangeAnalytics: Analytics = {
  92. ...analytics,
  93. summary: { ...analytics.summary, total: 712345 }
  94. };
  95. const analyticsRequest = vi.spyOn(api, 'analytics')
  96. .mockResolvedValueOnce({ analytics: previousRangeAnalytics })
  97. .mockRejectedValueOnce(new Error('Analytics service unavailable'));
  98. vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
  99. vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: { username: 'smtp-user', passwordSet: true } });
  100. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
  101. vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
  102. renderDashboard();
  103. expect(await screen.findByText('712345')).toBeTruthy();
  104. await user.click(screen.getByText('30 天'));
  105. expect(await screen.findByText('概览加载失败')).toBeTruthy();
  106. expect(screen.getByText(/投递统计.*Analytics service unavailable/)).toBeTruthy();
  107. expect(screen.queryByText('712345')).toBeNull();
  108. expect(analyticsRequest).toHaveBeenLastCalledWith(30);
  109. });
  110. });
  111. function renderDashboard(configPatch: Partial<RuntimeConfig> = {}) {
  112. const router = createMemoryRouter([
  113. { path: '/overview', element: <Dashboard /> },
  114. { path: '*', element: <div>Destination</div> }
  115. ], { initialEntries: ['/overview'] });
  116. const context: AppContextValue = {
  117. user: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin', status: 'active' },
  118. config: { ...runtimeConfig, ...configPatch },
  119. refreshBootstrap: vi.fn(async () => undefined),
  120. logout: vi.fn(async () => undefined)
  121. };
  122. render(
  123. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  124. <AntApp>
  125. <I18nProvider>
  126. <AppContext.Provider value={context}>
  127. <RouterProvider router={router} />
  128. </AppContext.Provider>
  129. </I18nProvider>
  130. </AntApp>
  131. </ConfigProvider>
  132. );
  133. return router;
  134. }
  135. function mockCoreApis() {
  136. vi.spyOn(api, 'analytics').mockResolvedValue({ analytics });
  137. vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
  138. }
  139. function mockHealthyDependencies() {
  140. mockCoreApis();
  141. vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: { username: 'smtp-user', passwordSet: true } });
  142. vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
  143. vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
  144. }
  145. const runtimeConfig: RuntimeConfig = {
  146. appBaseUrl: 'https://mail.example.test',
  147. mailHostname: 'mail.example.test',
  148. sendingIp: '192.0.2.10',
  149. defaultSpfMechanisms: '',
  150. dmarcPolicy: 'none',
  151. dmarcRua: '',
  152. sendRequiresVerified: true,
  153. engagementTrackingEnabled: true,
  154. listUnsubscribeMailto: '',
  155. listUnsubscribeUrl: '',
  156. listUnsubscribePostEnabled: false,
  157. feedbackIdEnabled: false,
  158. reportAbuseTo: '',
  159. csaComplaintsTo: '',
  160. bounceAddress: '',
  161. bounceEnvelopeEnabled: false
  162. };
  163. const analytics: Analytics = {
  164. windowDays: 7,
  165. summary: {
  166. total: 0,
  167. submitted: 0,
  168. queued: 0,
  169. failed: 0,
  170. accepted: 0,
  171. delivered: 0,
  172. pending: 0,
  173. deferred: 0,
  174. bounced: 0,
  175. terminalFailed: 0,
  176. recipients: 0,
  177. today: 0,
  178. last7Days: 0,
  179. successRate: 0,
  180. acceptanceRate: 0,
  181. deliveryRate: 0,
  182. failureRate: 0,
  183. domains: 0,
  184. verifiedDomains: 0
  185. },
  186. deliveryFunnel: [],
  187. engagement: {
  188. trackedDelivered: 0,
  189. totalOpens: 0,
  190. uniqueOpens: 0,
  191. proxyOpens: 0,
  192. totalClicks: 0,
  193. uniqueClicks: 0,
  194. scannerEvents: 0,
  195. openRate: 0,
  196. clickRate: 0,
  197. clickToOpenRate: 0
  198. },
  199. engagementByDay: [],
  200. topLinks: [],
  201. byDay: [],
  202. byDomain: [],
  203. byStatus: [],
  204. hourly: [],
  205. failureReasons: [],
  206. recentFailures: []
  207. };
  208. const sendToken: ApiToken = {
  209. id: 7,
  210. name: 'sender',
  211. tokenPrefix: 'mh_sender',
  212. tokenRecoverable: false,
  213. scopes: ['send'],
  214. mailboxAccess: 'owner',
  215. mailboxIds: [],
  216. status: 'active',
  217. createdAt: '2026-07-15T00:00:00.000Z'
  218. };
  219. const deadDelivery: WebhookDelivery = {
  220. id: 10,
  221. webhookId: 42,
  222. userId: 1,
  223. sendEventId: 99,
  224. eventType: 'failed',
  225. status: 'dead',
  226. attemptCount: 8,
  227. error: 'connection refused',
  228. createdAt: '2026-07-15T00:00:00.000Z'
  229. };