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: '', registrationRequiresApproval: false, 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, ownerUserId: 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, access: { type: 'owner', permissions: { view: true, receive: true, send: true } }, 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 } ];