| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341 |
- 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: <Inbox /> },
- { path: '/inbox/messages/:messageId', element: <Inbox /> },
- { path: '*', element: <div>other</div> }
- ], {
- 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: <SendingLogs /> },
- { path: '/activity/:eventId', element: <SendingLogs /> },
- { path: '*', element: <div>other</div> }
- ], {
- 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: <AdminPage /> }], {
- 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: <AdminPage /> }], {
- 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: <AdminPage /> }], {
- 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: <AdminPage /> }], {
- 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<typeof createMemoryRouter>) {
- return render(
- <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
- <AntApp>
- <I18nProvider>
- <AppContext.Provider value={appContext}>
- <RouterProvider router={router} />
- </AppContext.Provider>
- </I18nProvider>
- </AntApp>
- </ConfigProvider>
- );
- }
- function deferred<T>() {
- let resolve!: (value: T) => void;
- const promise = new Promise<T>((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: '<message-9@example.test>',
- 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: '<p>HTML</p>',
- 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 }
- ];
|