| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219 |
- 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(
- <CloneInboundModal
- open
- dbInbound={sourceInbound()}
- nodes={NODES}
- portsInUse={new Map([[2, new Set([443])]])}
- onClose={onClose}
- onCloned={onCloned}
- />,
- );
- 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<string, unknown> & { 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) => (
- <ThemeProvider>
- <CloneInboundModal
- open={open}
- dbInbound={sourceInbound()}
- nodes={nodes}
- portsInUse={new Map()}
- onClose={() => {}}
- onCloned={() => {}}
- />
- </ThemeProvider>
- );
- 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) => (
- <ThemeProvider>
- <CloneInboundModal
- open={open}
- dbInbound={sourceInbound()}
- nodes={NODES}
- portsInUse={new Map()}
- onClose={() => {}}
- onCloned={() => {}}
- />
- </ThemeProvider>
- );
- 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);
- });
- });
|