| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264 |
- import { App as AntApp, ConfigProvider } from 'antd';
- import { render, screen, waitFor, within } from '@testing-library/react';
- import userEvent from '@testing-library/user-event';
- import { createMemoryRouter, RouterProvider } from 'react-router-dom';
- import { afterEach, beforeEach, 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 { Analytics, ApiToken, RuntimeConfig, WebhookDelivery } from '../../src/frontend/types';
- import Dashboard from '../../src/pages/Dashboard';
- vi.mock('../../src/pages/DashboardCharts', () => ({
- default: () => <div data-testid="dashboard-charts" />
- }));
- describe('Dashboard operational states', () => {
- beforeEach(() => {
- window.localStorage.removeItem('mailhub.locale');
- });
- afterEach(() => vi.restoreAllMocks());
- it('keeps dependency failures visible without reporting a missing credential or all-clear state', async () => {
- const user = userEvent.setup();
- mockCoreApis();
- vi.spyOn(api, 'smtpCredential')
- .mockRejectedValueOnce(new Error('SMTP timeout'))
- .mockResolvedValue({ credential: null });
- vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [sendToken] });
- const deliveries = vi.spyOn(api, 'webhookDeliveries')
- .mockRejectedValueOnce(new Error('Webhook timeout'))
- .mockResolvedValue({ deliveries: [] });
- renderDashboard();
- expect(await screen.findByText('部分运维状态获取失败')).toBeTruthy();
- expect(screen.getByText(/SMTP 凭据.*SMTP timeout/)).toBeTruthy();
- expect(screen.getByText(/Webhook 失败投递.*Webhook timeout/)).toBeTruthy();
- expect(screen.queryByText('尚未创建可用的发送凭据')).toBeNull();
- expect(screen.queryByText('状态正常')).toBeNull();
- expect(screen.getByText('状态未知')).toBeTruthy();
- await user.click(screen.getByRole('button', { name: /重\s*试/ }));
- await waitFor(() => expect(screen.queryByText('部分运维状态获取失败')).toBeNull());
- expect(await screen.findByText('状态正常')).toBeTruthy();
- expect(deliveries).toHaveBeenCalledTimes(2);
- });
- it('accepts SMTP or a send-scoped API key and exposes both setup entrances', async () => {
- const user = userEvent.setup();
- mockCoreApis();
- vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: null });
- vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
- vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
- const router = renderDashboard();
- expect(await screen.findByText('尚未创建可用的发送凭据')).toBeTruthy();
- expect(screen.getByRole('button', { name: /配置 SMTP/ })).toBeTruthy();
- expect(screen.getByRole('button', { name: /创建 API 密钥/ })).toBeTruthy();
- expect(screen.getByRole('button', { name: 'SMTP' })).toBeTruthy();
- expect(screen.getByRole('button', { name: 'API 密钥' })).toBeTruthy();
- await user.click(screen.getByRole('button', { name: /创建 API 密钥/ }));
- await waitFor(() => expect(router.state.location.pathname).toBe('/integrations/api-keys'));
- });
- it('does not flag credentials as missing when an active send-scoped API key exists', async () => {
- mockCoreApis();
- vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: null });
- vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [sendToken] });
- vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
- renderDashboard();
- expect(await screen.findByText('状态正常')).toBeTruthy();
- expect(screen.queryByText('尚未创建可用的发送凭据')).toBeNull();
- });
- it('explains the deployment-level ADMIN_PASSWORD change without linking to system settings', async () => {
- mockHealthyDependencies();
- renderDashboard({ usingDefaultAdminPassword: true });
- const warning = (await screen.findByText('当前仍在使用默认管理员密码')).closest('.ant-alert');
- expect(warning).not.toBeNull();
- expect(within(warning as HTMLElement).getByText(/ADMIN_PASSWORD/)).toBeTruthy();
- expect(within(warning as HTMLElement).getByText(/重启 MailHub/)).toBeTruthy();
- expect(within(warning as HTMLElement).queryByRole('link')).toBeNull();
- });
- it('links a failed webhook warning to that webhook dead-delivery filter', async () => {
- const user = userEvent.setup();
- mockCoreApis();
- vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: { username: 'smtp-user', passwordSet: true } });
- vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
- const deliveries = vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [deadDelivery] });
- const router = renderDashboard();
- await user.click(await screen.findByRole('button', { name: /检查 Webhooks/ }));
- await waitFor(() => expect(router.state.location.pathname).toBe('/integrations/webhooks'));
- const params = new URLSearchParams(router.state.location.search);
- expect(params.get('webhookId')).toBe('42');
- expect(params.get('deliveryStatus')).toBe('dead');
- expect(deliveries).toHaveBeenCalledWith({ status: 'dead', limit: 5 });
- });
- it('never labels analytics from the previous range as the newly selected range after a failed refresh', async () => {
- const user = userEvent.setup();
- const previousRangeAnalytics: Analytics = {
- ...analytics,
- summary: { ...analytics.summary, total: 712345 }
- };
- const analyticsRequest = vi.spyOn(api, 'analytics')
- .mockResolvedValueOnce({ analytics: previousRangeAnalytics })
- .mockRejectedValueOnce(new Error('Analytics service unavailable'));
- vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
- vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: { username: 'smtp-user', passwordSet: true } });
- vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
- vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
- renderDashboard();
- expect(await screen.findByText('712345')).toBeTruthy();
- await user.click(screen.getByText('30 天'));
- expect(await screen.findByText('概览加载失败')).toBeTruthy();
- expect(screen.getByText(/投递统计.*Analytics service unavailable/)).toBeTruthy();
- expect(screen.queryByText('712345')).toBeNull();
- expect(analyticsRequest).toHaveBeenLastCalledWith(30);
- });
- });
- function renderDashboard(configPatch: Partial<RuntimeConfig> = {}) {
- const router = createMemoryRouter([
- { path: '/overview', element: <Dashboard /> },
- { path: '*', element: <div>Destination</div> }
- ], { initialEntries: ['/overview'] });
- const context: AppContextValue = {
- user: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin', status: 'active' },
- config: { ...runtimeConfig, ...configPatch },
- refreshBootstrap: vi.fn(async () => undefined),
- logout: vi.fn(async () => undefined)
- };
- 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;
- }
- function mockCoreApis() {
- vi.spyOn(api, 'analytics').mockResolvedValue({ analytics });
- vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
- }
- function mockHealthyDependencies() {
- mockCoreApis();
- vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: { username: 'smtp-user', passwordSet: true } });
- vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
- vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
- }
- 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
- };
- const analytics: Analytics = {
- windowDays: 7,
- summary: {
- total: 0,
- submitted: 0,
- queued: 0,
- failed: 0,
- accepted: 0,
- delivered: 0,
- pending: 0,
- deferred: 0,
- bounced: 0,
- terminalFailed: 0,
- recipients: 0,
- today: 0,
- last7Days: 0,
- successRate: 0,
- acceptanceRate: 0,
- deliveryRate: 0,
- failureRate: 0,
- domains: 0,
- verifiedDomains: 0
- },
- deliveryFunnel: [],
- engagement: {
- trackedDelivered: 0,
- totalOpens: 0,
- uniqueOpens: 0,
- proxyOpens: 0,
- totalClicks: 0,
- uniqueClicks: 0,
- scannerEvents: 0,
- openRate: 0,
- clickRate: 0,
- clickToOpenRate: 0
- },
- engagementByDay: [],
- topLinks: [],
- byDay: [],
- byDomain: [],
- byStatus: [],
- hourly: [],
- failureReasons: [],
- recentFailures: []
- };
- const sendToken: ApiToken = {
- id: 7,
- name: 'sender',
- tokenPrefix: 'mh_sender',
- tokenRecoverable: false,
- scopes: ['send'],
- mailboxAccess: 'owner',
- mailboxIds: [],
- status: 'active',
- createdAt: '2026-07-15T00:00:00.000Z'
- };
- const deadDelivery: WebhookDelivery = {
- id: 10,
- webhookId: 42,
- userId: 1,
- sendEventId: 99,
- eventType: 'failed',
- status: 'dead',
- attemptCount: 8,
- error: 'connection refused',
- createdAt: '2026-07-15T00:00:00.000Z'
- };
|