clients-summary.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. import { describe, it, expect } from 'vitest';
  2. import { computeClientsSummary, pickClientsSummary, sameSpeedMap, sameSummaryInputs } from '@/hooks/useClients';
  3. import type { ClientTraffic, ClientsSummary } from '@/schemas/client';
  4. // Parity with web/service/client.go buildClientsSummary: the same client must
  5. // land in the same bucket whether the count comes from the server (list fetch)
  6. // or is recomputed live from the client_stats WS event. A mismatch would make
  7. // the summary card "jump" on refresh.
  8. type Row = ClientTraffic & { email?: string };
  9. const GB = 1024 * 1024 * 1024;
  10. const DAY = 86_400_000;
  11. function row(over: Partial<Row>): Row {
  12. return { email: 'x', enable: true, up: 0, down: 0, total: 0, expiryTime: 0, ...over } as Row;
  13. }
  14. describe('computeClientsSummary', () => {
  15. it('buckets each client the way the Go service does', () => {
  16. const now = Date.now();
  17. const stats: Row[] = [
  18. row({ email: 'online@x', enable: true }),
  19. row({ email: 'offline@x', enable: true }),
  20. row({ email: 'disabled@x', enable: false }),
  21. row({ email: 'exhausted@x', enable: true, total: 1 * GB, up: 1 * GB }),
  22. row({ email: 'expired@x', enable: true, expiryTime: now - DAY }),
  23. row({ email: 'nearexpiry@x', enable: true, expiryTime: now + DAY }),
  24. row({ email: 'nearlimit@x', enable: true, total: 10 * GB, up: 9.9 * GB }),
  25. ];
  26. const online = new Set(['online@x', 'disabled@x']); // disabled-but-online must NOT count as online
  27. const expireDiffMs = 3 * DAY;
  28. const trafficDiffBytes = 1 * GB;
  29. const s = computeClientsSummary(stats, online, expireDiffMs, trafficDiffBytes);
  30. expect(s.total).toBe(7);
  31. expect(s.online).toEqual(['online@x']);
  32. expect(s.depleted.sort()).toEqual(['exhausted@x', 'expired@x']);
  33. expect(s.deactive).toEqual(['disabled@x']);
  34. expect(s.expiring.sort()).toEqual(['nearexpiry@x', 'nearlimit@x']);
  35. expect(s.active).toBe(2); // online@x + offline@x
  36. });
  37. it('reports a counter alongside every bucket list', () => {
  38. const stats: Row[] = [
  39. row({ email: 'online@x', enable: true }),
  40. row({ email: 'disabled@x', enable: false }),
  41. row({ email: 'exhausted@x', enable: true, total: 1 * GB, up: 1 * GB }),
  42. row({ email: 'nearlimit@x', enable: true, total: 10 * GB, up: 9.9 * GB }),
  43. ];
  44. const s = computeClientsSummary(stats, new Set(['online@x']), 3 * DAY, 1 * GB);
  45. // The server caps its lists but never its counters; the live recompute has
  46. // both, so the summary card reads the same either way.
  47. expect(s.onlineCount).toBe(s.online.length);
  48. expect(s.depletedCount).toBe(s.depleted.length);
  49. expect(s.expiringCount).toBe(s.expiring.length);
  50. expect(s.deactiveCount).toBe(s.deactive.length);
  51. expect(s.active + s.depletedCount + s.expiringCount + s.deactiveCount).toBe(s.total);
  52. });
  53. it('depleted wins over disabled and over online', () => {
  54. const stats: Row[] = [
  55. row({ email: 'a@x', enable: false, total: 1 * GB, up: 2 * GB }),
  56. ];
  57. const s = computeClientsSummary(stats, new Set(['a@x']), 0, 0);
  58. expect(s.depleted).toEqual(['a@x']);
  59. expect(s.deactive).toEqual([]);
  60. expect(s.online).toEqual([]); // disabled is never online
  61. });
  62. it('unlimited + no expiry is active', () => {
  63. const stats: Row[] = [row({ email: 'a@x', enable: true, total: 0, expiryTime: 0 })];
  64. const s = computeClientsSummary(stats, new Set(), 3 * DAY, 1 * GB);
  65. expect(s.active).toBe(1);
  66. expect(s.expiring).toEqual([]);
  67. expect(s.depleted).toEqual([]);
  68. });
  69. });
  70. describe('pickClientsSummary', () => {
  71. const serverSummary: ClientsSummary = {
  72. total: 67, active: 58,
  73. onlineCount: 0, depletedCount: 4, expiringCount: 3, deactiveCount: 2,
  74. online: [], depleted: [], expiring: [], deactive: [],
  75. };
  76. it('keeps the server summary when the snapshot is short of the server total (#6102)', () => {
  77. const shortSnapshot: Row[] = Array.from({ length: 58 }, (_, i) => row({ email: `c${i}@x`, enable: true }));
  78. const s = pickClientsSummary(serverSummary, shortSnapshot, new Set(), 3 * DAY, 1 * GB);
  79. expect(s).toEqual(serverSummary);
  80. });
  81. it('uses the live recompute when the snapshot covers every client', () => {
  82. const fullSnapshot: Row[] = Array.from({ length: 67 }, (_, i) => row({ email: `c${i}@x`, enable: true }));
  83. const s = pickClientsSummary(serverSummary, fullSnapshot, new Set(), 3 * DAY, 1 * GB);
  84. expect(s.total).toBe(67);
  85. expect(s.active).toBe(67);
  86. });
  87. it('falls back to the server summary before the first WS snapshot arrives', () => {
  88. const s = pickClientsSummary(serverSummary, [], new Set(), 3 * DAY, 1 * GB);
  89. expect(s).toEqual(serverSummary);
  90. });
  91. });
  92. describe('websocket payload identity preservation', () => {
  93. const speed = (up: number, down: number) => ({ up, down });
  94. it('treats an unchanged speed map as unchanged', () => {
  95. const a = { 'a@x': speed(1, 2), 'b@x': speed(3, 4) };
  96. expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 4) })).toBe(true);
  97. expect(sameSpeedMap(a, { 'a@x': speed(1, 2) })).toBe(false);
  98. expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 5) })).toBe(false);
  99. expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'c@x': speed(3, 4) })).toBe(false);
  100. expect(sameSpeedMap({}, {})).toBe(true);
  101. });
  102. it('compares exactly the fields the summary reads, and ignores lastOnline', () => {
  103. const base: Row[] = [row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99 })];
  104. // lastOnline moves for every online client on every push and no counter
  105. // depends on it, so it must not force a new snapshot.
  106. const onlyLastOnlineMoved: Row[] = [
  107. row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, lastOnline: 12345 }),
  108. ];
  109. expect(sameSummaryInputs(base, onlyLastOnlineMoved)).toBe(true);
  110. for (const changed of [
  111. row({ email: 'b@x', up: 1, down: 2, total: 10, expiryTime: 99 }),
  112. row({ email: 'a@x', up: 2, down: 2, total: 10, expiryTime: 99 }),
  113. row({ email: 'a@x', up: 1, down: 3, total: 10, expiryTime: 99 }),
  114. row({ email: 'a@x', up: 1, down: 2, total: 11, expiryTime: 99 }),
  115. row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 100 }),
  116. row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, enable: false }),
  117. ]) {
  118. expect(sameSummaryInputs(base, [changed])).toBe(false);
  119. }
  120. expect(sameSummaryInputs(base, [])).toBe(false);
  121. });
  122. });