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 } from 'react-router-dom';
import { afterEach, 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 { AdminUser, AuditLogEntry, InboundMailbox, InboundMessage, RuntimeConfig, SendEvent } from '../../src/frontend/types';
import AdminPage from '../../src/pages/Admin';
import Inbox from '../../src/pages/Inbox';
import SendingLogs from '../../src/pages/SendingLogs';
describe('Operational navigation state', () => {
afterEach(() => vi.restoreAllMocks());
it('keeps inbox body tabs in the URL and does not reopen a closed detail drawer on back', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
vi.spyOn(api, 'inboundFolders').mockResolvedValue({
folders: [{ name: 'INBOX', specialUse: null, messageCount: 1, unreadCount: 0 }]
});
vi.spyOn(api, 'inboundMessages').mockResolvedValue({ messages: [inboundMessage], total: 1, page: 1, pageSize: 25 });
vi.spyOn(api, 'inboundMessage').mockResolvedValue({ message: inboundMessage });
const router = createMemoryRouter([
{ path: '/inbox', element: },
{ path: '/inbox/messages/:messageId', element: },
{ path: '*', element:
other
}
], {
initialEntries: ['/overview', '/inbox?mailboxId=1&folder=INBOX'],
initialIndex: 1
});
renderRouter(router);
await user.click(await screen.findByRole('button', { name: 'Quarterly report · sender@example.test' }));
await waitFor(() => expect(router.state.location.pathname).toBe('/inbox/messages/9'));
await user.click(await screen.findByRole('tab', { name: 'HTML 源码' }));
await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('html'));
await act(async () => {
await router.navigate(-1);
});
expect(router.state.location.pathname).toBe('/inbox/messages/9');
expect(new URLSearchParams(router.state.location.search).has('tab')).toBe(false);
await act(async () => {
await router.navigate(1);
});
expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('html');
await user.click(screen.getByRole('button', { name: 'Close' }));
await waitFor(() => expect(router.state.location.pathname).toBe('/inbox'));
expect(new URLSearchParams(router.state.location.search).has('tab')).toBe(false);
await act(async () => {
await router.navigate(-1);
});
expect(router.state.location.pathname).toBe('/overview');
expect(router.state.location.pathname).not.toContain('/messages/');
});
it('does not reopen a closed activity drawer after switching its detail tab', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'events').mockResolvedValue({ events: [sendEvent], total: 1, page: 1, pageSize: 25 });
vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
vi.spyOn(api, 'event').mockResolvedValue({ event: sendEvent });
const router = createMemoryRouter([
{ path: '/activity', element: },
{ path: '/activity/:eventId', element: },
{ path: '*', element: other
}
], {
initialEntries: ['/overview', '/activity?page=1&pageSize=25'],
initialIndex: 1
});
renderRouter(router);
await user.click(await screen.findByRole('button', { name: '查看发送详情' }));
await waitFor(() => expect(router.state.location.pathname).toBe('/activity/11'));
await user.click(await screen.findByRole('tab', { name: 'Webhook' }));
await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('webhooks'));
await act(async () => {
await router.navigate(-1);
});
expect(router.state.location.pathname).toBe('/activity/11');
expect(new URLSearchParams(router.state.location.search).has('tab')).toBe(false);
await act(async () => {
await router.navigate(1);
});
expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('webhooks');
await user.click(screen.getByRole('button', { name: 'Close' }));
await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
await act(async () => {
await router.navigate(-1);
});
expect(router.state.location.pathname).toBe('/overview');
});
it('keeps other admin rows interactive while a user mutation is pending', async () => {
const user = userEvent.setup();
const pending = deferred<{ message: string }>();
vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
vi.spyOn(api, 'resendAdminVerification').mockReturnValue(pending.promise);
const router = createMemoryRouter([{ path: '/admin/:section', element: }], {
initialEntries: ['/admin/users']
});
renderRouter(router);
const firstCard = (await screen.findByText('first@example.test')).closest('.admin-user-card');
const secondCard = screen.getByText('second@example.test').closest('.admin-user-card');
expect(firstCard).not.toBeNull();
expect(secondCard).not.toBeNull();
const firstActions = within(firstCard!).getByRole('button', { name: /用户操作.*first/ });
const secondActions = within(secondCard!).getByRole('button', { name: /用户操作.*second/ });
await user.click(firstActions);
await user.click(await screen.findByRole('menuitem', { name: /重发验证/ }));
await waitFor(() => expect((firstActions as HTMLButtonElement).disabled).toBe(true));
expect((secondActions as HTMLButtonElement).disabled).toBe(false);
pending.resolve({ message: 'ok' });
await waitFor(() => expect((firstActions as HTMLButtonElement).disabled).toBe(false));
});
it('labels admin user controls and keeps risky actions in a confirmed overflow menu', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
const router = createMemoryRouter([{ path: '/admin/:section', element: }], {
initialEntries: ['/admin/users']
});
renderRouter(router);
expect(await screen.findByRole('heading', { name: '管理中心' })).toBeTruthy();
const firstCard = screen.getByText('first@example.test').closest('.admin-user-card');
expect(firstCard).not.toBeNull();
expect(within(firstCard!).getByRole('combobox', { name: /用户状态.*first/ })).toBeTruthy();
expect(within(firstCard!).getByRole('combobox', { name: /用户角色.*first/ })).toBeTruthy();
expect(within(firstCard!).queryByRole('button', { name: '重置邮件' })).toBeNull();
await user.click(within(firstCard!).getByRole('button', { name: /用户操作.*first/ }));
await user.click(await screen.findByRole('menuitem', { name: /重置邮件/ }));
const confirmation = await screen.findByRole('dialog');
expect(confirmation.textContent).toContain('确认给 first@example.test 发送密码重置邮件?');
});
it('restores audit filters from URL history', async () => {
const user = userEvent.setup();
vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
const auditLogs = vi.spyOn(api, 'adminAuditLogs').mockResolvedValue({ logs: [] });
const router = createMemoryRouter([{ path: '/admin/:section', element: }], {
initialEntries: ['/admin/audit-logs?action=admin.old&actorUserId=1']
});
renderRouter(router);
const actionInput = await screen.findByLabelText('动作');
await waitFor(() => expect((actionInput as HTMLInputElement).value).toBe('admin.old'));
expect(auditLogs).toHaveBeenCalledWith('action=admin.old&actorUserId=1');
await user.clear(actionInput);
await user.type(actionInput, 'admin.new');
await user.click(screen.getByRole('button', { name: /查.*询/ }));
await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('action')).toBe('admin.new'));
await act(async () => {
await router.navigate(-1);
});
await waitFor(() => expect((screen.getByLabelText('动作') as HTMLInputElement).value).toBe('admin.old'));
await waitFor(() => expect(auditLogs).toHaveBeenLastCalledWith('action=admin.old&actorUserId=1'));
});
it('ignores a late audit response after browser back restores an earlier query', async () => {
const user = userEvent.setup();
const delayedNewQuery = deferred<{ logs: AuditLogEntry[] }>();
const oldLog = auditLog(1, 'admin.old.result');
const lateLog = auditLog(2, 'admin.new.late-result');
vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
const auditLogs = vi.spyOn(api, 'adminAuditLogs').mockImplementation((query) => (
query === 'action=admin.new' ? delayedNewQuery.promise : Promise.resolve({ logs: [oldLog] })
));
const router = createMemoryRouter([{ path: '/admin/:section', element: }], {
initialEntries: ['/admin/audit-logs?action=admin.old']
});
renderRouter(router);
const actionInput = await screen.findByLabelText('动作');
expect(await screen.findByText(oldLog.action)).toBeTruthy();
await user.clear(actionInput);
await user.type(actionInput, 'admin.new');
await user.click(screen.getByRole('button', { name: /查.*询/ }));
await waitFor(() => expect(auditLogs).toHaveBeenCalledWith('action=admin.new'));
await act(async () => {
await router.navigate(-1);
});
await waitFor(() => expect((screen.getByLabelText('动作') as HTMLInputElement).value).toBe('admin.old'));
await waitFor(() => expect(auditLogs).toHaveBeenLastCalledWith('action=admin.old'));
expect(screen.getByText(oldLog.action)).toBeTruthy();
await act(async () => {
delayedNewQuery.resolve({ logs: [lateLog] });
await delayedNewQuery.promise;
});
await waitFor(() => expect(screen.queryByText(lateLog.action)).toBeNull());
expect(screen.getByText(oldLog.action)).toBeTruthy();
});
});
function renderRouter(router: ReturnType) {
return render(
);
}
function deferred() {
let resolve!: (value: T) => void;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}
function auditLog(id: number, action: string): AuditLogEntry {
return {
id,
actorUserId: 1,
action,
targetType: 'user',
targetId: '2',
targetUserId: 2,
summary: {},
createdAt: '2026-07-14T00:00:00.000Z'
};
}
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
};
const appContext: AppContextValue = {
user: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin', status: 'active' },
config: runtimeConfig,
refreshBootstrap: vi.fn(async () => undefined),
logout: vi.fn(async () => undefined)
};
const mailbox: InboundMailbox = {
id: 1,
userId: 1,
domainId: 1,
domain: 'example.test',
address: 'inbox@example.test',
localPart: 'inbox',
displayName: 'Inbox',
aliases: [],
forwardTo: [],
keepForwarded: true,
quotaMb: 1024,
passwordSet: true,
passwordRecoverable: false,
status: 'active',
messageCount: 1,
unreadCount: 0,
createdAt: '2026-07-14T00:00:00.000Z',
updatedAt: '2026-07-14T00:00:00.000Z'
};
const inboundMessage: InboundMessage = {
id: 9,
mailboxId: 1,
userId: 1,
domainId: 1,
domain: 'example.test',
mailboxAddress: 'inbox@example.test',
folder: 'INBOX',
sender: 'sender@example.test',
recipients: ['inbox@example.test'],
subject: 'Quarterly report',
messageId: '',
preview: 'Report preview',
read: true,
receivedAt: '2026-07-14T00:00:00.000Z',
createdAt: '2026-07-14T00:00:00.000Z',
updatedAt: '2026-07-14T00:00:00.000Z',
textBody: 'Plain text',
htmlBody: 'HTML
',
rawMessage: 'Raw MIME'
};
const sendEvent: SendEvent = {
id: 11,
userId: 1,
domainId: null,
smtpRelayId: null,
domain: 'example.test',
sender: 'sender@example.test',
recipients: ['recipient@example.test'],
subject: 'Delivery test',
status: 'delivered',
detail: '250 accepted',
queueId: 'QUEUE-11',
messageId: 'mh-11',
createdAt: '2026-07-14T00:00:00.000Z'
};
const resourceCounts = {
domains: 0,
dnsCredentials: 0,
apiTokens: 0,
inboundMailboxes: 0,
inboundMessages: 0,
sendEvents: 0,
smtpCredential: 0
};
const adminUsers: AdminUser[] = [
{ id: 1, username: 'first', email: 'first@example.test', role: 'user', status: 'pending_email', resourceCounts },
{ id: 2, username: 'second', email: 'second@example.test', role: 'user', status: 'active', resourceCounts }
];