| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- import { App as AntApp, ConfigProvider } from 'antd';
- import { act, render, screen, waitFor, within } from '@testing-library/react';
- import userEvent from '@testing-library/user-event';
- import { createMemoryRouter, RouterProvider, useLocation } from 'react-router-dom';
- import { describe, expect, it, vi } from 'vitest';
- import { I18nProvider } from '../../src/frontend/i18n/react';
- import { api } from '../../src/frontend/services/api';
- import { mailhubTheme } from '../../src/frontend/theme';
- import type { Webhook, WebhookDelivery, WebhookDeliveryFilters } from '../../src/frontend/types';
- import Webhooks from '../../src/pages/Webhooks';
- describe('Webhook delivery detail', () => {
- it('opens from the endpoint list and loads deliveries for only that webhook', async () => {
- const user = userEvent.setup();
- const webhookDeliveries = mockWebhookApis({ detailDeliveries: [failedDelivery] });
- const router = renderPage('/integrations/webhooks');
- await screen.findByText(webhook.name);
- await user.click(screen.getByRole('button', { name: '查看投递' }));
- await waitFor(() => expect(router.state.location.search).toBe('?webhookId=7'));
- await waitFor(() => expect(webhookDeliveries).toHaveBeenCalledWith({
- webhookId: webhook.id,
- status: undefined,
- eventType: undefined,
- limit: 200
- }));
- expect(await screen.findByText('Connection refused by receiver')).not.toBeNull();
- });
- it('stores delivery filters in the URL and sends them to the server', async () => {
- const user = userEvent.setup();
- const webhookDeliveries = mockWebhookApis({ detailDeliveries: [failedDelivery] });
- const router = renderPage('/integrations/webhooks?webhookId=7');
- await screen.findByRole('dialog', { name: /Production events · 投递记录/ });
- await selectOption(user, '按状态筛选', '已放弃');
- await waitFor(() => expect(router.state.location.search).toContain('deliveryStatus=dead'));
- await waitFor(() => expect(webhookDeliveries).toHaveBeenCalledWith({
- webhookId: webhook.id,
- status: 'dead',
- eventType: undefined,
- limit: 200
- }));
- await selectOption(user, '按事件筛选', '失败');
- await waitFor(() => {
- const params = new URLSearchParams(router.state.location.search);
- expect(params.get('webhookId')).toBe('7');
- expect(params.get('deliveryStatus')).toBe('dead');
- expect(params.get('deliveryEvent')).toBe('failed');
- });
- await waitFor(() => expect(webhookDeliveries).toHaveBeenCalledWith({
- webhookId: webhook.id,
- status: 'dead',
- eventType: 'failed',
- limit: 200
- }));
- });
- it('keeps detail failures local and retries them without reloading the endpoint list', async () => {
- const user = userEvent.setup();
- let detailAttempts = 0;
- const webhookDeliveries = mockWebhookApis({
- detailResolver: async () => {
- detailAttempts += 1;
- if (detailAttempts === 1) throw new Error('Webhook delivery service unavailable');
- return { deliveries: [failedDelivery] };
- }
- });
- renderPage('/integrations/webhooks?webhookId=7');
- const error = await screen.findByText('Webhook delivery service unavailable');
- const alert = error.closest('.ant-alert');
- expect(alert).not.toBeNull();
- await user.click(within(alert as HTMLElement).getByRole('button', { name: /刷新/ }));
- expect(await screen.findByText('Connection refused by receiver')).not.toBeNull();
- expect(detailAttempts).toBe(2);
- expect(webhookDeliveries.mock.calls.filter(([filters]) => filters.webhookId === webhook.id)).toHaveLength(2);
- expect(api.webhooks).toHaveBeenCalledTimes(1);
- });
- it('closes back to the list without leaving a detail entry behind the list', async () => {
- const user = userEvent.setup();
- mockWebhookApis({ detailDeliveries: [failedDelivery] });
- const router = renderPage('/integrations/webhooks', {
- initialEntries: ['/overview', '/integrations/webhooks'],
- initialIndex: 1
- });
- await screen.findByText(webhook.name);
- await user.click(screen.getByRole('button', { name: '查看投递' }));
- const drawer = await screen.findByRole('dialog', { name: /Production events · 投递记录/ });
- await selectOption(user, '按状态筛选', '已放弃');
- await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('deliveryStatus')).toBe('dead'));
- await act(async () => {
- await router.navigate(-1);
- });
- expect(router.state.location.search).toBe('?webhookId=7');
- await act(async () => {
- await router.navigate(1);
- });
- expect(new URLSearchParams(router.state.location.search).get('deliveryStatus')).toBe('dead');
- await user.click(within(drawer).getByRole('button', { name: /close/i }));
- await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe('/integrations/webhooks'));
- expect(screen.queryByRole('dialog', { name: /Production events · 投递记录/ })).toBeNull();
- await act(async () => {
- await router.navigate(-1);
- });
- expect(router.state.location.pathname).toBe('/overview');
- expect(router.state.location.search).toBe('');
- });
- it('requires explicit acknowledgement before removing a newly created secret', async () => {
- const user = userEvent.setup();
- mockWebhookApis({ webhooks: [] });
- const fullSecret = 'whsec_only-visible-once';
- vi.spyOn(api, 'createWebhook').mockResolvedValue({
- webhook: { ...webhook, secret: fullSecret }
- });
- renderPage('/integrations/webhooks');
- await screen.findByText('暂无 Webhook。创建后即可接收投递状态回调。');
- await user.click(screen.getAllByRole('button', { name: /新建 Webhook/ })[0]);
- const editor = await screen.findByRole('dialog', { name: '新建 Webhook' });
- await user.type(within(editor).getByLabelText('名称'), webhook.name);
- await user.type(within(editor).getByLabelText('回调 URL'), webhook.url);
- await user.click(within(editor).getByRole('button', { name: '新建 Webhook' }));
- const reveal = await screen.findByRole('dialog', { name: 'Webhook 密钥已创建' });
- expect(within(reveal).getByText(fullSecret)).not.toBeNull();
- expect(reveal.querySelector('.ant-modal-close')).toBeNull();
- await user.keyboard('{Escape}');
- expect(screen.getByRole('dialog', { name: 'Webhook 密钥已创建' })).not.toBeNull();
- await user.click(within(reveal).getByRole('button', { name: /确.*认/ }));
- await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Webhook 密钥已创建' })).toBeNull());
- expect(screen.queryByText(fullSecret)).toBeNull();
- });
- });
- interface MockWebhookApisOptions {
- webhooks?: Webhook[];
- detailDeliveries?: WebhookDelivery[];
- detailResolver?: (filters: WebhookDeliveryFilters) => Promise<{ deliveries: WebhookDelivery[] }>;
- }
- function mockWebhookApis({
- webhooks = [webhook],
- detailDeliveries = [],
- detailResolver
- }: MockWebhookApisOptions = {}) {
- vi.spyOn(api, 'webhooks').mockResolvedValue({ webhooks });
- vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
- vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
- return vi.spyOn(api, 'webhookDeliveries').mockImplementation(async (filters = {}) => {
- if (filters.webhookId != null) {
- if (detailResolver) return detailResolver(filters);
- return { deliveries: detailDeliveries };
- }
- return { deliveries: [] };
- });
- }
- function renderPage(
- path: string,
- history?: { initialEntries: string[]; initialIndex: number }
- ) {
- const router = createMemoryRouter([
- {
- path: '/integrations/webhooks',
- element: (
- <>
- <Webhooks />
- <LocationProbe />
- </>
- )
- },
- { path: '*', element: <LocationProbe /> }
- ], history || { initialEntries: [path], initialIndex: 0 });
- render(
- <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
- <AntApp>
- <I18nProvider>
- <RouterProvider router={router} />
- </I18nProvider>
- </AntApp>
- </ConfigProvider>
- );
- return router;
- }
- function LocationProbe() {
- const location = useLocation();
- return <div data-testid="location">{location.pathname}{location.search}</div>;
- }
- async function selectOption(user: ReturnType<typeof userEvent.setup>, label: string, option: string) {
- await user.click(screen.getByRole('combobox', { name: label }));
- await user.click(await screen.findByText(option, { selector: '.ant-select-item-option-content' }));
- }
- const webhook: Webhook = {
- id: 7,
- userId: 1,
- domainId: null,
- mailboxId: null,
- name: 'Production events',
- url: 'https://hooks.example.test/mailhub',
- secretPrefix: 'whsec_prod',
- events: ['sent', 'failed'],
- enabled: true,
- createdAt: '2026-07-14T00:00:00.000Z',
- updatedAt: '2026-07-14T00:00:00.000Z'
- };
- const failedDelivery: WebhookDelivery = {
- id: 31,
- webhookId: webhook.id,
- userId: 1,
- sendEventId: 101,
- eventType: 'failed',
- status: 'dead',
- attemptCount: 5,
- lastAttemptAt: '2026-07-14T00:05:00.000Z',
- responseStatus: 503,
- error: 'Connection refused by receiver',
- createdAt: '2026-07-14T00:00:00.000Z'
- };
|