clone-inbound-modal.test.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  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')).find(
  53. (o) => (o.textContent ?? '').trim() === text,
  54. );
  55. if (!option) throw new Error(`option '${text}' not found`);
  56. fireEvent.click(option);
  57. }
  58. function clickOk() {
  59. fireEvent.click(screen.getByRole('button', { name: 'Clone' }));
  60. }
  61. type PostBody = Record<string, unknown> & { nodeId?: number };
  62. const postedBodies = () => postSpy.mock.calls.map((c) => c[1] as PostBody);
  63. const selectedTitles = () =>
  64. Array.from(document.querySelectorAll('.ant-select-selection-item[title]')).map((el) =>
  65. el.getAttribute('title'),
  66. );
  67. beforeEach(() => {
  68. postSpy.mockClear();
  69. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  70. postSpy.mockResolvedValue({ success: true, obj: {} } as any);
  71. });
  72. describe('CloneInboundModal', () => {
  73. it('pre-selects the source node and clones onto it with a fresh port and no clients', async () => {
  74. const { onCloned, onClose } = renderModal();
  75. expect(document.querySelector('.ant-select-selection-item[title="arm2"]')).toBeTruthy();
  76. clickOk();
  77. await waitFor(() => expect(postSpy).toHaveBeenCalledTimes(1));
  78. expect(postSpy.mock.calls[0][0]).toBe('/panel/api/inbounds/add');
  79. const body = postedBodies()[0];
  80. expect(body.nodeId).toBe(2);
  81. expect(body.enable).toBe(false);
  82. expect(body.remark).toBe('edge (clone)');
  83. expect(body.port).not.toBe(443);
  84. expect(body).not.toHaveProperty('tag');
  85. expect(JSON.parse(body.settings as string).clients).toEqual([]);
  86. await waitFor(() => expect(onCloned).toHaveBeenCalledTimes(1));
  87. expect(onClose).toHaveBeenCalledTimes(1);
  88. });
  89. it('posts once per selected target and omits nodeId for the local panel', async () => {
  90. renderModal();
  91. openTargetDropdown();
  92. clickOption('Local panel');
  93. clickOk();
  94. await waitFor(() => expect(postSpy).toHaveBeenCalledTimes(2));
  95. const [nodeBody, localBody] = postedBodies();
  96. expect(nodeBody.nodeId).toBe(2);
  97. expect(localBody).not.toHaveProperty('nodeId');
  98. expect(nodeBody.port).not.toBe(443);
  99. });
  100. it('disables non-online nodes and hides disabled nodes from the target list', () => {
  101. renderModal();
  102. openTargetDropdown();
  103. const option = (text: string) =>
  104. Array.from(document.querySelectorAll('.ant-select-item-option')).find(
  105. (o) => (o.textContent ?? '').trim() === text,
  106. );
  107. // Only `online` is selectable — `offline` and `unknown` (no heartbeat
  108. // yet) are both shown but disabled.
  109. expect(option('arm3 (offline)')?.className).toContain('ant-select-item-option-disabled');
  110. expect(option('arm5 (unknown)')?.className).toContain('ant-select-item-option-disabled');
  111. expect(option('arm2')?.className).not.toContain('ant-select-item-option-disabled');
  112. const labels = Array.from(document.querySelectorAll('.ant-select-item-option')).map((o) =>
  113. (o.textContent ?? '').trim(),
  114. );
  115. expect(labels).toEqual(['Local panel', 'arm2', 'arm3 (offline)', 'arm5 (unknown)']);
  116. });
  117. it('select-all picks only selectable targets and clear-all blocks submit', () => {
  118. renderModal();
  119. const selectAll = screen.getByRole('button', { name: 'Select all' });
  120. fireEvent.click(selectAll);
  121. // Local panel + online node; offline/unknown nodes stay unpickable.
  122. expect(selectedTitles().sort()).toEqual(['Local panel', 'arm2']);
  123. expect((selectAll as HTMLButtonElement).disabled).toBe(true);
  124. // Clear all empties the selection and disables OK.
  125. fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
  126. expect(selectedTitles()).toEqual([]);
  127. expect((screen.getByRole('button', { name: 'Clone' }) as HTMLButtonElement).disabled).toBe(
  128. true,
  129. );
  130. });
  131. it('keeps a cleared selection when the nodes list refetches mid-dialog', () => {
  132. // The page LazyMounts the modal once and keeps it mounted; heartbeats give
  133. // `nodes` a new array identity on every refetch. The reset effect must not
  134. // refire on that — only on the open transition.
  135. const modal = (nodes: NodeRecord[], open = true) => (
  136. <ThemeProvider>
  137. <CloneInboundModal
  138. open={open}
  139. dbInbound={sourceInbound()}
  140. nodes={nodes}
  141. portsInUse={new Map()}
  142. onClose={() => {}}
  143. onCloned={() => {}}
  144. />
  145. </ThemeProvider>
  146. );
  147. const { rerender } = render(modal(NODES));
  148. fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
  149. expect(selectedTitles()).toEqual([]);
  150. rerender(modal(NODES.map((n) => ({ ...n, latencyMs: 42 })) as unknown as NodeRecord[]));
  151. expect(selectedTitles()).toEqual([]);
  152. });
  153. it('resets the selection to the source node on each reopen', () => {
  154. const modal = (open: boolean) => (
  155. <ThemeProvider>
  156. <CloneInboundModal
  157. open={open}
  158. dbInbound={sourceInbound()}
  159. nodes={NODES}
  160. portsInUse={new Map()}
  161. onClose={() => {}}
  162. onCloned={() => {}}
  163. />
  164. </ThemeProvider>
  165. );
  166. const { rerender } = render(modal(true));
  167. fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
  168. expect(selectedTitles()).toEqual([]);
  169. rerender(modal(false));
  170. rerender(modal(true));
  171. expect(selectedTitles()).toEqual(['arm2']);
  172. });
  173. it('reports a partial failure with the backend reason and still closes', async () => {
  174. const { onCloned, onClose } = renderModal();
  175. postSpy.mockImplementation(async (_url, data) => {
  176. const body = data as PostBody;
  177. if (body.nodeId === 2) {
  178. return {
  179. success: false,
  180. msg: "port 23456 (tcp) already used by inbound 'x' (#1) on *",
  181. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  182. } as any;
  183. }
  184. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  185. return { success: true, obj: {} } as any;
  186. });
  187. openTargetDropdown();
  188. clickOption('Local panel');
  189. clickOk();
  190. await screen.findByText(/port 23456 \(tcp\) already used/);
  191. await waitFor(() => expect(onCloned).toHaveBeenCalledTimes(1));
  192. expect(onClose).toHaveBeenCalledTimes(1);
  193. });
  194. });