package service import ( "encoding/json" "fmt" "maps" "path/filepath" "regexp" "slices" "strings" "sync" "testing" "gorm.io/gorm" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/xray" ) func initTrafficTestDB(t *testing.T) *gorm.DB { t.Helper() dbDir := t.TempDir() t.Setenv("XUI_DB_FOLDER", dbDir) dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db")) return database.GetDB() } func createNodeInbound(t *testing.T, db *gorm.DB, nodeID int, tag string, port int) { t.Helper() nid := nodeID ib := &model.Inbound{UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, NodeID: &nid} if err := db.Create(ib).Error; err != nil { t.Fatalf("create node inbound %q: %v", tag, err) } } // createNodeInboundWithClient mirrors createNodeInbound but stores the client // in the settings JSON, which the node sync turns into a client_inbounds link. func createNodeInboundWithClient(t *testing.T, db *gorm.DB, nodeID int, tag string, port int, email string) { t.Helper() nid := nodeID settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email) ib := &model.Inbound{UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, NodeID: &nid, Settings: settings} if err := db.Create(ib).Error; err != nil { t.Fatalf("create node inbound %q: %v", tag, err) } } func syncNode(t *testing.T, svc *InboundService, nodeID int, tag string, stats ...xray.ClientTraffic) { t.Helper() snap := &runtime.TrafficSnapshot{ Inbounds: []*model.Inbound{{Tag: tag, ClientStats: stats}}, } if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil { t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err) } } // syncNodeWithSettings mirrors syncNode but carries a real settings JSON on // the snapshot inbound, like production nodes do — the sync mirrors snapshot // settings onto the central row, and the shared-accumulator guard reads the // clients out of those settings. func syncNodeWithSettings(t *testing.T, svc *InboundService, nodeID int, tag, settings string, stats ...xray.ClientTraffic) { t.Helper() snap := &runtime.TrafficSnapshot{ Inbounds: []*model.Inbound{{Tag: tag, Settings: settings, ClientStats: stats}}, } if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil { t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err) } } func readTraffic(t *testing.T, db *gorm.DB, email string) xray.ClientTraffic { t.Helper() var ct xray.ClientTraffic if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&ct).Error; err != nil { t.Fatalf("read client_traffics %q: %v", email, err) } return ct } func assertUpDown(t *testing.T, ct xray.ClientTraffic, wantUp, wantDown int64, when string) { t.Helper() if ct.Up != wantUp || ct.Down != wantDown { t.Errorf("%s: up=%d down=%d, want %d/%d", when, ct.Up, ct.Down, wantUp, wantDown) } } func TestTwoNodesShareEmail_SumsCorrectly(t *testing.T) { db := initTrafficTestDB(t) createNodeInbound(t, db, 1, "n1-in", 41001) createNodeInbound(t, db, 2, "n2-in", 41002) svc := &InboundService{} const email = "shared" // First-ever sync from each node: the row is created at 0 and the current // node counters become the baseline. Only deltas beyond those baselines count. syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 0, 0, "after baselines — historical node traffic not imported") syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 150, Down: 150, Enable: true}) syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 260, Down: 260, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 110, 110, "after both nodes grow — deltas (50+60) accrue") } func TestSingleNode_MirrorsCorrectly(t *testing.T) { db := initTrafficTestDB(t) createNodeInbound(t, db, 1, "n1-in", 41001) svc := &InboundService{} const email = "solo" // First sync: row seeded at 0, current counter becomes the baseline. syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 500, Down: 600, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 0, 0, "first sync — historical traffic not imported") // Second sync: delta (700-500=200 / 800-600=200) accrues normally. syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 700, Down: 800, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 200, 200, "second sync — delta accrues") } func TestNodeAdd_ImportsClientHistoryWithNewInbound(t *testing.T) { db := initTrafficTestDB(t) svc := &InboundService{} const email = "newnode-client" const histUp, histDown int64 = 6_000_000_000, 200_000_000_000 syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: histUp, Down: histDown, Enable: true}) assertUpDown(t, readTraffic(t, db, email), histUp, histDown, "node-add: client history imported with its brand-new inbound") syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: histUp + 1024, Down: histDown + 2048, Enable: true}) assertUpDown(t, readTraffic(t, db, email), histUp+1024, histDown+2048, "post-import delta accrues, no double count") } // TestStaleSnapshot_DeletedClientNotResurrected reproduces #5739: a snapshot // fetched just before a client's deletion still names the email. Applying it // must neither recreate the client_traffics row (at zero) nor re-add the // client to the central inbound's settings while the delete tombstone lives. func TestStaleSnapshot_DeletedClientNotResurrected(t *testing.T) { db := initTrafficTestDB(t) createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "victim-5739") svc := &InboundService{} const email = "victim-5739" withClient := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email) syncNodeWithSettings(t, svc, 1, "n1-in", withClient, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) if err := db.Model(&model.Inbound{}).Where("tag = ?", "n1-in"). Update("settings", `{"clients": []}`).Error; err != nil { t.Fatalf("clear central settings: %v", err) } if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil { t.Fatalf("delete stats row: %v", err) } tombstoneClientEmail(email) syncNodeWithSettings(t, svc, 1, "n1-in", withClient, xray.ClientTraffic{Email: email, Up: 120, Down: 120, Enable: true}) var rows int64 if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&rows).Error; err != nil { t.Fatalf("count stats rows: %v", err) } if rows != 0 { t.Errorf("deleted client's stats row resurrected by stale snapshot (%d rows)", rows) } var ib model.Inbound if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil { t.Fatalf("load inbound: %v", err) } if strings.Contains(ib.Settings, email) { t.Errorf("deleted client re-added to central settings: %s", ib.Settings) } } func TestNodeAdd_TombstonedClientNotResurrected(t *testing.T) { db := initTrafficTestDB(t) svc := &InboundService{} const email = "deleted-ghost" const stale int64 = 50_000_000_000 tombstoneClientEmail(email) syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: stale, Down: stale, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 0, 0, "tombstoned client must not resurrect via node-add seed") } func TestUpgrade_PreExistingRow_NoDoubleCount(t *testing.T) { db := initTrafficTestDB(t) createNodeInbound(t, db, 1, "n1-in", 41001) svc := &InboundService{} const email = "legacy" var ib model.Inbound if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil { t.Fatalf("load inbound: %v", err) } if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: email, Up: 1000, Down: 2000, Enable: true}).Error; err != nil { t.Fatalf("seed pre-existing row: %v", err) } syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 1000, Down: 2000, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 1000, 2000, "first snapshot must not double-count") syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 1100, Down: 2100, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 1100, 2100, "growth after upgrade accrues") } // TestGhostData_NoPhantomTraffic reproduces the 50 GB phantom traffic bug: // a node retains stale data for a deleted client (e.g. de-1 was offline during // deletion). When the master first syncs this email again it must NOT import the // stale counter — it sets Up=0 and records the current node value as the // baseline so only future deltas count. func TestGhostData_NoPhantomTraffic(t *testing.T) { db := initTrafficTestDB(t) createNodeInbound(t, db, 1, "de1-in", 41001) svc := &InboundService{} const email = "pouya" const staleBytes int64 = 50 * 1024 * 1024 * 1024 // 50 GB stale on de-1 // Node reports a client with a large pre-existing counter (ghost data from a // deleted account that survived on the node). Master has no row for this email. syncNode(t, svc, 1, "de1-in", xray.ClientTraffic{Email: email, Up: staleBytes, Down: staleBytes, Enable: true}) ct := readTraffic(t, db, email) if ct.Up >= staleBytes || ct.Down >= staleBytes { t.Errorf("ghost data imported: up=%d down=%d; stale node counter must not count as new traffic", ct.Up, ct.Down) } assertUpDown(t, ct, 0, 0, "first sync of ghost email — imported as 0, not 50 GB") // Subsequent syncs only add incremental usage beyond the baseline. syncNode(t, svc, 1, "de1-in", xray.ClientTraffic{Email: email, Up: staleBytes + 1024, Down: staleBytes + 2048, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 1024, 2048, "only incremental traffic beyond baseline counts") } func TestNodeCounterReset_NoReAdd(t *testing.T) { db := initTrafficTestDB(t) createNodeInbound(t, db, 1, "n1-in", 41001) svc := &InboundService{} const email = "restart" // First sync seeds at 0 (baseline=900). Second sync adds delta 950-900=50. syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 900, Enable: true}) syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 950, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 50, 50, "before node reset") // Node reboot drops the counter to 50. delta=50-950=-900 is a counter reset, // not new traffic: add 0 and rebaseline to 50, never re-add the node's full // cumulative counter onto the master total (#5456). syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 50, Enable: true}) ct := readTraffic(t, db, email) if ct.Up < 0 || ct.Down < 0 { t.Fatalf("row went negative after node reset: up=%d down=%d", ct.Up, ct.Down) } assertUpDown(t, ct, 50, 50, "after node counter reset: rebaselined, not re-added") // Post-reset accrual resumes from the new baseline: 80-50=30. syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 80, Down: 80, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 80, 80, "post-reset delta accrues from rebaselined counter") } func TestCentralReset_NoReAdd(t *testing.T) { db := initTrafficTestDB(t) createNodeInbound(t, db, 1, "n1-in", 41001) createNodeInbound(t, db, 2, "n2-in", 41002) svc := &InboundService{} const email = "reset" syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true}) syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true}) if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil { t.Fatalf("simulate central reset: %v", err) } syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 210, Down: 210, Enable: true}) syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 205, Down: 205, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 15, 15, "after central reset only increments accrue") } // A real reset (ResetClientTrafficByEmail) must clear the per-node baseline so // the node's pre-reset cumulative — including traffic it counted but had not yet // synced — cannot leak back onto the master after the reset (#5476, #5390). func TestCentralResetClearsNodeBaseline_NoLeak(t *testing.T) { db := initTrafficTestDB(t) createNodeInbound(t, db, 1, "n1-in", 41001) StartTrafficWriter() svc := &InboundService{} const email = "reset-revert" syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 300, Down: 300, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 200, 200, "before reset") if err := svc.ResetClientTrafficByEmail(email); err != nil { t.Fatalf("ResetClientTrafficByEmail: %v", err) } assertUpDown(t, readTraffic(t, db, email), 0, 0, "right after reset") var baselines int64 if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&baselines).Error; err != nil { t.Fatalf("count baselines: %v", err) } if baselines != 0 { t.Fatalf("reset must clear node baseline rows, found %d", baselines) } // Node still reports its pre-reset cumulative (340 > last synced 300: usage it // had not synced before the reset). It must not revert the reset. syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 340, Down: 340, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 0, 0, "stale node counter must not revert reset") // Genuine post-reset usage accrues from the rebaselined counter: 370-340=30. syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 370, Down: 370, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 30, 30, "post-reset usage accrues") } func TestInboundRemoval_KeepsSharedEmailRow(t *testing.T) { db := initTrafficTestDB(t) createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "shared") createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "shared") svc := &InboundService{} const email = "shared" settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email) // Baselines set: node1=100, node2=200. Row seeded at 0. syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true}) // node1 delta=50, node2 delta=60 → total=110. syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 150, Down: 150, Enable: true}) syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 260, Down: 260, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 110, 110, "baseline sum") // Node 1 rebuilt (reinstall / another master's reconcile): its inbound // vanishes from the snapshot. The shared accumulator must survive — losing // it would let the next node sync re-seed the row with that node's counter // alone, showing only the last panel's number instead of the sum. if _, err := svc.setRemoteTrafficLocked(1, &runtime.TrafficSnapshot{}, false, false); err != nil { t.Fatalf("sync node 1 with empty snapshot: %v", err) } assertUpDown(t, readTraffic(t, db, email), 110, 110, "after node 1 inbound removal") // Node 2 keeps accruing onto the surviving row. node2 delta=300-260=40 → 150. syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 300, Down: 300, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 150, 150, "after node 2 grows") } func TestClientGoneFromOneNode_KeepsSharedEmailRow(t *testing.T) { db := initTrafficTestDB(t) createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "shared") createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "shared") svc := &InboundService{} const email = "shared" settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email) // First syncs set baselines (node1=100, node2=200). Row seeded at 0. syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true}) // Client detached from node 1's inbound only: its stats vanish from that // inbound's snapshot while node 2 still hosts the email. syncNodeWithSettings(t, svc, 1, "n1-in", `{"clients": []}`) assertUpDown(t, readTraffic(t, db, email), 0, 0, "after client left node 1 — row unchanged at 0") // Node 2 delta: 240-200=40 → accrues to 40. syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 240, Down: 240, Enable: true}) assertUpDown(t, readTraffic(t, db, email), 40, 40, "node 2 keeps accruing") } // TestStatsUnderSiblingInbound_KeepsNodeBaseline reproduces the recurring // sweep bug behind #5202: the client is attached to two inbounds of the SAME // node, the node reports its stats under n1-a only, but the master-side row // is owned by n1-b's mirror. The per-email sweep for n1-b must not drop the // (node, email) baseline that n1-a's delta computation needs — doing so every // cycle froze the client's counter permanently. func TestStatsUnderSiblingInbound_KeepsNodeBaseline(t *testing.T) { db := initTrafficTestDB(t) createNodeInboundWithClient(t, db, 1, "n1-a", 41001, "fresh") createNodeInbound(t, db, 1, "n1-b", 41002) svc := &InboundService{} const email = "fresh" var ibB model.Inbound if err := db.Where("tag = ?", "n1-b").First(&ibB).Error; err != nil { t.Fatalf("load n1-b: %v", err) } // Master-side row created when the client was added on the panel, owned by // n1-b's mirror (e.g. the client form targeted that inbound). if err := db.Create(&xray.ClientTraffic{InboundId: ibB.Id, Email: email, Enable: true}).Error; err != nil { t.Fatalf("seed master row: %v", err) } settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email) sync := func(up, down int64) { t.Helper() snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{ {Tag: "n1-a", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: up, Down: down, Enable: true}}}, {Tag: "n1-b", Settings: `{"clients": []}`}, }} if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { t.Fatalf("sync: %v", err) } } sync(630, 630) var baselines int64 if err := db.Model(&model.NodeClientTraffic{}).Where("node_id = ? AND email = ?", 1, email).Count(&baselines).Error; err != nil { t.Fatalf("count baselines: %v", err) } if baselines != 1 { t.Fatalf("baseline must survive the sibling-inbound sweep, found %d rows", baselines) } sync(700, 700) assertUpDown(t, readTraffic(t, db, email), 70, 70, "delta accrues once baseline survives") } // TestMultiAttach_SameNode_DivergentSiblings reproduces #5274: a client is // attached to several inbounds of the SAME node. Xray reports client traffic // globally per email, so the node's enriched inbound list copies one shared // counter onto every inbound the client is on. When those copies diverge — a // legacy per-inbound row surviving the v3.2.x→v3.3.x upgrade, or any drift — // the per-inbound delta loop used to treat the lower sibling as a node-counter // reset and re-add its full value, inflating the client far past real usage. // The merge must collapse the email to its node-wide total and count it once. func TestMultiAttach_SameNode_DivergentSiblings(t *testing.T) { db := initTrafficTestDB(t) createNodeInboundWithClient(t, db, 1, "n1-a", 41001, "multi") createNodeInboundWithClient(t, db, 1, "n1-b", 41002, "multi") createNodeInboundWithClient(t, db, 1, "n1-c", 41003, "multi") svc := &InboundService{} const email = "multi" settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email) // The three inbounds report the same email with diverging values; the // node's true per-email total is the largest (the shared global counter). sync := func(a, b, c int64) { t.Helper() snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{ {Tag: "n1-a", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: a, Down: a, Enable: true}}}, {Tag: "n1-b", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: b, Down: b, Enable: true}}}, {Tag: "n1-c", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: c, Down: c, Enable: true}}}, }} if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { t.Fatalf("sync: %v", err) } } // First-ever sync: row seeded at 0, canon (max of siblings = 100) becomes the // baseline. The node total is NOT imported as historical traffic — this prevents // ghost data from a previously-deleted account being counted as real usage. sync(100, 50, 80) assertUpDown(t, readTraffic(t, db, email), 0, 0, "first sync seeds at 0 — not the sum (230) nor the max (100)") // Second sync: canon grew from 100→150, delta=50 accrues. Siblings 60 and 90 // do not re-add their full values — the canon clamp prevents inflation. sync(150, 60, 90) assertUpDown(t, readTraffic(t, db, email), 50, 50, "second sync: only the 50-unit increment counts, not every sibling") // Equal siblings (the healthy current-schema case): canon grew from 150→200, // delta=50 accrues once. sync(200, 200, 200) assertUpDown(t, readTraffic(t, db, email), 100, 100, "equal siblings accrue the single increment") } var clientWriteSQL = regexp.MustCompile(`(?i)^\s*(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+["` + "`" + `]?(clients|client_inbounds|client_traffics|node_client_traffics)\b`) // countClientRowWrites returns, per client table, how many rows fn's // INSERT/UPDATE/DELETE statements touched. func countClientRowWrites(t *testing.T, fn func()) map[string]int64 { t.Helper() db := database.GetDB() var mu sync.Mutex rows := map[string]int64{} record := func(tx *gorm.DB) { if tx.Error != nil || tx.RowsAffected == 0 || tx.Statement == nil { return } if m := clientWriteSQL.FindStringSubmatch(tx.Statement.SQL.String()); m != nil { mu.Lock() rows[m[1]] += tx.RowsAffected mu.Unlock() } } const name = "test:count_client_row_writes" cb := db.Callback() for _, err := range []error{ cb.Create().After("gorm:create").Register(name, record), cb.Update().After("gorm:update").Register(name, record), cb.Delete().After("gorm:delete").Register(name, record), cb.Raw().After("gorm:raw").Register(name, record), } { if err != nil { t.Fatalf("register write callback: %v", err) } } defer func() { for _, err := range []error{ cb.Create().Remove(name), cb.Update().Remove(name), cb.Delete().Remove(name), cb.Raw().Remove(name), } { if err != nil { t.Errorf("remove write callback: %v", err) } } }() fn() return rows } func TestNodeMerge_WritesOnlyChangedClients(t *testing.T) { const clientCount = 20 type snapInbound struct { tag string protocol model.Protocol entry func(i int, email string) map[string]any } vlessEntry := func(flow string) func(int, string) map[string]any { return func(i int, email string) map[string]any { e := map[string]any{"email": email, "id": fmt.Sprintf("%08d-0000-4000-8000-000000000000", i), "subId": email, "enable": true} if flow != "" { e["flow"] = flow } return e } } wgEntry := func(tunnel int) func(int, string) map[string]any { return func(i int, email string) map[string]any { return map[string]any{ "email": email, "subId": email, "enable": true, "privateKey": fmt.Sprintf("priv-%d-%d", tunnel, i), "publicKey": fmt.Sprintf("pub-%d-%d", tunnel, i), "allowedIPs": []string{fmt.Sprintf("10.%d.0.%d/32", tunnel, i+2)}, "preSharedKey": fmt.Sprintf("psk-%d-%d", tunnel, i), "keepAlive": 25 * tunnel, } } } cases := []struct { name string inbounds []snapInbound }{ {"vless with and without flow", []snapInbound{ {"n1-reality", model.VLESS, vlessEntry("xtls-rprx-vision")}, {"n1-ws", model.VLESS, vlessEntry("")}, }}, {"wireguard and vless", []snapInbound{ {"n1-wg", model.WireGuard, wgEntry(1)}, {"n1-vless", model.VLESS, vlessEntry("")}, }}, {"two wireguard tunnels", []snapInbound{ {"n1-wg-a", model.WireGuard, wgEntry(1)}, {"n1-wg-b", model.WireGuard, wgEntry(2)}, }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { db := initTrafficTestDB(t) svc := &InboundService{} emails := make([]string, clientCount) stats := make([]xray.ClientTraffic, clientCount) for i := range emails { emails[i] = fmt.Sprintf("multi-%d", i) stats[i] = xray.ClientTraffic{Email: emails[i], Up: 1000, Down: 2000, Enable: true, LastOnline: 5000} } settings := make([]string, len(tc.inbounds)) for n, ib := range tc.inbounds { entries := make([]map[string]any, clientCount) for i, email := range emails { entries[i] = ib.entry(i, email) } raw, err := json.Marshal(map[string]any{"clients": entries}) if err != nil { t.Fatalf("marshal settings: %v", err) } settings[n] = string(raw) nodeID := 1 if err := db.Create(&model.Inbound{ UserId: 1, NodeID: &nodeID, Tag: ib.tag, Enable: true, Port: 42000 + n, Protocol: ib.protocol, Settings: settings[n], }).Error; err != nil { t.Fatalf("create inbound %q: %v", ib.tag, err) } } merge := func(stats []xray.ClientTraffic) { t.Helper() // A node copies a multi-attached client's one counter onto every inbound it is on. snap := &runtime.TrafficSnapshot{} for n, ib := range tc.inbounds { snap.Inbounds = append(snap.Inbounds, &model.Inbound{ Tag: ib.tag, Enable: true, Port: 42000 + n, Protocol: ib.protocol, Settings: settings[n], ClientStats: slices.Clone(stats), }) } if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { t.Fatalf("setRemoteTrafficLocked: %v", err) } } merge(stats) if got := countClientRowWrites(t, func() { merge(stats) }); len(got) != 0 { t.Errorf("repeating an unchanged snapshot wrote client rows %v, want none", got) } active := slices.Clone(stats) active[0].Up, active[0].Down, active[0].LastOnline = 1300, 2600, 6000 got := countClientRowWrites(t, func() { merge(active) }) want := map[string]int64{"client_traffics": 1, "node_client_traffics": 1} if !maps.Equal(got, want) { t.Errorf("one client's traffic tick wrote client rows %v, want %v", got, want) } ct := readTraffic(t, db, emails[0]) assertUpDown(t, ct, 300, 600, "active client after its tick") if ct.LastOnline != 6000 { t.Errorf("last_online = %d, want 6000", ct.LastOnline) } }) } } func TestDelClientStat_CleansNodeBaselines(t *testing.T) { db := initTrafficTestDB(t) svc := &InboundService{} const email = "gone" if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: email, Enable: true}).Error; err != nil { t.Fatalf("seed client_traffics: %v", err) } if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: email, Up: 10, Down: 10}).Error; err != nil { t.Fatalf("seed node baseline 1: %v", err) } if err := db.Create(&model.NodeClientTraffic{NodeId: 2, Email: email, Up: 20, Down: 20}).Error; err != nil { t.Fatalf("seed node baseline 2: %v", err) } if err := svc.DelClientStat(db, email); err != nil { t.Fatalf("DelClientStat: %v", err) } var cnt int64 if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil { t.Fatalf("count baselines: %v", err) } if cnt != 0 { t.Errorf("expected node baselines cleaned, found %d", cnt) } } func TestNodeDelete_CleansNodeBaselines(t *testing.T) { db := initTrafficTestDB(t) nodeSvc := NodeService{} if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "a", Up: 1, Down: 1}).Error; err != nil { t.Fatalf("seed node 7 a: %v", err) } if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "b", Up: 2, Down: 2}).Error; err != nil { t.Fatalf("seed node 7 b: %v", err) } if err := db.Create(&model.NodeClientTraffic{NodeId: 8, Email: "c", Up: 3, Down: 3}).Error; err != nil { t.Fatalf("seed node 8 c: %v", err) } if err := nodeSvc.Delete(7); err != nil { t.Fatalf("NodeService.Delete(7): %v", err) } var sevenCnt, eightCnt int64 db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", 7).Count(&sevenCnt) db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", 8).Count(&eightCnt) if sevenCnt != 0 { t.Errorf("node 7 baselines not cleaned: %d remain", sevenCnt) } if eightCnt != 1 { t.Errorf("node 8 baseline should survive, found %d", eightCnt) } }