Files
3x-ui/frontend/src/test/clients-summary.test.tsx
T
Egor aacfaebab8 fix(tuic): client speed display and certificate button layout (#6723)
* fix(tuic): restore client speed and certificate layout

* docs(tuic): clarify native runtime and protocol behavior

* fix(websocket): preserve traffic updates from independent sources

* fix(docs): sync websocket traffic schema and drop restating TUIC tests

docs/public/openapi.json still described the old traffic event, without
clientTrafficSource/clientTrafficIntervalMs or the TUIC oneOf branch, so
the docs site showed a payload the panel no longer sends. No check
covers that copy.

The TUIC certificate layout test and the TUIC speed payload test only
read back the literals the code writes, so neither could fail on a real
regression. Both are removed, along with the className that existed only
for the layout test.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-10-05 19:46:46 +02:00

186 lines
6.4 KiB
TypeScript

import type { ReactNode } from 'react';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { sameSpeedMap, useClients } from '@/hooks/useClients';
import { makeTestQueryClient } from '@/test/test-utils';
import { HttpUtil, Msg } from '@/utils';
import type { ClientsSummary } from '@/schemas/client';
afterEach(() => {
vi.restoreAllMocks();
});
describe('websocket payload identity preservation', () => {
const speed = (up: number, down: number) => ({ up, down });
it('treats an unchanged speed map as unchanged', () => {
const a = { 'a@x': speed(1, 2), 'b@x': speed(3, 4) };
expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 4) })).toBe(true);
expect(sameSpeedMap(a, { 'a@x': speed(1, 2) })).toBe(false);
expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 5) })).toBe(false);
expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'c@x': speed(3, 4) })).toBe(false);
expect(sameSpeedMap({}, {})).toBe(true);
});
});
describe('client summary always reflects the server, never a client_stats recompute (#6116)', () => {
const serverSummary: ClientsSummary = {
total: 3,
active: 3,
onlineCount: 0,
depletedCount: 0,
expiringCount: 0,
deactiveCount: 0,
online: [],
depleted: [],
expiring: [],
deactive: [],
};
const pagedResponse = {
items: [],
total: 3,
filtered: 3,
page: 1,
pageSize: 25,
groups: [],
summary: serverSummary,
};
function mockPanel() {
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
if (url.includes('/clients/list/paged')) return new Msg(true, '', pagedResponse);
if (url.includes('/inbounds/options')) return new Msg(true, '', []);
return new Msg(true, '', null);
});
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url: string) => {
if (url.includes('/setting/defaultSettings')) return new Msg(true, '', { pageSize: 25 });
if (url.includes('/clients/onlines')) return new Msg(true, '', []);
return new Msg(true, '', null);
});
}
function wrapperFor() {
const queryClient = makeTestQueryClient();
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
async function loadedHook() {
mockPanel();
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
await waitFor(() => expect(result.current.settingsReady).toBe(true));
act(() => {
result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
});
await waitFor(() => expect(result.current.fetched).toBe(true));
expect(result.current.summary).toEqual(serverSummary);
return result;
}
it('stays pinned to the server summary across a client_stats push carrying an orphan row with no matching gap', async () => {
const result = await loadedHook();
act(() => {
result.current.applyClientStatsEvent({
snapshot: true,
clients: [
{ email: 'a@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
{ email: 'b@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
{ email: 'c@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
{ email: 'ghost@x', enable: false, up: 0, down: 0, total: 1, expiryTime: 1 },
],
});
});
expect(result.current.summary).toEqual(serverSummary);
});
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 () => {
const result = await loadedHook();
act(() => {
result.current.applyClientStatsEvent({
snapshot: true,
clients: [
{ email: 'a@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
{ email: 'b@x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0 },
{ email: 'ghost@x', enable: false, up: 0, down: 0, total: 1, expiryTime: 1 },
],
});
});
expect(result.current.summary).toEqual(serverSummary);
});
it('combines independently sampled TUIC and Xray speeds and replaces each source snapshot', async () => {
const result = await loadedHook();
act(() => {
result.current.applyTrafficEvent({
clientTraffics: [{ email: 'shared@example.test', up: 100, down: 150 }],
});
});
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 20, down: 30 });
act(() => {
result.current.applyTrafficEvent({
clientTrafficSource: 'tuic',
clientTrafficIntervalMs: 10_000,
clientTraffics: [
{ email: 'shared@example.test', up: 300, down: 100 },
{ email: 'shared@example.test', up: 100, down: 100 },
],
});
});
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 60, down: 50 });
act(() => {
result.current.applyTrafficEvent({
clientTraffics: [{ email: 'shared@example.test', up: 50, down: 25 }],
});
});
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 50, down: 25 });
act(() => {
result.current.applyTrafficEvent({
clientTrafficSource: 'tuic',
clientTrafficIntervalMs: 10_000,
clientTraffics: [],
});
});
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 10, down: 5 });
});
it('expires stale per-source speeds without clearing the other source', async () => {
const result = await loadedHook();
vi.useFakeTimers();
try {
act(() => {
result.current.applyTrafficEvent({
clientTrafficSource: 'xray',
clientTrafficIntervalMs: 1_000,
clientTraffics: [{ email: 'shared@example.test', up: 100, down: 200 }],
});
result.current.applyTrafficEvent({
clientTrafficSource: 'tuic',
clientTrafficIntervalMs: 2_000,
clientTraffics: [{ email: 'shared@example.test', up: 300, down: 400 }],
});
});
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 250, down: 400 });
act(() => vi.advanceTimersByTime(2_000));
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 150, down: 200 });
act(() => vi.advanceTimersByTime(2_000));
expect(result.current.clientSpeed).toEqual({});
} finally {
vi.useRealTimers();
}
});
});