import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import CloneInboundModal from '@/pages/inbounds/CloneInboundModal';
import { HttpUtil } from '@/utils';
import { DBInbound } from '@/models/dbinbound';
import { ThemeProvider } from '@/hooks/useTheme';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { renderWithProviders } from './test-utils';
const postSpy = vi.mocked(HttpUtil.post);
const NODES = [
{ id: 2, name: 'arm2', enable: true, status: 'online' },
{ id: 3, name: 'arm3', enable: true, status: 'offline' },
{ id: 4, name: 'retired', enable: false, status: 'online' },
{ id: 5, name: 'arm5', enable: true, status: 'unknown' },
] as unknown as NodeRecord[];
function sourceInbound() {
return new DBInbound({
id: 7,
port: 443,
listen: '',
protocol: 'vless',
remark: 'edge',
enable: true,
settings: JSON.stringify({ clients: [{ id: 'uuid-1', email: 'a@test' }], decryption: 'none' }),
streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
sniffing: '',
nodeId: 2,
shareAddrStrategy: 'node',
shareAddr: '',
});
}
function renderModal(onCloned = vi.fn(), onClose = vi.fn()) {
renderWithProviders(
,
);
return { onCloned, onClose };
}
function openTargetDropdown() {
// antd v6 Select has no .ant-select-selector; mouseDown on the root opens it.
const selector = document.querySelector('.ant-select');
if (!selector) throw new Error('target select not rendered');
fireEvent.mouseDown(selector);
}
function clickOption(text: string) {
const option = Array.from(document.querySelectorAll('.ant-select-item-option'))
.find((o) => (o.textContent ?? '').trim() === text);
if (!option) throw new Error(`option '${text}' not found`);
fireEvent.click(option);
}
function clickOk() {
fireEvent.click(screen.getByRole('button', { name: 'Clone' }));
}
type PostBody = Record & { nodeId?: number };
const postedBodies = () => postSpy.mock.calls.map((c) => c[1] as PostBody);
const selectedTitles = () => Array.from(document.querySelectorAll('.ant-select-selection-item[title]'))
.map((el) => el.getAttribute('title'));
beforeEach(() => {
postSpy.mockClear();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
postSpy.mockResolvedValue({ success: true, obj: {} } as any);
});
describe('CloneInboundModal', () => {
it('pre-selects the source node and clones onto it with a fresh port and no clients', async () => {
const { onCloned, onClose } = renderModal();
expect(document.querySelector('.ant-select-selection-item[title="arm2"]')).toBeTruthy();
clickOk();
await waitFor(() => expect(postSpy).toHaveBeenCalledTimes(1));
expect(postSpy.mock.calls[0][0]).toBe('/panel/api/inbounds/add');
const body = postedBodies()[0];
expect(body.nodeId).toBe(2);
expect(body.enable).toBe(false);
expect(body.remark).toBe('edge (clone)');
expect(body.port).not.toBe(443);
expect(body).not.toHaveProperty('tag');
expect(JSON.parse(body.settings as string).clients).toEqual([]);
await waitFor(() => expect(onCloned).toHaveBeenCalledTimes(1));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('posts once per selected target and omits nodeId for the local panel', async () => {
renderModal();
openTargetDropdown();
clickOption('Local panel');
clickOk();
await waitFor(() => expect(postSpy).toHaveBeenCalledTimes(2));
const [nodeBody, localBody] = postedBodies();
expect(nodeBody.nodeId).toBe(2);
expect(localBody).not.toHaveProperty('nodeId');
expect(nodeBody.port).not.toBe(443);
});
it('disables non-online nodes and hides disabled nodes from the target list', () => {
renderModal();
openTargetDropdown();
const option = (text: string) => Array.from(document.querySelectorAll('.ant-select-item-option'))
.find((o) => (o.textContent ?? '').trim() === text);
// Only `online` is selectable — `offline` and `unknown` (no heartbeat
// yet) are both shown but disabled.
expect(option('arm3 (offline)')?.className).toContain('ant-select-item-option-disabled');
expect(option('arm5 (unknown)')?.className).toContain('ant-select-item-option-disabled');
expect(option('arm2')?.className).not.toContain('ant-select-item-option-disabled');
const labels = Array.from(document.querySelectorAll('.ant-select-item-option'))
.map((o) => (o.textContent ?? '').trim());
expect(labels).toEqual(['Local panel', 'arm2', 'arm3 (offline)', 'arm5 (unknown)']);
});
it('select-all picks only selectable targets and clear-all blocks submit', () => {
renderModal();
const selectAll = screen.getByRole('button', { name: 'Select all' });
fireEvent.click(selectAll);
// Local panel + online node; offline/unknown nodes stay unpickable.
expect(selectedTitles().sort()).toEqual(['Local panel', 'arm2']);
expect((selectAll as HTMLButtonElement).disabled).toBe(true);
// Clear all empties the selection and disables OK.
fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
expect(selectedTitles()).toEqual([]);
expect((screen.getByRole('button', { name: 'Clone' }) as HTMLButtonElement).disabled).toBe(true);
});
it('keeps a cleared selection when the nodes list refetches mid-dialog', () => {
// The page LazyMounts the modal once and keeps it mounted; heartbeats give
// `nodes` a new array identity on every refetch. The reset effect must not
// refire on that — only on the open transition.
const modal = (nodes: NodeRecord[], open = true) => (
{}}
onCloned={() => {}}
/>
);
const { rerender } = render(modal(NODES));
fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
expect(selectedTitles()).toEqual([]);
rerender(modal(NODES.map((n) => ({ ...n, latencyMs: 42 })) as unknown as NodeRecord[]));
expect(selectedTitles()).toEqual([]);
});
it('resets the selection to the source node on each reopen', () => {
const modal = (open: boolean) => (
{}}
onCloned={() => {}}
/>
);
const { rerender } = render(modal(true));
fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
expect(selectedTitles()).toEqual([]);
rerender(modal(false));
rerender(modal(true));
expect(selectedTitles()).toEqual(['arm2']);
});
it('reports a partial failure with the backend reason and still closes', async () => {
const { onCloned, onClose } = renderModal();
postSpy.mockImplementation(async (_url, data) => {
const body = data as PostBody;
if (body.nodeId === 2) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { success: false, msg: "port 23456 (tcp) already used by inbound 'x' (#1) on *" } as any;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { success: true, obj: {} } as any;
});
openTargetDropdown();
clickOption('Local panel');
clickOk();
await screen.findByText(/port 23456 \(tcp\) already used/);
await waitFor(() => expect(onCloned).toHaveBeenCalledTimes(1));
expect(onClose).toHaveBeenCalledTimes(1);
});
});