webhook-detail.test.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  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, useLocation } 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 { Webhook, WebhookDelivery, WebhookDeliveryFilters } from '../../src/frontend/types';
  10. import Webhooks from '../../src/pages/Webhooks';
  11. describe('Webhook delivery detail', () => {
  12. it('opens from the endpoint list and loads deliveries for only that webhook', async () => {
  13. const user = userEvent.setup();
  14. const webhookDeliveries = mockWebhookApis({ detailDeliveries: [failedDelivery] });
  15. const router = renderPage('/integrations/webhooks');
  16. await screen.findByText(webhook.name);
  17. await user.click(screen.getByRole('button', { name: '查看投递' }));
  18. await waitFor(() => expect(router.state.location.search).toBe('?webhookId=7'));
  19. await waitFor(() => expect(webhookDeliveries).toHaveBeenCalledWith({
  20. webhookId: webhook.id,
  21. status: undefined,
  22. eventType: undefined,
  23. limit: 200
  24. }));
  25. expect(await screen.findByText('Connection refused by receiver')).not.toBeNull();
  26. });
  27. it('stores delivery filters in the URL and sends them to the server', async () => {
  28. const user = userEvent.setup();
  29. const webhookDeliveries = mockWebhookApis({ detailDeliveries: [failedDelivery] });
  30. const router = renderPage('/integrations/webhooks?webhookId=7');
  31. await screen.findByRole('dialog', { name: /Production events · 投递记录/ });
  32. await selectOption(user, '按状态筛选', '已放弃');
  33. await waitFor(() => expect(router.state.location.search).toContain('deliveryStatus=dead'));
  34. await waitFor(() => expect(webhookDeliveries).toHaveBeenCalledWith({
  35. webhookId: webhook.id,
  36. status: 'dead',
  37. eventType: undefined,
  38. limit: 200
  39. }));
  40. await selectOption(user, '按事件筛选', '失败');
  41. await waitFor(() => {
  42. const params = new URLSearchParams(router.state.location.search);
  43. expect(params.get('webhookId')).toBe('7');
  44. expect(params.get('deliveryStatus')).toBe('dead');
  45. expect(params.get('deliveryEvent')).toBe('failed');
  46. });
  47. await waitFor(() => expect(webhookDeliveries).toHaveBeenCalledWith({
  48. webhookId: webhook.id,
  49. status: 'dead',
  50. eventType: 'failed',
  51. limit: 200
  52. }));
  53. });
  54. it('keeps detail failures local and retries them without reloading the endpoint list', async () => {
  55. const user = userEvent.setup();
  56. let detailAttempts = 0;
  57. const webhookDeliveries = mockWebhookApis({
  58. detailResolver: async () => {
  59. detailAttempts += 1;
  60. if (detailAttempts === 1) throw new Error('Webhook delivery service unavailable');
  61. return { deliveries: [failedDelivery] };
  62. }
  63. });
  64. renderPage('/integrations/webhooks?webhookId=7');
  65. const error = await screen.findByText('Webhook delivery service unavailable');
  66. const alert = error.closest('.ant-alert');
  67. expect(alert).not.toBeNull();
  68. await user.click(within(alert as HTMLElement).getByRole('button', { name: /刷新/ }));
  69. expect(await screen.findByText('Connection refused by receiver')).not.toBeNull();
  70. expect(detailAttempts).toBe(2);
  71. expect(webhookDeliveries.mock.calls.filter(([filters]) => filters.webhookId === webhook.id)).toHaveLength(2);
  72. expect(api.webhooks).toHaveBeenCalledTimes(1);
  73. });
  74. it('closes back to the list without leaving a detail entry behind the list', async () => {
  75. const user = userEvent.setup();
  76. mockWebhookApis({ detailDeliveries: [failedDelivery] });
  77. const router = renderPage('/integrations/webhooks', {
  78. initialEntries: ['/overview', '/integrations/webhooks'],
  79. initialIndex: 1
  80. });
  81. await screen.findByText(webhook.name);
  82. await user.click(screen.getByRole('button', { name: '查看投递' }));
  83. const drawer = await screen.findByRole('dialog', { name: /Production events · 投递记录/ });
  84. await selectOption(user, '按状态筛选', '已放弃');
  85. await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('deliveryStatus')).toBe('dead'));
  86. await act(async () => {
  87. await router.navigate(-1);
  88. });
  89. expect(router.state.location.search).toBe('?webhookId=7');
  90. await act(async () => {
  91. await router.navigate(1);
  92. });
  93. expect(new URLSearchParams(router.state.location.search).get('deliveryStatus')).toBe('dead');
  94. await user.click(within(drawer).getByRole('button', { name: /close/i }));
  95. await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe('/integrations/webhooks'));
  96. expect(screen.queryByRole('dialog', { name: /Production events · 投递记录/ })).toBeNull();
  97. await act(async () => {
  98. await router.navigate(-1);
  99. });
  100. expect(router.state.location.pathname).toBe('/overview');
  101. expect(router.state.location.search).toBe('');
  102. });
  103. it('requires explicit acknowledgement before removing a newly created secret', async () => {
  104. const user = userEvent.setup();
  105. mockWebhookApis({ webhooks: [] });
  106. const fullSecret = 'whsec_only-visible-once';
  107. vi.spyOn(api, 'createWebhook').mockResolvedValue({
  108. webhook: { ...webhook, secret: fullSecret }
  109. });
  110. renderPage('/integrations/webhooks');
  111. await screen.findByText('暂无 Webhook。创建后即可接收投递状态回调。');
  112. await user.click(screen.getAllByRole('button', { name: /新建 Webhook/ })[0]);
  113. const editor = await screen.findByRole('dialog', { name: '新建 Webhook' });
  114. await user.type(within(editor).getByLabelText('名称'), webhook.name);
  115. await user.type(within(editor).getByLabelText('回调 URL'), webhook.url);
  116. await user.click(within(editor).getByRole('button', { name: '新建 Webhook' }));
  117. const reveal = await screen.findByRole('dialog', { name: 'Webhook 密钥已创建' });
  118. expect(within(reveal).getByText(fullSecret)).not.toBeNull();
  119. expect(reveal.querySelector('.ant-modal-close')).toBeNull();
  120. await user.keyboard('{Escape}');
  121. expect(screen.getByRole('dialog', { name: 'Webhook 密钥已创建' })).not.toBeNull();
  122. await user.click(within(reveal).getByRole('button', { name: /确.*认/ }));
  123. await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Webhook 密钥已创建' })).toBeNull());
  124. expect(screen.queryByText(fullSecret)).toBeNull();
  125. });
  126. });
  127. interface MockWebhookApisOptions {
  128. webhooks?: Webhook[];
  129. detailDeliveries?: WebhookDelivery[];
  130. detailResolver?: (filters: WebhookDeliveryFilters) => Promise<{ deliveries: WebhookDelivery[] }>;
  131. }
  132. function mockWebhookApis({
  133. webhooks = [webhook],
  134. detailDeliveries = [],
  135. detailResolver
  136. }: MockWebhookApisOptions = {}) {
  137. vi.spyOn(api, 'webhooks').mockResolvedValue({ webhooks });
  138. vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
  139. vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
  140. return vi.spyOn(api, 'webhookDeliveries').mockImplementation(async (filters = {}) => {
  141. if (filters.webhookId != null) {
  142. if (detailResolver) return detailResolver(filters);
  143. return { deliveries: detailDeliveries };
  144. }
  145. return { deliveries: [] };
  146. });
  147. }
  148. function renderPage(
  149. path: string,
  150. history?: { initialEntries: string[]; initialIndex: number }
  151. ) {
  152. const router = createMemoryRouter([
  153. {
  154. path: '/integrations/webhooks',
  155. element: (
  156. <>
  157. <Webhooks />
  158. <LocationProbe />
  159. </>
  160. )
  161. },
  162. { path: '*', element: <LocationProbe /> }
  163. ], history || { initialEntries: [path], initialIndex: 0 });
  164. render(
  165. <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
  166. <AntApp>
  167. <I18nProvider>
  168. <RouterProvider router={router} />
  169. </I18nProvider>
  170. </AntApp>
  171. </ConfigProvider>
  172. );
  173. return router;
  174. }
  175. function LocationProbe() {
  176. const location = useLocation();
  177. return <div data-testid="location">{location.pathname}{location.search}</div>;
  178. }
  179. async function selectOption(user: ReturnType<typeof userEvent.setup>, label: string, option: string) {
  180. await user.click(screen.getByRole('combobox', { name: label }));
  181. await user.click(await screen.findByText(option, { selector: '.ant-select-item-option-content' }));
  182. }
  183. const webhook: Webhook = {
  184. id: 7,
  185. userId: 1,
  186. domainId: null,
  187. mailboxId: null,
  188. name: 'Production events',
  189. url: 'https://hooks.example.test/mailhub',
  190. secretPrefix: 'whsec_prod',
  191. events: ['sent', 'failed'],
  192. enabled: true,
  193. createdAt: '2026-07-14T00:00:00.000Z',
  194. updatedAt: '2026-07-14T00:00:00.000Z'
  195. };
  196. const failedDelivery: WebhookDelivery = {
  197. id: 31,
  198. webhookId: webhook.id,
  199. userId: 1,
  200. sendEventId: 101,
  201. eventType: 'failed',
  202. status: 'dead',
  203. attemptCount: 5,
  204. lastAttemptAt: '2026-07-14T00:05:00.000Z',
  205. responseStatus: 503,
  206. error: 'Connection refused by receiver',
  207. createdAt: '2026-07-14T00:00:00.000Z'
  208. };