|
|
@@ -4,7 +4,9 @@ import (
|
|
|
"encoding/json"
|
|
|
"testing"
|
|
|
|
|
|
+ "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"
|
|
|
)
|
|
|
|
|
|
// TestExportImportPreservesDisabledEnable covers #6478: ExportAll keeps the
|
|
|
@@ -169,3 +171,289 @@ func TestBulkCreate_DisabledOnNodeSkipsAddClient(t *testing.T) {
|
|
|
t.Fatal("disabled node create must leave node dirty for reconcile")
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+func TestExportImportPreservesTrafficCounters(t *testing.T) {
|
|
|
+ setupBulkDB(t)
|
|
|
+ svc := &ClientService{}
|
|
|
+ inboundSvc := &InboundService{}
|
|
|
+
|
|
|
+ ib := mkInbound(t, 25001, model.VLESS, `{"clients":[]}`)
|
|
|
+ const email = "portable@traffic"
|
|
|
+ const subID = "sub-portable-traffic"
|
|
|
+ if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
|
|
+ Client: model.Client{
|
|
|
+ Email: email, SubID: subID, Enable: true,
|
|
|
+ TotalGB: 10 << 30, ExpiryTime: 1_700_000_000_000,
|
|
|
+ },
|
|
|
+ InboundIds: []int{ib.Id},
|
|
|
+ }); err != nil {
|
|
|
+ t.Fatalf("Create: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ db := database.GetDB()
|
|
|
+ if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Updates(map[string]any{
|
|
|
+ "up": 111, "down": 222, "reset_count": 3, "last_online": 999,
|
|
|
+ }).Error; err != nil {
|
|
|
+ t.Fatalf("seed traffic: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ exported, err := svc.ExportAll()
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("ExportAll: %v", err)
|
|
|
+ }
|
|
|
+ if len(exported) != 1 {
|
|
|
+ t.Fatalf("ExportAll len=%d, want 1", len(exported))
|
|
|
+ }
|
|
|
+ if exported[0].Traffic == nil {
|
|
|
+ t.Fatal("ExportAll missing traffic snapshot")
|
|
|
+ }
|
|
|
+ if exported[0].Traffic.Up != 111 || exported[0].Traffic.Down != 222 || exported[0].Traffic.ResetCount != 3 {
|
|
|
+ t.Fatalf("exported traffic = %+v, want up=111 down=222 resetCount=3", exported[0].Traffic)
|
|
|
+ }
|
|
|
+
|
|
|
+ raw, err := json.Marshal(exported)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("marshal export: %v", err)
|
|
|
+ }
|
|
|
+ var roundTrip []ClientCreatePayload
|
|
|
+ if err := json.Unmarshal(raw, &roundTrip); err != nil {
|
|
|
+ t.Fatalf("unmarshal export: %v", err)
|
|
|
+ }
|
|
|
+ if roundTrip[0].Traffic == nil || roundTrip[0].Traffic.Up != 111 {
|
|
|
+ t.Fatalf("JSON round-trip lost traffic: %+v", roundTrip[0].Traffic)
|
|
|
+ }
|
|
|
+
|
|
|
+ rec := lookupClientRecord(t, email)
|
|
|
+ if _, err := svc.Delete(inboundSvc, rec.Id, false); err != nil {
|
|
|
+ t.Fatalf("Delete: %v", err)
|
|
|
+ }
|
|
|
+ var gone int64
|
|
|
+ if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Count(&gone).Error; err != nil {
|
|
|
+ t.Fatalf("count after delete: %v", err)
|
|
|
+ }
|
|
|
+ if gone != 0 {
|
|
|
+ t.Fatalf("client_traffics still present after delete: %d", gone)
|
|
|
+ }
|
|
|
+
|
|
|
+ res, _, err := svc.ImportClients(inboundSvc, roundTrip)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("ImportClients: %v", err)
|
|
|
+ }
|
|
|
+ if res.Created != 1 || len(res.Skipped) != 0 {
|
|
|
+ t.Fatalf("ImportClients result=%+v", res)
|
|
|
+ }
|
|
|
+ var restored xray.ClientTraffic
|
|
|
+ if err := db.Where("email = ?", email).First(&restored).Error; err != nil {
|
|
|
+ t.Fatalf("lookup restored traffic: %v", err)
|
|
|
+ }
|
|
|
+ if restored.Up != 111 || restored.Down != 222 || restored.ResetCount != 3 || restored.LastOnline != 999 {
|
|
|
+ t.Fatalf("restored traffic = %+v, want up=111 down=222 resetCount=3 lastOnline=999", restored)
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Updates(map[string]any{
|
|
|
+ "up": 5000, "down": 6000,
|
|
|
+ }).Error; err != nil {
|
|
|
+ t.Fatalf("bump live traffic: %v", err)
|
|
|
+ }
|
|
|
+ // Same email+subId is a BulkCreate reuse (may count as Created), not a hard
|
|
|
+ // skip — traffic apply must still refuse to overwrite the live counters.
|
|
|
+ if _, _, err := svc.ImportClients(inboundSvc, roundTrip); err != nil {
|
|
|
+ t.Fatalf("second ImportClients: %v", err)
|
|
|
+ }
|
|
|
+ var live xray.ClientTraffic
|
|
|
+ if err := db.Where("email = ?", email).First(&live).Error; err != nil {
|
|
|
+ t.Fatalf("lookup live traffic: %v", err)
|
|
|
+ }
|
|
|
+ if live.Up != 5000 || live.Down != 6000 {
|
|
|
+ t.Fatalf("re-import of existing email must leave live traffic alone, got up=%d down=%d", live.Up, live.Down)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestImportClientsAppliesTrafficForOrphans(t *testing.T) {
|
|
|
+ setupBulkDB(t)
|
|
|
+ svc := &ClientService{}
|
|
|
+
|
|
|
+ items := []ClientCreatePayload{{
|
|
|
+ Client: model.Client{
|
|
|
+ Email: "orphan@traffic", SubID: "sub-orphan-traffic", Enable: true,
|
|
|
+ TotalGB: 1 << 30,
|
|
|
+ },
|
|
|
+ InboundIds: nil,
|
|
|
+ Traffic: &ClientPortableTraffic{
|
|
|
+ Up: 7, Down: 8, ResetCount: 1,
|
|
|
+ },
|
|
|
+ }}
|
|
|
+ res, _, err := svc.ImportClients(&InboundService{}, items)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("ImportClients orphan: %v", err)
|
|
|
+ }
|
|
|
+ if res.Created != 1 {
|
|
|
+ t.Fatalf("created=%d, want 1", res.Created)
|
|
|
+ }
|
|
|
+ var traf xray.ClientTraffic
|
|
|
+ if err := database.GetDB().Where("email = ?", "orphan@traffic").First(&traf).Error; err != nil {
|
|
|
+ t.Fatalf("orphan traffic row missing: %v", err)
|
|
|
+ }
|
|
|
+ if traf.Up != 7 || traf.Down != 8 || traf.ResetCount != 1 {
|
|
|
+ t.Fatalf("orphan traffic = %+v", traf)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// An orphan's restored row must carry its weekly schedule, or depletedClientsClause
|
|
|
+// treats the over-quota renewing client as depleted and DelDepleted deletes it.
|
|
|
+func TestImportClientsOrphanTrafficKeepsWeeklyRenewal(t *testing.T) {
|
|
|
+ const email = "weekly@orphan"
|
|
|
+ cases := []struct {
|
|
|
+ name string
|
|
|
+ seed func(t *testing.T, svc *ClientService, inboundSvc *InboundService)
|
|
|
+ }{
|
|
|
+ {name: "no prior row", seed: func(*testing.T, *ClientService, *InboundService) {}},
|
|
|
+ {
|
|
|
+ name: "row kept by keepTraffic delete",
|
|
|
+ seed: func(t *testing.T, svc *ClientService, inboundSvc *InboundService) {
|
|
|
+ ib := mkInbound(t, 25003, model.VLESS, `{"clients":[]}`)
|
|
|
+ if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
|
|
+ Client: model.Client{Email: email, SubID: "sub-weekly-old", Enable: true, TotalGB: 1 << 30},
|
|
|
+ InboundIds: []int{ib.Id},
|
|
|
+ }); err != nil {
|
|
|
+ t.Fatalf("Create: %v", err)
|
|
|
+ }
|
|
|
+ if _, err := svc.Delete(inboundSvc, lookupClientRecord(t, email).Id, true); err != nil {
|
|
|
+ t.Fatalf("Delete keepTraffic: %v", err)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }
|
|
|
+ for _, tc := range cases {
|
|
|
+ t.Run(tc.name, func(t *testing.T) {
|
|
|
+ setupBulkDB(t)
|
|
|
+ svc := &ClientService{}
|
|
|
+ inboundSvc := &InboundService{}
|
|
|
+ tc.seed(t, svc, inboundSvc)
|
|
|
+
|
|
|
+ items := []ClientCreatePayload{{
|
|
|
+ Client: model.Client{
|
|
|
+ Email: email, SubID: "sub-weekly-orphan", Enable: true,
|
|
|
+ TotalGB: 1 << 30, ResetWeekday: 3,
|
|
|
+ },
|
|
|
+ Traffic: &ClientPortableTraffic{Up: 1 << 30, Down: 1},
|
|
|
+ }}
|
|
|
+ if res, _, err := svc.ImportClients(inboundSvc, items); err != nil || res.Created != 1 {
|
|
|
+ t.Fatalf("ImportClients result=%+v err=%v, want 1 created", res, err)
|
|
|
+ }
|
|
|
+ deleted, _, err := svc.DelDepleted(inboundSvc)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("DelDepleted: %v", err)
|
|
|
+ }
|
|
|
+ if deleted != 0 {
|
|
|
+ t.Fatalf("DelDepleted deleted %d weekly-renewing client(s), want 0", deleted)
|
|
|
+ }
|
|
|
+ lookupClientRecord(t, email)
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Restored counters are usage from before the import, so they must not move the
|
|
|
+// group total at import time: a delete+re-import would otherwise count them twice.
|
|
|
+func TestImportClientsTrafficLeavesGroupTotalUnchanged(t *testing.T) {
|
|
|
+ t.Run("re-import after delete on the same panel", func(t *testing.T) {
|
|
|
+ setupBulkDB(t)
|
|
|
+ svc := &ClientService{}
|
|
|
+ inboundSvc := &InboundService{}
|
|
|
+ ib := mkInbound(t, 25004, model.VLESS, `{"clients":[]}`)
|
|
|
+ const email = "grouped@traffic"
|
|
|
+ if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
|
|
+ Client: model.Client{Email: email, SubID: "sub-grouped", Enable: true, Group: "g"},
|
|
|
+ InboundIds: []int{ib.Id},
|
|
|
+ }); err != nil {
|
|
|
+ t.Fatalf("Create: %v", err)
|
|
|
+ }
|
|
|
+ if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", email).
|
|
|
+ Updates(map[string]any{"up": 100, "down": 200}).Error; err != nil {
|
|
|
+ t.Fatalf("seed traffic: %v", err)
|
|
|
+ }
|
|
|
+ exported, err := svc.ExportAll()
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("ExportAll: %v", err)
|
|
|
+ }
|
|
|
+ if _, err := svc.Delete(inboundSvc, lookupClientRecord(t, email).Id, false); err != nil {
|
|
|
+ t.Fatalf("Delete: %v", err)
|
|
|
+ }
|
|
|
+ if g := groupByName(t, svc, "g"); g.TrafficUsed != 300 {
|
|
|
+ t.Fatalf("group after delete = %d, want the kept 300", g.TrafficUsed)
|
|
|
+ }
|
|
|
+
|
|
|
+ if res, _, err := svc.ImportClients(inboundSvc, exported); err != nil || res.Created != 1 {
|
|
|
+ t.Fatalf("ImportClients result=%+v err=%v, want 1 created", res, err)
|
|
|
+ }
|
|
|
+ if g := groupByName(t, svc, "g"); g.Up != 100 || g.Down != 200 {
|
|
|
+ t.Fatalf("group after re-import up=%d down=%d, want unchanged 100/200", g.Up, g.Down)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("new panel starts the group at zero", func(t *testing.T) {
|
|
|
+ setupBulkDB(t)
|
|
|
+ svc := &ClientService{}
|
|
|
+ ib := mkInbound(t, 25005, model.VLESS, `{"clients":[]}`)
|
|
|
+ items := []ClientCreatePayload{
|
|
|
+ {Client: model.Client{Email: "attached@g", SubID: "sub-attached-g", Enable: true, Group: "g"}, InboundIds: []int{ib.Id}, Traffic: &ClientPortableTraffic{Up: 100, Down: 200}},
|
|
|
+ {Client: model.Client{Email: "orphan@g", SubID: "sub-orphan-g", Enable: true, Group: "g"}, Traffic: &ClientPortableTraffic{Up: 10, Down: 20}},
|
|
|
+ }
|
|
|
+ if res, _, err := svc.ImportClients(&InboundService{}, items); err != nil || res.Created != 2 {
|
|
|
+ t.Fatalf("ImportClients result=%+v err=%v, want 2 created", res, err)
|
|
|
+ }
|
|
|
+ if g := groupByName(t, svc, "g"); g.TrafficUsed != 0 {
|
|
|
+ t.Fatalf("group after import = %d (up=%d down=%d), want 0", g.TrafficUsed, g.Up, g.Down)
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// A duplicate email in the file is skipped, but the copy that was created must
|
|
|
+// still get its own counters rather than none or the skipped copy's.
|
|
|
+func TestImportClientsDuplicateEmailRestoresCreatedCopy(t *testing.T) {
|
|
|
+ cases := []struct {
|
|
|
+ name string
|
|
|
+ items func(ibID int) []ClientCreatePayload
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "second attached copy skipped",
|
|
|
+ items: func(ibID int) []ClientCreatePayload {
|
|
|
+ return []ClientCreatePayload{
|
|
|
+ {Client: model.Client{Email: "dup@traffic", SubID: "sub-dup-a", Enable: true}, InboundIds: []int{ibID}, Traffic: &ClientPortableTraffic{Up: 11, Down: 12}},
|
|
|
+ {Client: model.Client{Email: "dup@traffic", SubID: "sub-dup-b", Enable: true}, InboundIds: []int{ibID}, Traffic: &ClientPortableTraffic{Up: 99, Down: 99}},
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "earlier orphan copy skipped",
|
|
|
+ items: func(ibID int) []ClientCreatePayload {
|
|
|
+ return []ClientCreatePayload{
|
|
|
+ {Client: model.Client{Email: "dup@traffic", SubID: "sub-dup-b", Enable: true}, Traffic: &ClientPortableTraffic{Up: 99, Down: 99}},
|
|
|
+ {Client: model.Client{Email: "dup@traffic", SubID: "sub-dup-a", Enable: true}, InboundIds: []int{ibID}, Traffic: &ClientPortableTraffic{Up: 11, Down: 12}},
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }
|
|
|
+ for _, tc := range cases {
|
|
|
+ t.Run(tc.name, func(t *testing.T) {
|
|
|
+ setupBulkDB(t)
|
|
|
+ svc := &ClientService{}
|
|
|
+ ib := mkInbound(t, 25002, model.VLESS, `{"clients":[]}`)
|
|
|
+
|
|
|
+ res, _, err := svc.ImportClients(&InboundService{}, tc.items(ib.Id))
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("ImportClients: %v", err)
|
|
|
+ }
|
|
|
+ if res.Created != 1 || len(res.Skipped) != 1 {
|
|
|
+ t.Fatalf("ImportClients result=%+v, want 1 created and 1 skipped", res)
|
|
|
+ }
|
|
|
+ var row xray.ClientTraffic
|
|
|
+ if err := database.GetDB().Where("email = ?", "dup@traffic").First(&row).Error; err != nil {
|
|
|
+ t.Fatalf("lookup traffic: %v", err)
|
|
|
+ }
|
|
|
+ if row.Up != 11 || row.Down != 12 {
|
|
|
+ t.Fatalf("traffic up=%d down=%d, want the created copy's 11/12", row.Up, row.Down)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|