import { App as AntApp, ConfigProvider } from 'antd'; import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AppContext } 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 { AppContextValue } from '../../src/frontend/app-context'; import type { AddDomainPayload, Domain } from '../../src/frontend/types'; import Domains, { createDomainWithSetup, readDomainPagination } from '../../src/pages/Domains'; import DomainDetail from '../../src/pages/Domains/DomainDetail'; const defaultMatchMedia = window.matchMedia; afterEach(() => { Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: defaultMatchMedia }); }); describe('Domains URL state and creation workflow', () => { it('normalizes unsupported pagination values', () => { expect(readDomainPagination(new URLSearchParams('page=-1&pageSize=999'))).toEqual({ page: 1, pageSize: 20 }); }); it('opens from create=1 and removes only the create flag when cancelled', async () => { const user = userEvent.setup(); mockListApis(makeDomains(25)); renderPage('/domains?create=1&q=keep'); expect(await screen.findByText('添加发信域名')).toBeTruthy(); await waitFor(() => { const params = currentSearchParams(); expect(params.get('page')).toBe('1'); expect(params.get('pageSize')).toBe('20'); }); const drawer = screen.getByRole('dialog', { name: '添加发信域名' }); await user.click(within(drawer).getByRole('button', { name: /取\s*消/ })); await waitFor(() => expect(currentSearchParams().has('create')).toBe(false)); const params = currentSearchParams(); expect(params.get('q')).toBe('keep'); expect(params.get('page')).toBe('1'); expect(params.get('pageSize')).toBe('20'); }); it('always applies DNS after an automatic-mode creation', async () => { const created = makeDomain(1); const applied = { ...created, status: { ...created.status, verified: true } }; const operations = { createDomain: vi.fn(async () => ({ domain: created })), applyDns: vi.fn(async () => ({ domain: applied, apply: { ok: true, results: [] } })), checkDomain: vi.fn(async () => ({ domain: created })) }; const result = await createDomainWithSetup({ ...createPayload, dnsCredentialId: 9 }, operations); expect(operations.applyDns).toHaveBeenCalledWith(created.id); expect(operations.checkDomain).not.toHaveBeenCalled(); expect(result).toMatchObject({ domain: applied, setup: 'complete', followUp: 'apply' }); }); it('checks DNS after a manual creation only when immediate checking is enabled', async () => { const created = makeDomain(1); const checked = { ...created, status: { ...created.status, checkedAt: '2026-07-14T01:00:00.000Z' } }; const operations = { createDomain: vi.fn(async () => ({ domain: created })), applyDns: vi.fn(async () => ({ domain: created, apply: { ok: true, results: [] } })), checkDomain: vi.fn(async () => ({ domain: checked })) }; const result = await createDomainWithSetup({ ...createPayload, immediateCheck: true }, operations); expect(operations.checkDomain).toHaveBeenCalledWith(created.id); expect(operations.applyDns).not.toHaveBeenCalled(); expect(result).toMatchObject({ domain: checked, setup: 'complete', followUp: 'check' }); }); it('returns partial after a follow-up failure without turning it into a second create failure', async () => { const created = makeDomain(1); const operations = { createDomain: vi.fn(async () => ({ domain: created })), applyDns: vi.fn(async () => { throw new Error('provider unavailable'); }), checkDomain: vi.fn(async () => ({ domain: created })) }; await expect(createDomainWithSetup({ ...createPayload, dnsCredentialId: 9 }, operations)).resolves.toMatchObject({ domain: created, setup: 'partial', followUp: 'apply', error: 'provider unavailable' }); expect(operations.createDomain).toHaveBeenCalledTimes(1); }); it('treats apply.ok=false as partial and keeps the persisted per-record result', async () => { const created = makeDomain(1); const applied = { ...created, status: { ...created.status, apply: { ok: false, results: [{ key: 'dmarc', type: 'TXT', host: '_dmarc.example.test', ok: false, error: 'permission denied' }] } } } satisfies Domain; const operations = { createDomain: vi.fn(async () => ({ domain: created })), applyDns: vi.fn(async () => ({ domain: applied, apply: applied.status.apply })), checkDomain: vi.fn(async () => ({ domain: created })) }; await expect(createDomainWithSetup({ ...createPayload, dnsCredentialId: 9 }, operations)).resolves.toMatchObject({ domain: applied, setup: 'partial', followUp: 'apply', error: 'permission denied' }); }); it('still rejects when domain creation itself fails', async () => { const operations = { createDomain: vi.fn(async () => { throw new Error('domain already exists'); }), applyDns: vi.fn(async () => ({ domain: makeDomain(1), apply: { ok: true, results: [] } })), checkDomain: vi.fn(async () => ({ domain: makeDomain(1) })) }; await expect(createDomainWithSetup({ ...createPayload, dnsCredentialId: 9 }, operations)).rejects.toThrow('domain already exists'); expect(operations.applyDns).not.toHaveBeenCalled(); expect(operations.checkDomain).not.toHaveBeenCalled(); }); it('keeps page and pageSize in the URL and resets page when a filter changes', async () => { const user = userEvent.setup(); mockListApis(makeDomains(25)); setViewport(390); renderPage('/domains?page=2&pageSize=10'); expect(await screen.findByText('keep-11.example.test')).toBeTruthy(); expect(screen.queryByText('keep-01.example.test')).toBeNull(); await user.type(screen.getByLabelText('搜索域名'), '01'); await waitFor(() => { const params = currentSearchParams(); expect(params.get('q')).toBe('01'); expect(params.get('page')).toBe('1'); expect(params.get('pageSize')).toBe('10'); }); expect(await screen.findByText('keep-01.example.test')).toBeTruthy(); }); it('keeps the domain list usable when supporting integrations fail', async () => { const domain = { ...makeDomain(1), dnsCredentialId: 8 }; vi.spyOn(api, 'domains').mockResolvedValue({ domains: [domain] }); vi.spyOn(api, 'dnsCredentials').mockRejectedValue(new Error('DNS provider unavailable')); vi.spyOn(api, 'smtpRelays').mockResolvedValue({ relays: [] }); vi.spyOn(api, 'events').mockRejectedValue(new Error('Activity service unavailable')); renderPage('/domains'); expect(await screen.findByText(domain.domain)).toBeTruthy(); expect(screen.getByText('部分辅助信息暂不可用')).toBeTruthy(); expect(screen.getByText(/DNS provider unavailable/)).toBeTruthy(); expect(screen.getAllByText('暂不可用').length).toBeGreaterThan(0); expect(screen.queryByText('域名列表加载失败')).toBeNull(); }); it('keeps stale domains visible but reports a primary-list refresh failure', async () => { const user = userEvent.setup(); const domain = makeDomain(1); vi.spyOn(api, 'domains') .mockResolvedValueOnce({ domains: [domain] }) .mockRejectedValueOnce(new Error('Domain service unavailable')); vi.spyOn(api, 'dnsCredentials') .mockRejectedValueOnce(new Error('DNS provider unavailable')) .mockResolvedValue({ credentials: [] }); vi.spyOn(api, 'smtpRelays').mockResolvedValue({ relays: [] }); vi.spyOn(api, 'events').mockResolvedValue({ events: [], total: 0, page: 1, pageSize: 100 }); renderPage('/domains'); expect(await screen.findByText(domain.domain)).toBeTruthy(); const auxiliaryWarning = screen.getByText('部分辅助信息暂不可用').closest('.ant-alert'); expect(auxiliaryWarning).not.toBeNull(); await user.click(within(auxiliaryWarning as HTMLElement).getByRole('button', { name: '重试' })); const primaryError = await screen.findByText('域名列表加载失败'); const primaryAlert = primaryError.closest('.ant-alert'); expect(primaryAlert).not.toBeNull(); expect(within(primaryAlert as HTMLElement).getByText('Domain service unavailable')).toBeTruthy(); expect(screen.getByText(domain.domain)).toBeTruthy(); }); it('uses the same URL page slice on mobile and desktop', async () => { const domains = makeDomains(25); const expected = domains.slice(10, 20).map((domain) => domain.domain); mockListApis(domains); setViewport(390); renderPage('/domains?page=2&pageSize=10'); await screen.findByText(expected[0]); const mobile = visibleDomainNames(domains); cleanup(); setViewport(1024); renderPage('/domains?page=2&pageSize=10'); await screen.findByText(expected[0]); const desktop = visibleDomainNames(domains); expect(mobile).toEqual(expected); expect(desktop).toEqual(expected); }); it('shows partial setup guidance and each persisted DNS apply result', async () => { const domain = { ...makeDomain(7), dnsCredentialId: 4, status: { verified: false, records: [], apply: { ok: false, results: [ { key: 'spf', type: 'TXT', host: 'example.test', ok: true, detail: 'updated' }, { key: 'dmarc', type: 'TXT', host: '_dmarc.example.test', ok: false, error: 'permission denied' } ] } } } satisfies Domain; mockListApis([domain], [{ id: 4, userId: 1, name: 'Cloudflare', provider: 'cloudflare', zoneName: 'example.test', defaultTtl: 600, createdAt: domain.createdAt, updatedAt: domain.updatedAt }]); renderPage('/domains/7/dns?setup=partial'); expect(await screen.findByText('域名已创建,但 DNS 后续操作需要处理')).toBeTruthy(); const title = await screen.findByText('DNS 写入结果'); const card = title.closest('.ant-card'); expect(card).not.toBeNull(); expect(within(card as HTMLElement).getByText('example.test')).toBeTruthy(); expect(within(card as HTMLElement).getByText('_dmarc.example.test')).toBeTruthy(); expect(within(card as HTMLElement).getByText('permission denied')).toBeTruthy(); expect(within(card as HTMLElement).getByText('成功')).toBeTruthy(); expect(within(card as HTMLElement).getByText('失败')).toBeTruthy(); }); }); function renderPage(initialEntry: string) { return render( } /> } /> ); } function LocationProbe() { const location = useLocation(); return {location.search}; } function currentSearchParams() { return new URLSearchParams(screen.getByTestId('location-search').textContent || ''); } function mockListApis(domains: Domain[], credentials: Awaited>['credentials'] = []) { vi.spyOn(api, 'domains').mockResolvedValue({ domains }); vi.spyOn(api, 'dnsCredentials').mockResolvedValue({ credentials }); vi.spyOn(api, 'smtpRelays').mockResolvedValue({ relays: [] }); vi.spyOn(api, 'events').mockResolvedValue({ events: [], total: 0, page: 1, pageSize: 100 }); } function setViewport(width: number) { Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: (query: string) => { const min = Number(query.match(/min-width:\s*(\d+)px/)?.[1] || 0); const max = Number(query.match(/max-width:\s*(\d+)px/)?.[1] || Number.POSITIVE_INFINITY); return { matches: width >= min && width <= max, media: query, onchange: null, addListener: () => undefined, removeListener: () => undefined, addEventListener: () => undefined, removeEventListener: () => undefined, dispatchEvent: () => false }; } }); } function visibleDomainNames(domains: Domain[]) { return domains.filter((domain) => screen.queryAllByText(domain.domain).length > 0).map((domain) => domain.domain); } function makeDomains(count: number) { return Array.from({ length: count }, (_, index) => makeDomain(index + 1)); } function makeDomain(id: number): Domain { const ordinal = String(id).padStart(2, '0'); return { id, userId: 1, dnsCredentialId: null, smtpRelayId: null, domain: `keep-${ordinal}.example.test`, selector: 'mh202607', verificationToken: `verification-${id}`, dkimPublic: 'public-key', senderHost: `mail-${ordinal}.example.test`, sendingIp: '192.0.2.10', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '', catchAllAddress: '', status: { verified: false, records: [] }, createdAt: '2026-07-14T00:00:00.000Z', updatedAt: '2026-07-14T00:00:00.000Z' }; } const createPayload: AddDomainPayload = { domain: 'example.test', senderHost: 'mail.example.test', sendingIp: '192.0.2.10', selector: 'mh202607', dmarcPolicy: 'none', immediateCheck: true }; const context: AppContextValue = { user: { id: 1, username: 'operator', email: 'operator@example.test', role: 'admin', status: 'active' }, config: { 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 }, refreshBootstrap: vi.fn(async () => undefined), logout: vi.fn(async () => undefined) };