Files
3x-ui/internal/web/service/client_traffic_reset_order_test.go
T
MHSanaei feb8451bd1 fix(clients): zero traffic before re-enabling a client on reset
Invariant: a traffic reset leaves a quota-disabled client enabled
everywhere. ResetTrafficByEmail and BulkResetTraffic enabled the client
first and zeroed its counters afterwards. A traffic tick landing between
the two still saw the client depleted and switched it off again in
client_traffics, the record and settings. Update's direct record write
then set the record back to enabled and the reset zeroed the counters,
leaving the settings entry disabled: the client showed enabled with zero
usage but was dropped from the runtime. The periodic reset job goes through
ResetTrafficByEmail for every depleted client on its cycle, and a node push
inside Update widens the window to seconds.

Zero first, then enable: with the counters at zero the depletion predicate
no longer matches, so the tick has nothing to undo. UpdateInboundClient
re-adds the enabled user to the runtime itself.
2026-09-28 01:59:53 +02:00

92 lines
2.7 KiB
Go

package service
import (
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// A reset of a quota-disabled client must end enabled even when a traffic tick
// runs between its steps: enabling before zeroing lets the tick re-disable it.
func TestResetTrafficOfDepletedClientSurvivesTickMidReset(t *testing.T) {
resets := []struct {
name string
run func() error
}{
{"single", func() error {
_, err := (&ClientService{}).ResetTrafficByEmail(&InboundService{}, "d@stale")
return err
}},
{"bulk", func() error {
_, err := (&ClientService{}).BulkResetTraffic(&InboundService{}, []string{"d@stale"})
return err
}},
}
for _, reset := range resets {
t.Run(reset.name, func(t *testing.T) {
requireResetSurvivesTick(t, reset.run)
})
}
}
func requireResetSurvivesTick(t *testing.T, reset func() error) {
t.Helper()
setupBulkDB(t)
clients := []model.Client{{Email: "d@stale", ID: "aaaaaaaa-0000-0000-0000-00000000000d", SubID: "sub-d", Enable: false, TotalGB: 1000}}
ib := mkInbound(t, 23140, model.VLESS, clientsSettings(t, clients))
if err := (&ClientService{}).SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
row := xray.ClientTraffic{InboundId: ib.Id, Email: "d@stale", Enable: false, Up: 600, Down: 400, Total: 1000}
if err := database.GetDB().Create(&row).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
resetTrafficWriterForTest(t)
StartTrafficWriter()
parked := make(chan struct{})
release := make(chan struct{})
go func() {
_ = submitTrafficWrite(func() error {
close(parked)
<-release
return nil
})
}()
<-parked
resetDone := make(chan error, 1)
go func() { resetDone <- reset() }()
waitTrafficWriterQueued(t)
tickDone := make(chan error, 1)
go func() {
_, _, err := (&InboundService{}).AddTraffic(nil, nil)
tickDone <- err
}()
deadline := time.Now().Add(5 * time.Second)
for len(twQueue) < 2 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
close(release)
if err := <-resetDone; err != nil {
t.Fatalf("reset: %v", err)
}
if err := <-tickDone; err != nil {
t.Fatalf("AddTraffic: %v", err)
}
var after xray.ClientTraffic
if err := database.GetDB().Where("email = ?", "d@stale").First(&after).Error; err != nil {
t.Fatalf("read client_traffics: %v", err)
}
c, _ := settingsClient(t, ib.Id, "d@stale")
rec := lookupClientRecord(t, "d@stale")
if !after.Enable || !c.Enable || !rec.Enable || after.Up+after.Down != 0 {
t.Fatalf("after reset: traffic enable=%v used=%d, settings enable=%v, record enable=%v; want all enabled at 0",
after.Enable, after.Up+after.Down, c.Enable, rec.Enable)
}
}