Files
3x-ui/internal/web/service/client_update_reenable_test.go
T
Mahdi Fallahnejhad dbb6212988 fix(clients): restore depleted clients on renewal and prioritize operator writes (#6987)
Restore usable clients when renewed, avoid duplicate reset updates, and prioritize operator writes in the serialized traffic writer. Preserve manual disables and keep every inbound consistent after traffic resets.
2026-11-05 18:07:52 +01:00

285 lines
9.6 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"
)
// renewalReenableDay keeps the seeds one day past (or before) their boundary.
const renewalReenableDay = int64(24 * 60 * 60 * 1000)
// seedRenewableDepletedClient seeds a local inbound whose client the quota loop
// switched off: record off, inbound JSON off, and the traffic row off and depleted.
func seedRenewableDepletedClient(t *testing.T, svc *ClientService, port int, email string, total, expiry, up, down int64) *model.Inbound {
t.Helper()
ib := seedLocalDisabledClient(t, svc, port, "", email, total, expiry, up, down)
if got := trafficOf(t, email); got.Enable {
t.Fatalf("seed %s: want an off traffic row, got %+v", email, got)
}
return ib
}
func TestUpdate_ReenablesDepletedClient_WhenExpiryExtended(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
now := time.Now().UnixMilli()
email := "renew-exp@x"
ib := seedRenewableDepletedClient(t, svc, 53101, email, 0, now-2*renewalReenableDay, 0, 0)
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
updated := rec.ToClient()
updated.ExpiryTime = now + 30*renewalReenableDay
// The editor round-trips the switch as the stored off state; the renewal
// must turn it back on by itself.
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
if got := trafficOf(t, email).ExpiryTime; got != now+30*renewalReenableDay {
t.Fatalf("%s: expiry = %d, want %d", email, got, now+30*renewalReenableDay)
}
}
func TestUpdate_ReenablesDepletedClient_WhenTotalRaised(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
email := "renew-total@x"
ib := seedRenewableDepletedClient(t, svc, 53102, email, 100, 0, 60, 40)
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
updated := rec.ToClient()
updated.TotalGB = 300
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
if got := trafficOf(t, email).Total; got != 300 {
t.Fatalf("%s: total = %d, want 300", email, got)
}
}
func TestUpdate_KeepsDisabled_StillExpiredAfterUpdate(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
now := time.Now().UnixMilli()
email := "renew-sml@x"
ib := seedRenewableDepletedClient(t, svc, 53103, email, 0, now-10*renewalReenableDay, 0, 0)
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
updated := rec.ToClient()
updated.ExpiryTime = now - 5*renewalReenableDay // an extension that still lands in the past
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
}
func TestUpdate_KeepsDisabled_StillOverQuotaAfterUpdate(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
email := "renew-quota@x"
ib := seedRenewableDepletedClient(t, svc, 53104, email, 100, 0, 60, 40)
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
updated := rec.ToClient()
updated.TotalGB = 80 // still below the 100 bytes of usage
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
}
func TestUpdate_KeepsDisabled_ManuallyDisabledNotDepleted(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
now := time.Now().UnixMilli()
email := "renew-manual@x"
ib := seedRenewableDepletedClient(t, svc, 53105, email, 0, now+30*renewalReenableDay, 0, 0)
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
updated := rec.ToClient()
updated.ExpiryTime = now + 60*renewalReenableDay
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
// The quota loop never switched this client off — it reads under quota —
// so a manual switch-off survives a renewal.
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
}
func TestUpdate_ReenablesDepletedClient_SkipsStillDepletedGlobalUsage(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
now := time.Now().UnixMilli()
email := "renew-global@x"
ib := seedRenewableDepletedClient(t, svc, 53106, email, 100, now+30*renewalReenableDay, 0, 0)
// A fresh master push depletes the local limits even though up/down read 0.
global := model.ClientGlobalTraffic{MasterGuid: "master-a", Email: email, Up: 90, Down: 60}
if err := database.GetDB().Create(&global).Error; err != nil {
t.Fatalf("seed global traffic: %v", err)
}
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
updated := rec.ToClient()
updated.TotalGB = 100
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
// Raising the quota above the pushed usage re-enables it.
updated.TotalGB = 1000
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
}
func TestUpdate_ReenablesDepletedClient_WithoutInbound(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
now := time.Now().UnixMilli()
email := "renew-noib@x"
rec := &model.ClientRecord{
Email: email,
UUID: "44444444-4444-4444-4444-444444444444",
SubID: email,
}
if err := database.GetDB().Create(rec).Error; err != nil {
t.Fatalf("create record: %v", err)
}
forceRecordDisabled(t, svc, email)
if err := database.GetDB().Create(&xray.ClientTraffic{
Email: email, Enable: false, ExpiryTime: now - 2*renewalReenableDay,
}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
updated := rec.ToClient()
updated.ExpiryTime = now + 30*renewalReenableDay
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
if got := recordEnableOf(t, svc, email); !got {
t.Fatalf("%s: client_records.enable = false, want true", email)
}
if got := trafficOf(t, email).Enable; !got {
t.Fatalf("%s: client_traffics.enable = false, want true (no-inbound re-enable gap)", email)
}
}
func TestResetClientExpiryTimeByEmail_ReenablesDepletedClient(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
now := time.Now().UnixMilli()
email := "renew-setter@x"
ib := seedRenewableDepletedClient(t, svc, 53107, email, 0, now-2*renewalReenableDay, 0, 0)
if _, err := svc.ResetClientExpiryTimeByEmail(inboundSvc, email, now+30*renewalReenableDay); err != nil {
t.Fatalf("ResetClientExpiryTimeByEmail: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
}
func TestResetClientExpiryTimeByEmail_KeepsDisabled_ManuallyDisabledNotDepleted(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
now := time.Now().UnixMilli()
email := "renew-setter-off@x"
ib := seedRenewableDepletedClient(t, svc, 53108, email, 0, now+30*renewalReenableDay, 0, 0)
if _, err := svc.ResetClientExpiryTimeByEmail(inboundSvc, email, now+60*renewalReenableDay); err != nil {
t.Fatalf("ResetClientExpiryTimeByEmail: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
}
func TestResetClientTrafficLimitByEmail_ReenablesDepletedClient(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
email := "renew-setter-total@x"
ib := seedRenewableDepletedClient(t, svc, 53109, email, 100, 0, 60, 40)
if _, err := svc.ResetClientTrafficLimitByEmail(inboundSvc, email, 1); err != nil {
t.Fatalf("ResetClientTrafficLimitByEmail: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
if got := trafficOf(t, email).Total; got != 1<<30 {
t.Fatalf("%s: total = %d, want %d", email, got, 1<<30)
}
}
// A client living on several inbounds must come back on every one of them,
// not only the inbound whose reset ran first.
func TestResetTrafficByEmail_ReenablesEveryInboundOfTheClient(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
email := "renew-multi@x"
c := model.Client{
Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email,
Enable: false, TotalGB: 1000,
}
ibA := mkInbound(t, 53201, model.VLESS, clientsSettings(t, []model.Client{c}))
ibB := mkInbound(t, 53202, model.VLESS, clientsSettings(t, []model.Client{c}))
for _, ib := range []*model.Inbound{ibA, ibB} {
if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
t.Fatalf("SyncInbound %d: %v", ib.Id, err)
}
}
mkTraffic(t, ibA.Id, email, 600, 500, 1000, 0, false)
forceRecordDisabled(t, svc, email)
if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil {
t.Fatalf("ResetTrafficByEmail: %v", err)
}
assertEnableEverywhere(t, svc, inboundSvc, ibA.Id, email, true)
assertEnableEverywhere(t, svc, inboundSvc, ibB.Id, email, true)
if got := trafficOf(t, email); !got.Enable || got.Up+got.Down != 0 {
t.Fatalf("%s: traffic row enable=%v used=%d, want enabled at 0", email, got.Enable, got.Up+got.Down)
}
}