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' };