clients-summary.test.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import type { ReactNode } from 'react';
  2. import { renderHook, waitFor, act } from '@testing-library/react';
  3. import { QueryClientProvider } from '@tanstack/react-query';
  4. import { afterEach, describe, expect, it, vi } from 'vitest';
  5. import { sameSpeedMap, useClients } from '@/hooks/useClients';
  6. import { makeTestQueryClient } from '@/test/test-utils';
  7. import { HttpUtil, Msg } from '@/utils';
  8. import type { ClientsSummary } from '@/schemas/client';
  9. afterEach(() => {
  10. vi.restoreAllMocks();
  11. });
  12. describe('websocket payload identity preservation', () => {
  13. const speed = (up: number, down: number) => ({ up, down });
  14. it('treats an unchanged speed map as unchanged', () => {
  15. const a = { 'a@x': speed(1, 2), 'b@x': speed(3, 4) };
  16. expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 4) })).toBe(true);
  17. expect(sameSpeedMap(a, { 'a@x': speed(1, 2) })).toBe(false);
  18. expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 5) })).toBe(false);
  19. expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'c@x': speed(3, 4) })).toBe(false);
  20. expect(sameSpeedMap({}, {})).toBe(true);
  21. });
  22. });
  23. describe('client summary always reflects the server, never a client_stats recompute (#6116)', () => {
  24. const serverSummary: ClientsSummary = {
  25. total: 3,
  26. active: 3,
  27. onlineCount: 0,
  28. depletedCount: 0,
  29. expiringCount: 0,
  30. deactiveCount: 0,
  31. online: [],
  32. depleted: [],
  33. expiring: [],
  34. deactive: [],
  35. };
  36. const pagedResponse = {
  37. items: [],
  38. total: 3,
  39. filtered: 3,
  40. page: 1,
  41. pageSize: 25,
  42. groups: [],
  43. summary: serverSummary,
  44. };
  45. function mockPanel() {
  46. vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
  47. if (url.includes('/clients/list/paged')) return new Msg(true, '', pagedResponse);
  48. if (url.includes('/inbounds/options')) return new Msg(true, '', []);
  49. return new Msg(true, '', null);
  50. });
  51. vi.spyOn(HttpUtil, 'post').mockImplementation(async (url: string) => {
  52. if (url.includes('/setting/defaultSettings')) return new Msg(true, '', { pageSize: 25 });
  53. if (url.includes('/clients/onlines')) return new Msg(true, '', []);
  54. return new Msg(true, '', null);
  55. });
  56. }
  57. function wrapperFor() {
  58. const queryClient = makeTestQueryClient();
  59. return ({ children }: { children: ReactNode }) => (
  60. <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  61. );
  62. }
  63. async function loadedHook() {
  64. mockPanel();
  65. const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
  66. await waitFor(() => expect(result.current.settingsReady).toBe(true));
  67. act(() => {
  68. result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
  69. });
  70. await waitFor(() => expect(result.current.fetched).toBe(true));
  71. expect(result.current.summary).toEqual(serverSummary);
  72. return result;
  73. }
  74. it('stays pinned to the server summary across a client_stats push carrying an orphan row with no matching gap', async () => {
  75. const result = await loadedHook();
  76. act(() => {
  77. result.current.applyClientStatsEvent({
  78. snapshot: true,
  79. clients: [
  80. { email: 'a@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
  81. { email: 'b@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
  82. { email: 'c@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
  83. { email: 'ghost@x', enable: false, up: 0, down: 0, total: 1, expiryTime: 1 },
  84. ],
  85. });
  86. });
  87. expect(result.current.summary).toEqual(serverSummary);
  88. });
  89. it('stays pinned to the server summary across a client_stats push where an orphan and a gap net out to the server total', async () => {
  90. const result = await loadedHook();
  91. act(() => {
  92. result.current.applyClientStatsEvent({
  93. snapshot: true,
  94. clients: [
  95. { email: 'a@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
  96. { email: 'b@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
  97. { email: 'ghost@x', enable: false, up: 0, down: 0, total: 1, expiryTime: 1 },
  98. ],
  99. });
  100. });
  101. expect(result.current.summary).toEqual(serverSummary);
  102. });
  103. it('combines independently sampled TUIC and Xray speeds and replaces each source snapshot', async () => {
  104. const result = await loadedHook();
  105. act(() => {
  106. result.current.applyTrafficEvent({
  107. clientTraffics: [{ email: '[email protected]', up: 100, down: 150 }],
  108. });
  109. });
  110. expect(result.current.clientSpeed['[email protected]']).toEqual({ up: 20, down: 30 });
  111. act(() => {
  112. result.current.applyTrafficEvent({
  113. clientTrafficSource: 'tuic',
  114. clientTrafficIntervalMs: 10_000,
  115. clientTraffics: [
  116. { email: '[email protected]', up: 300, down: 100 },
  117. { email: '[email protected]', up: 100, down: 100 },
  118. ],
  119. });
  120. });
  121. expect(result.current.clientSpeed['[email protected]']).toEqual({ up: 60, down: 50 });
  122. act(() => {
  123. result.current.applyTrafficEvent({
  124. clientTraffics: [{ email: '[email protected]', up: 50, down: 25 }],
  125. });
  126. });
  127. expect(result.current.clientSpeed['[email protected]']).toEqual({ up: 50, down: 25 });
  128. act(() => {
  129. result.current.applyTrafficEvent({
  130. clientTrafficSource: 'tuic',
  131. clientTrafficIntervalMs: 10_000,
  132. clientTraffics: [],
  133. });
  134. });
  135. expect(result.current.clientSpeed['[email protected]']).toEqual({ up: 10, down: 5 });
  136. });
  137. it('expires stale per-source speeds without clearing the other source', async () => {
  138. const result = await loadedHook();
  139. vi.useFakeTimers();
  140. try {
  141. act(() => {
  142. result.current.applyTrafficEvent({
  143. clientTrafficSource: 'xray',
  144. clientTrafficIntervalMs: 1_000,
  145. clientTraffics: [{ email: '[email protected]', up: 100, down: 200 }],
  146. });
  147. result.current.applyTrafficEvent({
  148. clientTrafficSource: 'tuic',
  149. clientTrafficIntervalMs: 2_000,
  150. clientTraffics: [{ email: '[email protected]', up: 300, down: 400 }],
  151. });
  152. });
  153. expect(result.current.clientSpeed['[email protected]']).toEqual({ up: 250, down: 400 });
  154. act(() => vi.advanceTimersByTime(2_000));
  155. expect(result.current.clientSpeed['[email protected]']).toEqual({ up: 150, down: 200 });
  156. act(() => vi.advanceTimersByTime(2_000));
  157. expect(result.current.clientSpeed).toEqual({});
  158. } finally {
  159. vi.useRealTimers();
  160. }
  161. });
  162. });