|
@@ -0,0 +1,175 @@
|
|
|
|
|
+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, 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 AdminPage from '../../src/pages/Admin';
|
|
|
|
|
+import type { AdminResourceInventory, AdminUser, Domain, RuntimeConfig } from '../../src/frontend/types';
|
|
|
|
|
+
|
|
|
|
|
+describe('Admin resource migration', () => {
|
|
|
|
|
+ afterEach(() => vi.restoreAllMocks());
|
|
|
|
|
+
|
|
|
|
|
+ it('submits a domain transfer after selecting the domain and target user', async () => {
|
|
|
|
|
+ const user = userEvent.setup();
|
|
|
|
|
+ const transferDomain = vi.spyOn(api, 'transferAdminDomain').mockResolvedValue({ domain });
|
|
|
|
|
+ vi.spyOn(api, 'adminUsers').mockResolvedValue({ users });
|
|
|
|
|
+ vi.spyOn(api, 'adminResources').mockResolvedValue({ inventory });
|
|
|
|
|
+
|
|
|
|
|
+ renderAdminResourcesRoute();
|
|
|
|
|
+
|
|
|
|
|
+ const transferCard = (await screen.findByText('迁移域名')).closest('.ant-card');
|
|
|
|
|
+ expect(transferCard).not.toBeNull();
|
|
|
|
|
+ await selectOption(user, within(transferCard!).getByLabelText('域名'), 'taobiba.com · chendeben');
|
|
|
|
|
+ await selectOption(user, within(transferCard!).getByLabelText('目标用户'), 'chan (#7)');
|
|
|
|
|
+ await user.click(within(transferCard!).getByRole('button', { name: '执行迁移' }));
|
|
|
|
|
+
|
|
|
|
|
+ const dialog = await screen.findByRole('dialog');
|
|
|
|
|
+ expect(dialog.textContent).toContain('确认迁移该域名?');
|
|
|
|
|
+ await user.click(within(dialog).getByRole('button', { name: /确\s*认/ }));
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => expect(transferDomain).toHaveBeenCalledWith(12, {
|
|
|
|
|
+ targetUserId: 7,
|
|
|
|
|
+ dnsCredentialMode: 'domain_only'
|
|
|
|
|
+ }));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('submits a DNS credential transfer after confirming the action', async () => {
|
|
|
|
|
+ const user = userEvent.setup();
|
|
|
|
|
+ const transferCredential = vi.spyOn(api, 'transferAdminDnsCredential').mockResolvedValue({
|
|
|
|
|
+ credential: inventory.users[0].dnsCredentials[0]
|
|
|
|
|
+ });
|
|
|
|
|
+ vi.spyOn(api, 'adminUsers').mockResolvedValue({ users });
|
|
|
|
|
+ vi.spyOn(api, 'adminResources').mockResolvedValue({ inventory });
|
|
|
|
|
+
|
|
|
|
|
+ renderAdminResourcesRoute();
|
|
|
|
|
+
|
|
|
|
|
+ const transferCard = (await screen.findByText('迁移 DNS 凭据')).closest('.ant-card');
|
|
|
|
|
+ expect(transferCard).not.toBeNull();
|
|
|
|
|
+ await selectOption(user, within(transferCard!).getByLabelText('DNS 凭据'), 'DNSPod · taobiba.com · chendeben');
|
|
|
|
|
+ await selectOption(user, within(transferCard!).getByLabelText('目标用户'), 'chan (#7)');
|
|
|
|
|
+ await user.click(within(transferCard!).getByRole('button', { name: '执行迁移' }));
|
|
|
|
|
+
|
|
|
|
|
+ const dialog = await screen.findByRole('dialog');
|
|
|
|
|
+ expect(dialog.textContent).toContain('确认迁移该 DNS 凭据?');
|
|
|
|
|
+ await user.click(within(dialog).getByRole('button', { name: /确\s*认/ }));
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => expect(transferCredential).toHaveBeenCalledWith(8, { targetUserId: 7 }));
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+async function selectOption(user: ReturnType<typeof userEvent.setup>, input: HTMLElement, option: string) {
|
|
|
|
|
+ await user.click(input);
|
|
|
|
|
+ await user.click(await screen.findByText(option, { selector: '.ant-select-item-option-content' }));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderAdminResourcesRoute() {
|
|
|
|
|
+ const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
|
|
|
|
|
+ initialEntries: ['/admin/resources']
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return render(
|
|
|
|
|
+ <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
|
|
|
|
|
+ <AntApp>
|
|
|
|
|
+ <I18nProvider>
|
|
|
|
|
+ <AppContext.Provider value={appContext}>
|
|
|
|
|
+ <RouterProvider router={router} />
|
|
|
|
|
+ </AppContext.Provider>
|
|
|
|
|
+ </I18nProvider>
|
|
|
|
|
+ </AntApp>
|
|
|
|
|
+ </ConfigProvider>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const resourceCounts = {
|
|
|
|
|
+ domains: 1,
|
|
|
|
|
+ dnsCredentials: 1,
|
|
|
|
|
+ apiTokens: 0,
|
|
|
|
|
+ inboundMailboxes: 0,
|
|
|
|
|
+ inboundMessages: 0,
|
|
|
|
|
+ sendEvents: 0,
|
|
|
|
|
+ smtpCredential: 0
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const users: AdminUser[] = [
|
|
|
|
|
+ { id: 1, username: 'chendeben', email: 'owner@example.test', role: 'admin', status: 'active', resourceCounts },
|
|
|
|
|
+ { id: 7, username: 'chan', email: 'chan@example.test', role: 'user', status: 'active', resourceCounts }
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+const domain: Domain = {
|
|
|
|
|
+ id: 12,
|
|
|
|
|
+ userId: 1,
|
|
|
|
|
+ dnsCredentialId: 8,
|
|
|
|
|
+ smtpRelayId: null,
|
|
|
|
|
+ domain: 'taobiba.com',
|
|
|
|
|
+ selector: 'mailhub',
|
|
|
|
|
+ verificationToken: 'verify-token',
|
|
|
|
|
+ dkimPublic: 'public-key',
|
|
|
|
|
+ senderHost: 'mail.taobiba.com',
|
|
|
|
|
+ sendingIp: '192.0.2.10',
|
|
|
|
|
+ spfExtra: '',
|
|
|
|
|
+ dmarcPolicy: 'none',
|
|
|
|
|
+ dmarcRua: '',
|
|
|
|
|
+ catchAllAddress: '',
|
|
|
|
|
+ mailboxSignupEnabled: false,
|
|
|
|
|
+ status: {},
|
|
|
|
|
+ createdAt: '2026-07-19T00:00:00.000Z',
|
|
|
|
|
+ updatedAt: '2026-07-19T00:00:00.000Z'
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const inventory: AdminResourceInventory = {
|
|
|
|
|
+ users: [
|
|
|
|
|
+ {
|
|
|
|
|
+ user: users[0],
|
|
|
|
|
+ domains: [domain],
|
|
|
|
|
+ dnsCredentials: [{
|
|
|
|
|
+ id: 8,
|
|
|
|
|
+ userId: 1,
|
|
|
|
|
+ name: 'DNSPod',
|
|
|
|
|
+ provider: 'dnspod',
|
|
|
|
|
+ zoneName: 'taobiba.com',
|
|
|
|
|
+ defaultTtl: 600,
|
|
|
|
|
+ credentialSet: true,
|
|
|
|
|
+ createdAt: '2026-07-19T00:00:00.000Z',
|
|
|
|
|
+ updatedAt: '2026-07-19T00:00:00.000Z'
|
|
|
|
|
+ }],
|
|
|
|
|
+ smtpCredential: null,
|
|
|
|
|
+ apiTokens: [],
|
|
|
|
|
+ inboundMailboxes: [],
|
|
|
|
|
+ inboundMessageCount: 0,
|
|
|
|
|
+ sendEventCount: 0
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ warnings: []
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+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: users[0],
|
|
|
|
|
+ config: runtimeConfig,
|
|
|
|
|
+ refreshBootstrap: vi.fn(async () => undefined),
|
|
|
|
|
+ logout: vi.fn(async () => undefined)
|
|
|
|
|
+};
|