| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488 |
- 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(<Webhooks />, '/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(<ApiTokens />, '/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(<DnsApi />, '/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(
- <DnsApi />,
- '/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(
- <DnsApi />,
- '/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(
- <DnsApi />,
- '/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(
- <SmtpCredentials />,
- '/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(
- <SmtpCredentials />,
- '/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(
- <SmtpCredentials />,
- '/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(
- <SmtpCredentials />,
- '/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(
- <ApiTokens />,
- '/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(
- <ApiTokens />,
- '/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(
- <ApiTokens />,
- '/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: <div>other</div> }
- ], history || { initialEntries: [initialEntry], initialIndex: 0 });
- render(
- <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
- <AntApp>
- <I18nProvider>
- <AppContext.Provider value={context}>
- <RouterProvider router={router} />
- </AppContext.Provider>
- </I18nProvider>
- </AntApp>
- </ConfigProvider>
- );
- 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: '',
- 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'
- };
|