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: ( <> ) }, { path: '*', element: } ], history || { initialEntries: [path], initialIndex: 0 }); render( ); return router; } function LocationProbe() { const location = useLocation(); return
{location.pathname}{location.search}
; } async function selectOption(user: ReturnType, 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' };