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 type { ReactElement } from 'react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { AppContext, type AppContextValue } from '../../src/frontend/app-context';
import { I18nProvider } from '../../src/frontend/i18n/react';
import { api } from '../../src/frontend/services/api';
import { mailhubTheme } from '../../src/frontend/theme';
import type {
ApiToken,
DnsCredential,
RuntimeConfig,
SmtpCredential,
SmtpRelay,
Webhook
} from '../../src/frontend/types';
import ApiTokens from '../../src/pages/ApiTokens';
import DnsApi from '../../src/pages/DnsApi';
import SmtpCredentials from '../../src/pages/SmtpCredentials';
import Webhooks from '../../src/pages/Webhooks';
describe('Integration resource resilience and deep links', () => {
it('keeps an initial Webhook endpoint failure visible and never presents it as an empty list', async () => {
const user = userEvent.setup();
const endpoints = vi.spyOn(api, 'webhooks')
.mockRejectedValueOnce(new Error('Webhook endpoint service unavailable'))
.mockResolvedValue({ webhooks: [webhook] });
vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
renderPage(, '/integrations/webhooks', '/integrations/webhooks');
const errorTitle = await screen.findByText('Webhook 端点加载失败');
const alert = errorTitle.closest('.ant-alert');
expect(alert).not.toBeNull();
expect(within(alert as HTMLElement).getByText('Webhook endpoint service unavailable')).toBeTruthy();
expect(screen.queryByText('暂无 Webhook。创建后即可接收投递状态回调。')).toBeNull();
await user.click(within(alert as HTMLElement).getByRole('button', { name: /刷新/ }));
expect(await screen.findByText(webhook.name)).toBeTruthy();
expect(endpoints).toHaveBeenCalledTimes(2);
});
it('keeps an initial API token failure visible instead of presenting a business empty state', async () => {
const user = userEvent.setup();
const tokens = vi.spyOn(api, 'apiTokens')
.mockRejectedValueOnce(new Error('API token service unavailable'))
.mockResolvedValue({ tokens: [apiToken] });
vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
renderPage(, '/integrations/api-keys', '/integrations/api-keys');
const error = await screen.findByText('API token service unavailable');
const alert = error.closest('.ant-alert');
expect(alert).not.toBeNull();
expect(document.querySelector('.empty-state')).toBeNull();
expect(screen.queryByText('创建 API 密钥后可在列表和详情中复制完整 Token,并按最小权限限制邮箱访问范围。')).toBeNull();
await user.click(within(alert as HTMLElement).getByRole('button', { name: /刷新/ }));
expect(await screen.findByText(apiToken.name)).toBeTruthy();
expect(tokens).toHaveBeenCalledTimes(2);
});
it('keeps an initial DNS credential failure visible instead of presenting a business empty state', async () => {
const user = userEvent.setup();
const credentials = vi.spyOn(api, 'dnsCredentials')
.mockRejectedValueOnce(new Error('DNS credential service unavailable'))
.mockResolvedValue({ credentials: [dnsCredential] });
renderPage(, '/integrations/dns', '/integrations/dns');
const error = await screen.findByText('DNS credential service unavailable');
const alert = error.closest('.ant-alert');
expect(alert).not.toBeNull();
expect(document.querySelector('.empty-state')).toBeNull();
await user.click(within(alert as HTMLElement).getByRole('button', { name: /刷新/ }));
expect(await screen.findByText(dnsCredential.name)).toBeTruthy();
expect(credentials).toHaveBeenCalledTimes(2);
await user.click(screen.getByRole('button', { name: /新增凭据/ }));
expect(await findDrawer('新增 DNS 集成凭据')).toBeTruthy();
});
it('restores a DNS credential detail from the URL and uses the DNS-specific deletion warning', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'dnsCredentials').mockResolvedValue({ credentials: [dnsCredential] });
const router = renderPage(
,
'/integrations/dns',
`/integrations/dns?credentialId=${dnsCredential.id}`
);
expect(await findDrawer(dnsCredential.name)).toBeTruthy();
expect(new URLSearchParams(router.state.location.search).get('credentialId')).toBe(String(dnsCredential.id));
await user.click(screen.getByRole('button', { name: `删除 ${dnsCredential.name}` }));
expect(await screen.findByText('确认删除该 DNS 集成凭据?删除后将无法再通过此 Provider 管理对应 Zone。')).toBeTruthy();
expect(screen.queryByText('确认删除该 API 密钥?')).toBeNull();
});
it('closes a list-opened DNS detail without leaving a duplicate list history entry', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'dnsCredentials').mockResolvedValue({ credentials: [dnsCredential] });
const router = renderPage(
,
'/integrations/dns',
'/integrations/dns',
appContext(runtimeConfig),
{ initialEntries: ['/overview', '/integrations/dns'], initialIndex: 1 }
);
await user.click(await screen.findByRole('row', { name: new RegExp(dnsCredential.name) }));
await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('credentialId')).toBe(String(dnsCredential.id)));
const detail = await findDrawer(dnsCredential.name);
await user.click(within(detail).getByRole('button', { name: 'Close' }));
await waitFor(() => expect(new URLSearchParams(router.state.location.search).has('credentialId')).toBe(false));
await act(async () => {
await router.navigate(-1);
});
expect(router.state.location.pathname).toBe('/overview');
});
it('replaces an untouched DNS direct link when closing it', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'dnsCredentials').mockResolvedValue({ credentials: [dnsCredential] });
const router = renderPage(
,
'/integrations/dns',
`/integrations/dns?credentialId=${dnsCredential.id}`,
appContext(runtimeConfig),
{ initialEntries: ['/overview', `/integrations/dns?credentialId=${dnsCredential.id}`], initialIndex: 1 }
);
const detail = await findDrawer(dnsCredential.name);
await user.click(within(detail).getByRole('button', { name: 'Close' }));
await waitFor(() => expect(router.state.location.search).toBe(''));
await act(async () => {
await router.navigate(-1);
});
expect(router.state.location.pathname).toBe('/overview');
});
it('keeps relay navigation usable when the SMTP credential list fails', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: smtpCredential });
vi.spyOn(api, 'smtpCredentials').mockRejectedValue(new Error('SMTP credential service unavailable'));
vi.spyOn(api, 'smtpRelays').mockResolvedValue({ relays: [smtpRelay] });
const router = renderPage(
,
'/integrations/smtp',
`/integrations/smtp?tab=relays&relayId=${smtpRelay.id}`
);
const relayDetail = await findDrawer(smtpRelay.name);
expect(within(relayDetail).getByText(`${smtpRelay.host}:${smtpRelay.port}`)).toBeTruthy();
expect(new URLSearchParams(router.state.location.search).get('relayId')).toBe(String(smtpRelay.id));
expect(screen.getAllByText(smtpRelay.name).length).toBeGreaterThan(0);
await user.click(screen.getByRole('tab', { name: 'SMTP 凭据' }));
expect(await screen.findByText('SMTP credential service unavailable')).toBeTruthy();
});
it('keeps credential navigation usable when the SMTP relay list fails', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: smtpCredential });
vi.spyOn(api, 'smtpCredentials').mockResolvedValue({ credentials: [smtpCredential] });
vi.spyOn(api, 'smtpRelays').mockRejectedValue(new Error('SMTP relay service unavailable'));
const router = renderPage(
,
'/integrations/smtp',
`/integrations/smtp?tab=credentials&credentialId=${smtpCredential.id}`
);
const credentialDetail = await findDrawer(smtpCredential.username);
expect(within(credentialDetail).getAllByText(smtpCredential.username).length).toBeGreaterThan(0);
expect(new URLSearchParams(router.state.location.search).get('credentialId')).toBe(String(smtpCredential.id));
await user.click(screen.getByRole('tab', { name: '发送中继' }));
expect(await screen.findByText('SMTP relay service unavailable')).toBeTruthy();
});
it('closes a list-opened SMTP relay without leaving the detail behind the list', async () => {
const user = userEvent.setup();
mockSmtpApis();
const listPath = '/integrations/smtp?tab=relays';
const router = renderPage(
,
'/integrations/smtp',
listPath,
appContext(runtimeConfig),
{ initialEntries: ['/overview', listPath], initialIndex: 1 }
);
await user.click(await screen.findByRole('row', { name: new RegExp(smtpRelay.name) }));
await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('relayId')).toBe(String(smtpRelay.id)));
const detail = await findDrawer(smtpRelay.name);
await user.click(within(detail).getByRole('button', { name: 'Close' }));
await waitFor(() => expect(`${router.state.location.pathname}${router.state.location.search}`).toBe(listPath));
await act(async () => {
await router.navigate(-1);
});
expect(router.state.location.pathname).toBe('/overview');
});
it('replaces an untouched SMTP credential direct link while preserving its tab', async () => {
const user = userEvent.setup();
mockSmtpApis();
const detailPath = `/integrations/smtp?tab=credentials&credentialId=${smtpCredential.id}`;
const router = renderPage(
,
'/integrations/smtp',
detailPath,
appContext(runtimeConfig),
{ initialEntries: ['/overview', detailPath], initialIndex: 1 }
);
const detail = await findDrawer(smtpCredential.username);
await user.click(within(detail).getByRole('button', { name: 'Close' }));
await waitFor(() => {
const params = new URLSearchParams(router.state.location.search);
expect(params.get('tab')).toBe('credentials');
expect(params.has('credentialId')).toBe(false);
});
await act(async () => {
await router.navigate(-1);
});
expect(router.state.location.pathname).toBe('/overview');
});
it('restores API token and messages guide deep links while upgrading a public HTTP base URL to HTTPS', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [apiToken] });
vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
const router = renderPage(
,
'/integrations/api-keys',
`/integrations/api-keys?guide=messages&tokenId=${apiToken.id}`,
appContext({ ...runtimeConfig, appBaseUrl: 'http://mail-send.ss5.xyz' })
);
expect(await findDrawer(apiToken.name)).toBeTruthy();
const guide = await findDrawer('API 使用文档');
expect(within(guide).getByText('需要 messages:read 权限,并受 Token 的邮箱访问范围及账号收信权限共同限制。列表支持分页与邮箱、文件夹、已读状态、关键词筛选。')).toBeTruthy();
expect(within(guide).getAllByText('https://mail-send.ss5.xyz/api/inbound-messages').length).toBeGreaterThan(0);
expect(guide.textContent).not.toContain('http://mail-send.ss5.xyz');
const params = new URLSearchParams(router.state.location.search);
expect(params.get('guide')).toBe('messages');
expect(params.get('tokenId')).toBe(String(apiToken.id));
await user.click(within(guide).getByRole('button', { name: 'Close' }));
await waitFor(() => expect(new URLSearchParams(router.state.location.search).has('guide')).toBe(false));
expect(new URLSearchParams(router.state.location.search).get('tokenId')).toBe(String(apiToken.id));
});
it('closes nested token and guide history without reopening either drawer on Back', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [apiToken] });
vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
const listPath = '/integrations/api-keys';
const router = renderPage(
,
'/integrations/api-keys',
listPath,
appContext(runtimeConfig),
{ initialEntries: ['/overview', listPath], initialIndex: 1 }
);
await user.click(await screen.findByRole('row', { name: new RegExp(apiToken.name) }));
const tokenDetail = await findDrawer(apiToken.name);
await user.click(within(tokenDetail).getByRole('button', { name: /API 使用文档/ }));
const guide = await findDrawer('API 使用文档');
expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('send');
await user.click(within(guide).getByText('收信 API', { selector: '.ant-collapse-header-text' }));
await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('messages'));
await act(async () => {
await router.navigate(-1);
});
expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('send');
await act(async () => {
await router.navigate(1);
});
expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('messages');
await user.click(within(guide).getByRole('button', { name: 'Close' }));
await waitFor(() => {
const params = new URLSearchParams(router.state.location.search);
expect(params.has('guide')).toBe(false);
expect(params.get('tokenId')).toBe(String(apiToken.id));
});
await user.click(within(tokenDetail).getByRole('button', { name: 'Close' }));
await waitFor(() => expect(`${router.state.location.pathname}${router.state.location.search}`).toBe(listPath));
await act(async () => {
await router.navigate(-1);
});
expect(router.state.location.pathname).toBe('/overview');
});
it('closes a changed direct-link API guide before replacing its direct token detail', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [apiToken] });
vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
const directPath = `/integrations/api-keys?guide=send&tokenId=${apiToken.id}`;
const router = renderPage(
,
'/integrations/api-keys',
directPath,
appContext(runtimeConfig),
{ initialEntries: ['/overview', directPath], initialIndex: 1 }
);
const tokenDetail = await findDrawer(apiToken.name);
const guide = await findDrawer('API 使用文档');
await user.click(within(guide).getByText('收信 API', { selector: '.ant-collapse-header-text' }));
await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('messages'));
await act(async () => {
await router.navigate(-1);
});
expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('send');
await act(async () => {
await router.navigate(1);
});
expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('messages');
await user.click(within(guide).getByRole('button', { name: 'Close' }));
await waitFor(() => {
const params = new URLSearchParams(router.state.location.search);
expect(params.has('guide')).toBe(false);
expect(params.get('tokenId')).toBe(String(apiToken.id));
});
await user.click(within(tokenDetail).getByRole('button', { name: 'Close' }));
await waitFor(() => expect(router.state.location.search).toBe(''));
await act(async () => {
await router.navigate(-1);
});
expect(router.state.location.pathname).toBe('/overview');
});
});
function renderPage(
element: ReactElement,
routePath: string,
initialEntry: string,
context: AppContextValue = appContext(runtimeConfig),
history?: { initialEntries: string[]; initialIndex: number }
) {
const router = createMemoryRouter([
{ path: routePath, element },
{ path: '*', element:
other
}
], history || { initialEntries: [initialEntry], initialIndex: 0 });
render(
);
return router;
}
async function findDrawer(title: string) {
const titleElement = await screen.findByText(title, { selector: '.ant-drawer-title' });
const drawer = titleElement.closest('[role="dialog"]');
expect(drawer).not.toBeNull();
return drawer as HTMLElement;
}
function appContext(config: RuntimeConfig): AppContextValue {
return {
user: { id: 1, username: 'operator', email: 'operator@example.test', role: 'admin', status: 'active' },
config,
refreshBootstrap: vi.fn(async () => undefined),
logout: vi.fn(async () => undefined)
};
}
function mockSmtpApis() {
vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: smtpCredential });
vi.spyOn(api, 'smtpCredentials').mockResolvedValue({ credentials: [smtpCredential] });
vi.spyOn(api, 'smtpRelays').mockResolvedValue({ relays: [smtpRelay] });
}
const runtimeConfig: RuntimeConfig = {
appBaseUrl: 'https://mail.example.test',
mailHostname: 'mail.example.test',
sendingIp: '192.0.2.10',
defaultSpfMechanisms: '',
dmarcPolicy: 'none',
dmarcRua: '',
registrationRequiresApproval: false,
sendRequiresVerified: true,
engagementTrackingEnabled: true,
listUnsubscribeMailto: '',
listUnsubscribeUrl: '',
listUnsubscribePostEnabled: false,
feedbackIdEnabled: false,
reportAbuseTo: '',
csaComplaintsTo: '',
bounceAddress: '',
bounceEnvelopeEnabled: false,
submission: {
enabled: true,
host: 'mail.example.test',
ports: [{ port: 587, protocol: 'STARTTLS' }],
username: 'primary-smtp',
passwordSet: true,
inboundEnabled: true,
tls: true,
requireTlsForAuth: true
}
};
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 dnsCredential: DnsCredential = {
id: 3,
userId: 1,
name: 'Primary Cloudflare',
provider: 'cloudflare',
zoneName: 'example.test',
defaultTtl: 600,
credentialSet: true,
createdAt: '2026-07-14T00:00:00.000Z',
updatedAt: '2026-07-14T00:00:00.000Z'
};
const smtpCredential: SmtpCredential = {
id: 5,
userId: 1,
username: 'application-smtp',
passwordSet: true,
createdAt: '2026-07-14T00:00:00.000Z',
updatedAt: '2026-07-14T00:00:00.000Z'
};
const smtpRelay: SmtpRelay = {
id: 8,
userId: 1,
name: 'SES outbound',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
secure: false,
username: 'relay-user',
passwordSet: true,
helo: 'mail.example.test',
isDefault: true,
createdAt: '2026-07-14T00:00:00.000Z',
updatedAt: '2026-07-14T00:00:00.000Z'
};
const apiToken: ApiToken = {
id: 9,
userId: 1,
name: 'Message reader',
tokenPrefix: 'mh_message',
tokenRecoverable: false,
scopes: ['messages:read'],
mailboxAccess: 'owner',
mailboxIds: [],
status: 'active',
createdAt: '2026-07-14T00:00:00.000Z'
};