clone-inbound-modal.test.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import { beforeEach, describe, expect, it, vi } from 'vitest';
  2. import { fireEvent, render, screen, waitFor } from '@testing-library/react';
  3. import CloneInboundModal from '@/pages/inbounds/CloneInboundModal';
  4. import { HttpUtil } from '@/utils';
  5. import { DBInbound } from '@/models/dbinbound';
  6. import { ThemeProvider } from '@/hooks/useTheme';
  7. import type { NodeRecord } from '@/api/queries/useNodesQuery';
  8. import { renderWithProviders } from './test-utils';
  9. const postSpy = vi.mocked(HttpUtil.post);
  10. const NODES = [
  11. { id: 2, name: 'arm2', enable: true, status: 'online' },
  12. { id: 3, name: 'arm3', enable: true, status: 'offline' },
  13. { id: 4, name: 'retired', enable: false, status: 'online' },
  14. { id: 5, name: 'arm5', enable: true, status: 'unknown' },
  15. ] as unknown as NodeRecord[];
  16. function sourceInbound() {
  17. return new DBInbound({
  18. id: 7,
  19. port: 443,
  20. listen: '',
  21. protocol: 'vless',
  22. remark: 'edge',
  23. enable: true,
  24. settings: JSON.stringify({ clients: [{ id: 'uuid-1', email: 'a@test' }], decryption: 'none' }),
  25. streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
  26. sniffing: '',
  27. nodeId: 2,
  28. shareAddrStrategy: 'node',
  29. shareAddr: '',
  30. });
  31. }
  32. function renderModal(onCloned = vi.fn(), onClose = vi.fn()) {
  33. renderWithProviders(
  34. <CloneInboundModal
  35. open
  36. dbInbound={sourceInbound()}
  37. nodes={NODES}
  38. portsInUse={new Map([[2, new Set([443])]])}
  39. onClose={onClose}
  40. onCloned={onCloned}
  41. />,
  42. );
  43. return { onCloned, onClose };
  44. }
  45. function openTargetDropdown() {
  46. // antd v6 Select has no .ant-select-selector; mouseDown on the root opens it.
  47. const selector = document.querySelector('.ant-select');
  48. if (!selector) throw new Error('target select not rendered');
  49. fireEvent.mouseDown(selector);
  50. }
  51. function clickOption(text: string) {
  52. const option = Array.from(document.querySelectorAll('.ant-select-item-option'))
  53. .find((o) => (o.textContent ?? '').trim() === text);
  54. if (!option) throw new Error(`option '${text}' not found`);
  55. fireEvent.click(option);
  56. }
  57. function clickOk() {
  58. fireEvent.click(screen.getByRole('button', { name: 'Clone' }));
  59. }
  60. type PostBody = Record<string, unknown> & { nodeId?: number };
  61. const postedBodies = () => postSpy.mock.calls.map((c) => c[1] as PostBody);
  62. const selectedTitles = () => Array.from(document.querySelectorAll('.ant-select-selection-item[title]'))
  63. .map((el) => el.getAttribute('title'));
  64. beforeEach(() => {
  65. postSpy.mockClear();
  66. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  67. postSpy.mockResolvedValue({ success: true, obj: {} } as any);
  68. });
  69. describe('CloneInboundModal', () => {
  70. it('pre-selects the source node and clones onto it with a fresh port and no clients', async () => {
  71. const { onCloned, onClose } = renderModal();
  72. expect(document.querySelector('.ant-select-selection-item[title="arm2"]')).toBeTruthy();
  73. clickOk();
  74. await waitFor(() => expect(postSpy).toHaveBeenCalledTimes(1));
  75. expect(postSpy.mock.calls[0][0]).toBe('/panel/api/inbounds/add');
  76. const body = postedBodies()[0];
  77. expect(body.nodeId).toBe(2);
  78. expect(body.enable).toBe(false);
  79. expect(body.remark).toBe('edge (clone)');
  80. expect(body.port).not.toBe(443);
  81. expect(body).not.toHaveProperty('tag');
  82. expect(JSON.parse(body.settings as string).clients).toEqual([]);
  83. await waitFor(() => expect(onCloned).toHaveBeenCalledTimes(1));
  84. expect(onClose).toHaveBeenCalledTimes(1);
  85. });
  86. it('posts once per selected target and omits nodeId for the local panel', async () => {
  87. renderModal();
  88. openTargetDropdown();
  89. clickOption('Local panel');
  90. clickOk();
  91. await waitFor(() => expect(postSpy).toHaveBeenCalledTimes(2));
  92. const [nodeBody, localBody] = postedBodies();
  93. expect(nodeBody.nodeId).toBe(2);
  94. expect(localBody).not.toHaveProperty('nodeId');
  95. expect(nodeBody.port).not.toBe(443);
  96. });
  97. it('disables non-online nodes and hides disabled nodes from the target list', () => {
  98. renderModal();
  99. openTargetDropdown();
  100. const option = (text: string) => Array.from(document.querySelectorAll('.ant-select-item-option'))
  101. .find((o) => (o.textContent ?? '').trim() === text);
  102. // Only `online` is selectable — `offline` and `unknown` (no heartbeat
  103. // yet) are both shown but disabled.
  104. expect(option('arm3 (offline)')?.className).toContain('ant-select-item-option-disabled');
  105. expect(option('arm5 (unknown)')?.className).toContain('ant-select-item-option-disabled');
  106. expect(option('arm2')?.className).not.toContain('ant-select-item-option-disabled');
  107. const labels = Array.from(document.querySelectorAll('.ant-select-item-option'))
  108. .map((o) => (o.textContent ?? '').trim());
  109. expect(labels).toEqual(['Local panel', 'arm2', 'arm3 (offline)', 'arm5 (unknown)']);
  110. });
  111. it('select-all picks only selectable targets and clear-all blocks submit', () => {
  112. renderModal();
  113. const selectAll = screen.getByRole('button', { name: 'Select all' });
  114. fireEvent.click(selectAll);
  115. // Local panel + online node; offline/unknown nodes stay unpickable.
  116. expect(selectedTitles().sort()).toEqual(['Local panel', 'arm2']);
  117. expect((selectAll as HTMLButtonElement).disabled).toBe(true);
  118. // Clear all empties the selection and disables OK.
  119. fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
  120. expect(selectedTitles()).toEqual([]);
  121. expect((screen.getByRole('button', { name: 'Clone' }) as HTMLButtonElement).disabled).toBe(true);
  122. });
  123. it('keeps a cleared selection when the nodes list refetches mid-dialog', () => {
  124. // The page LazyMounts the modal once and keeps it mounted; heartbeats give
  125. // `nodes` a new array identity on every refetch. The reset effect must not
  126. // refire on that — only on the open transition.
  127. const modal = (nodes: NodeRecord[], open = true) => (
  128. <ThemeProvider>
  129. <CloneInboundModal
  130. open={open}
  131. dbInbound={sourceInbound()}
  132. nodes={nodes}
  133. portsInUse={new Map()}
  134. onClose={() => {}}
  135. onCloned={() => {}}
  136. />
  137. </ThemeProvider>
  138. );
  139. const { rerender } = render(modal(NODES));
  140. fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
  141. expect(selectedTitles()).toEqual([]);
  142. rerender(modal(NODES.map((n) => ({ ...n, latencyMs: 42 })) as unknown as NodeRecord[]));
  143. expect(selectedTitles()).toEqual([]);
  144. });
  145. it('resets the selection to the source node on each reopen', () => {
  146. const modal = (open: boolean) => (
  147. <ThemeProvider>
  148. <CloneInboundModal
  149. open={open}
  150. dbInbound={sourceInbound()}
  151. nodes={NODES}
  152. portsInUse={new Map()}
  153. onClose={() => {}}
  154. onCloned={() => {}}
  155. />
  156. </ThemeProvider>
  157. );
  158. const { rerender } = render(modal(true));
  159. fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
  160. expect(selectedTitles()).toEqual([]);
  161. rerender(modal(false));
  162. rerender(modal(true));
  163. expect(selectedTitles()).toEqual(['arm2']);
  164. });
  165. it('reports a partial failure with the backend reason and still closes', async () => {
  166. const { onCloned, onClose } = renderModal();
  167. postSpy.mockImplementation(async (_url, data) => {
  168. const body = data as PostBody;
  169. if (body.nodeId === 2) {
  170. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  171. return { success: false, msg: "port 23456 (tcp) already used by inbound 'x' (#1) on *" } as any;
  172. }
  173. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  174. return { success: true, obj: {} } as any;
  175. });
  176. openTargetDropdown();
  177. clickOption('Local panel');
  178. clickOk();
  179. await screen.findByText(/port 23456 \(tcp\) already used/);
  180. await waitFor(() => expect(onCloned).toHaveBeenCalledTimes(1));
  181. expect(onClose).toHaveBeenCalledTimes(1);
  182. });
  183. });