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: (
<>