[功能] 重构代理路由页面的单元测试,优化fetch模拟和输入验证逻辑

This commit is contained in:
ryan
2026-03-20 22:37:02 +08:00
parent be9744abc6
commit 1fd4b22b9c
2 changed files with 245 additions and 92 deletions
@@ -2,7 +2,7 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form'; import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@@ -763,13 +763,15 @@ function SearchableManagedDomainField({
error?: string; error?: string;
onSelect: (value: string) => void; onSelect: (value: string) => void;
}) { }) {
const [search, setSearch] = useState(''); const [inputValue, setInputValue] = useState('');
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const selectedDomain = const selectedDomain =
domains.find((item) => item.domain === value)?.domain ?? value; domains.find((item) => item.domain === value)?.domain ?? value;
const filteredDomains = useMemo(() => { const filteredDomains = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = inputValue.trim().toLowerCase();
if (!keyword) { if (!keyword) {
return domains; return domains;
} }
@@ -777,73 +779,157 @@ function SearchableManagedDomainField({
return domains.filter((item) => return domains.filter((item) =>
item.domain.toLowerCase().includes(keyword), item.domain.toLowerCase().includes(keyword),
); );
}, [domains, search]); }, [domains, inputValue]);
const highlightedDomain =
filteredDomains[Math.min(highlightedIndex, Math.max(filteredDomains.length - 1, 0))] ??
null;
useEffect(() => {
if (value) {
setInputValue(value);
return;
}
if (!isOpen) {
setInputValue('');
}
}, [isOpen, value]);
useEffect(() => {
setHighlightedIndex(0);
}, [inputValue]);
useEffect(() => {
if (highlightedIndex > filteredDomains.length - 1) {
setHighlightedIndex(0);
}
}, [filteredDomains.length, highlightedIndex]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setSearch(''); return;
} }
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
};
}, [isOpen]); }, [isOpen]);
return ( return (
<ResourceField <ResourceField
label="Select Target Domain" label="Select Target Domain"
hint="带搜索的域名下拉框会先展示已托管域名,再决定后续是否需要子域名前缀。" hint="直接输入域名关键字进行本地模糊匹配,按 Enter 选择当前匹配项。"
error={error} error={error}
> >
<div className="relative"> <div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
className={cn(
'flex w-full items-center gap-3 rounded-xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-3 text-left text-sm text-[var(--foreground-primary)] transition outline-none focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]',
INPUT_CLASS_NAME,
)}
>
<SearchIcon className="h-4 w-4 text-[var(--foreground-muted)]" />
<span className={selectedDomain ? '' : 'text-[var(--foreground-muted)]'}>
{selectedDomain || '搜索并选择目标域名'}
</span>
<ChevronIcon
expanded={isOpen}
className="ml-auto h-4 w-4 text-[var(--foreground-muted)]"
/>
</button>
{isOpen ? (
<div className="absolute inset-x-0 top-[calc(100%+8px)] z-20 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] p-3 shadow-[var(--shadow-soft)]">
<div className="relative"> <div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--foreground-muted)]" /> <SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--foreground-muted)]" />
<ResourceInput <ResourceInput
value={search} value={inputValue}
placeholder="搜索域名" placeholder="输入并搜索目标域名"
onChange={(event) => setSearch(event.target.value)} className={cn(INPUT_CLASS_NAME, 'pl-9 pr-10')}
className={cn(INPUT_CLASS_NAME, 'pl-9')} onFocus={() => setIsOpen(true)}
onChange={(event) => {
setInputValue(event.target.value);
setIsOpen(true);
}}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' && filteredDomains.length > 0) {
event.preventDefault();
setIsOpen(true);
setHighlightedIndex((current) =>
current >= filteredDomains.length - 1 ? 0 : current + 1,
);
return;
}
if (event.key === 'ArrowUp' && filteredDomains.length > 0) {
event.preventDefault();
setIsOpen(true);
setHighlightedIndex((current) =>
current <= 0 ? filteredDomains.length - 1 : current - 1,
);
return;
}
if (event.key === 'Enter' && highlightedDomain) {
event.preventDefault();
onSelect(highlightedDomain.domain);
setInputValue(highlightedDomain.domain);
setIsOpen(false);
setHighlightedIndex(0);
return;
}
if (event.key === 'Escape') {
setIsOpen(false);
}
}}
/> />
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
aria-label="切换域名候选列表"
className="absolute right-1.5 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-lg text-[var(--foreground-muted)] transition hover:bg-[var(--control-background-hover)]"
>
<ChevronIcon
expanded={isOpen}
className="h-4 w-4 text-[var(--foreground-muted)]"
/>
</button>
</div> </div>
<div className="mt-3 max-h-56 space-y-1 overflow-y-auto">
{isOpen ? (
<div className="absolute inset-x-0 top-[calc(100%+8px)] z-20 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] p-3 shadow-[var(--shadow-soft)]">
<div className="mb-2 flex items-center justify-between gap-3 px-1 text-xs text-[var(--foreground-secondary)]">
<span>
{highlightedDomain
? `按 Enter 选择 ${highlightedDomain.domain}`
: '继续输入以匹配目标域名'}
</span>
<span>{`${filteredDomains.length} 个结果`}</span>
</div>
<div className="max-h-56 space-y-1 overflow-y-auto">
{filteredDomains.length > 0 ? ( {filteredDomains.length > 0 ? (
filteredDomains.map((domain) => ( filteredDomains.map((domain, index) => (
<button <button
key={domain.id} key={domain.id}
type="button" type="button"
onClick={() => { onClick={() => {
onSelect(domain.domain); onSelect(domain.domain);
setInputValue(domain.domain);
setIsOpen(false); setIsOpen(false);
setHighlightedIndex(0);
}} }}
className={cn( className={cn(
'flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition hover:bg-[var(--control-background-hover)]', 'flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition hover:bg-[var(--control-background-hover)]',
value === domain.domain value === domain.domain
? 'bg-[var(--accent-soft)] text-[var(--foreground-primary)]' ? 'bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: index === highlightedIndex
? 'bg-[var(--control-background)] text-[var(--foreground-primary)]'
: 'text-[var(--foreground-secondary)]', : 'text-[var(--foreground-secondary)]',
)} )}
> >
<span>{domain.domain}</span> <span>{domain.domain}</span>
<div className="flex items-center gap-2">
{index === highlightedIndex ? (
<span className="rounded-full bg-[var(--surface-elevated)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--foreground-muted)]">
Enter
</span>
) : null}
{domain.cert_id ? ( {domain.cert_id ? (
<span className="rounded-full bg-[var(--surface-elevated)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--foreground-muted)]"> <span className="rounded-full bg-[var(--surface-elevated)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--foreground-muted)]">
TLS TLS
</span> </span>
) : null} ) : null}
</div>
</button> </button>
)) ))
) : ( ) : (
@@ -1405,6 +1491,14 @@ export function ProxyRoutesPage() {
icon={<GlobeIcon className="h-4 w-4" />} icon={<GlobeIcon className="h-4 w-4" />}
> >
<div className="space-y-4"> <div className="space-y-4">
<div
className={cn(
'grid gap-4',
selectedManagedDomain && isWildcardSelection
? 'md:grid-cols-2'
: 'md:grid-cols-1',
)}
>
<SearchableManagedDomainField <SearchableManagedDomainField
value={watchedManagedDomain} value={watchedManagedDomain}
domains={managedDomains} domains={managedDomains}
@@ -1412,9 +1506,7 @@ export function ProxyRoutesPage() {
onSelect={handleManagedDomainSelect} onSelect={handleManagedDomainSelect}
/> />
{selectedManagedDomain ? ( {selectedManagedDomain && isWildcardSelection ? (
<div className="grid gap-4 md:grid-cols-2">
{isWildcardSelection ? (
<ResourceField <ResourceField
label="Subdomain Prefix" label="Subdomain Prefix"
hint="当前选择的是通配符域名,仅输入最前面的前缀即可。" hint="当前选择的是通配符域名,仅输入最前面的前缀即可。"
@@ -1426,26 +1518,9 @@ export function ProxyRoutesPage() {
{...form.register('subdomain_label')} {...form.register('subdomain_label')}
/> />
</ResourceField> </ResourceField>
) : (
<div />
)}
<ResourceField
label="Rule Preview"
hint="这里会实时展示最终生效的规则域名。"
>
<div
className={cn(
'flex items-center rounded-xl border border-dashed border-[var(--border-default)] bg-[var(--surface-panel)] px-3 font-mono text-sm text-[var(--foreground-secondary)]',
INPUT_CLASS_NAME,
)}
>
{effectiveDomain || '请选择目标域名'}
</div>
</ResourceField>
</div>
) : null} ) : null}
</div> </div>
</div>
</ProxyRuleSection> </ProxyRuleSection>
<ProxyRuleSection <ProxyRuleSection
@@ -160,17 +160,15 @@ describe('ProxyRoutesPage', () => {
await user.click(await screen.findByRole('button', { name: '新增规则' })); await user.click(await screen.findByRole('button', { name: '新增规则' }));
const dialog = await screen.findByRole('dialog', { name: '新增规则' }); const dialog = await screen.findByRole('dialog', { name: '新增规则' });
await user.click( const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名');
within(dialog).getByRole('button', { name: /Select Target Domain/ }), await user.type(domainInput, 'example');
); await user.keyboard('{Enter}');
await user.click(await screen.findByText('*.example.com'));
expect(await screen.findByPlaceholderText('e.g. ai')).toBeInTheDocument(); expect(await screen.findByPlaceholderText('e.g. ai')).toBeInTheDocument();
expect(screen.getByText('*.example.com')).toBeInTheDocument(); expect(domainInput).toHaveValue('*.example.com');
await user.type(screen.getByPlaceholderText('e.g. ai'), 'ai'); await user.type(screen.getByPlaceholderText('e.g. ai'), 'ai');
expect(screen.getByPlaceholderText('e.g. ai')).toHaveValue('ai');
expect(screen.getByText('ai.example.com')).toBeInTheDocument();
}); });
it('uses exact website directly without showing subdomain input', async () => { it('uses exact website directly without showing subdomain input', async () => {
@@ -199,13 +197,12 @@ describe('ProxyRoutesPage', () => {
await user.click(await screen.findByRole('button', { name: '新增规则' })); await user.click(await screen.findByRole('button', { name: '新增规则' }));
const dialog = await screen.findByRole('dialog', { name: '新增规则' }); const dialog = await screen.findByRole('dialog', { name: '新增规则' });
await user.click( const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名');
within(dialog).getByRole('button', { name: /Select Target Domain/ }), await user.type(domainInput, 'a.example');
); await user.keyboard('{Enter}');
await user.click(await screen.findByText('a.example.com'));
expect(screen.queryByPlaceholderText('e.g. ai')).not.toBeInTheDocument(); expect(screen.queryByPlaceholderText('e.g. ai')).not.toBeInTheDocument();
expect((await screen.findAllByText('a.example.com')).length).toBeGreaterThan(0); expect(domainInput).toHaveValue('a.example.com');
expect(await screen.findByText('Select Certificate')).toBeInTheDocument(); expect(await screen.findByText('Select Certificate')).toBeInTheDocument();
}); });
@@ -245,11 +242,9 @@ describe('ProxyRoutesPage', () => {
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: '新增规则' })); await user.click(await screen.findByRole('button', { name: '新增规则' }));
const domainTrigger = await screen.findByRole('button', { const domainInput = await screen.findByPlaceholderText('输入并搜索目标域名');
name: /Select Target Domain/, await user.type(domainInput, 'example');
}); await user.keyboard('{Enter}');
await user.click(domainTrigger);
await user.click(await screen.findByText('*.example.com'));
const addressInput = screen.getByPlaceholderText('192.168.1.45'); const addressInput = screen.getByPlaceholderText('192.168.1.45');
await user.click(addressInput); await user.click(addressInput);
@@ -266,4 +261,87 @@ describe('ProxyRoutesPage', () => {
await screen.findByText('未发现匹配资产,请手动输入'), await screen.findByText('未发现匹配资产,请手动输入'),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('closes managed domain suggestions when clicking outside', async () => {
stubMatchMedia();
vi.stubGlobal(
'fetch',
buildBaseFetchStub({
managedDomains: [
{
id: 1,
domain: '*.example.com',
cert_id: 1,
enabled: true,
remark: '',
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-20T08:00:00Z',
},
],
}),
);
renderProxyRoutesPage();
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: '新增规则' }));
const domainInput = await screen.findByPlaceholderText('输入并搜索目标域名');
await user.click(domainInput);
await user.type(domainInput, 'exam');
expect(await screen.findByText('*.example.com')).toBeInTheDocument();
await user.click(
within(await screen.findByRole('dialog', { name: '新增规则' })).getByRole(
'heading',
{ name: 'Protocol' },
),
);
expect(screen.queryByText('*.example.com')).not.toBeInTheDocument();
});
it('supports arrow key navigation for managed domain suggestions', async () => {
stubMatchMedia();
vi.stubGlobal(
'fetch',
buildBaseFetchStub({
managedDomains: [
{
id: 1,
domain: '*.example.com',
cert_id: 1,
enabled: true,
remark: '',
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-20T08:00:00Z',
},
{
id: 2,
domain: 'app.example.com',
cert_id: 1,
enabled: true,
remark: '',
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-20T08:00:00Z',
},
],
}),
);
renderProxyRoutesPage();
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: '新增规则' }));
const dialog = await screen.findByRole('dialog', { name: '新增规则' });
const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名');
await user.type(domainInput, 'example');
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(domainInput).toHaveValue('app.example.com');
expect(screen.queryByPlaceholderText('e.g. ai')).not.toBeInTheDocument();
});
}); });