package service import ( "slices" "strconv" "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" ) const ( pagingDay = int64(86400000) pagingGB = int64(1) << 30 ) type pagingSeed struct { email string enable bool totalGB int64 expiryTime int64 used int64 subID string uuid string password string auth string comment string group string tgID int64 reset int lastOnline int64 inbounds []int } // seedPagingClients writes one vless and one trojan inbound plus a fixed client // set covering every bucket, sort key and search field ListPaged supports. // Returns "now" so the expectations can be phrased relative to it. func seedPagingClients(t *testing.T) (int64, []pagingSeed) { t.Helper() db := database.GetDB() now := time.Now().UnixMilli() vless := &model.Inbound{UserId: 1, Tag: "in-vless", Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`} trojan := &model.Inbound{UserId: 1, Tag: "in-trojan", Enable: true, Port: 40002, Protocol: model.Trojan, Settings: `{"clients":[]}`} for _, ib := range []*model.Inbound{vless, trojan} { if err := db.Create(ib).Error; err != nil { t.Fatalf("create inbound %s: %v", ib.Tag, err) } } seeds := []pagingSeed{ {email: "alpha@x", enable: true, totalGB: 0, expiryTime: 0, used: 5 * pagingGB, subID: "sub-alpha", uuid: "uuid-alpha", inbounds: []int{vless.Id}, lastOnline: now - 10*pagingDay}, {email: "bravo@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: pagingGB, password: "pw-bravo", inbounds: []int{vless.Id, trojan.Id}, lastOnline: now - pagingDay}, {email: "charlie@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10 * pagingGB, auth: "auth-charlie", inbounds: []int{trojan.Id}}, {email: "delta@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, used: pagingGB, inbounds: []int{vless.Id}}, {email: "echo@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, inbounds: []int{vless.Id}}, {email: "foxtrot@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, inbounds: []int{trojan.Id}}, {email: "golf@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 2*pagingDay, inbounds: []int{vless.Id}}, {email: "hotel@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10*pagingGB - pagingGB/2, inbounds: []int{vless.Id}}, {email: "india@x", enable: true, totalGB: 0, expiryTime: -5 * pagingDay, inbounds: []int{vless.Id}}, {email: "juliet@x", enable: true, comment: " vip customer ", group: "VIP", tgID: 555, reset: 7, inbounds: []int{vless.Id}}, {email: "kilo_1@x", enable: true, group: "vip", inbounds: nil}, {email: "kilo1@x", enable: true, inbounds: []int{trojan.Id}}, } for i, s := range seeds { rec := model.ClientRecord{ Email: s.email, SubID: s.subID, UUID: s.uuid, Password: s.password, Auth: s.auth, Comment: s.comment, Group: s.group, TgID: s.tgID, Reset: s.reset, Enable: s.enable, TotalGB: s.totalGB, ExpiryTime: s.expiryTime, CreatedAt: now - int64(len(seeds)-i)*pagingDay, UpdatedAt: now - int64(i)*pagingDay, } if err := db.Create(&rec).Error; err != nil { t.Fatalf("create client %s: %v", s.email, err) } if !s.enable { // clients.enable carries a `default:true` tag, so GORM leaves the // zero value out of the INSERT and the column comes back true. // Restate updated_at so the autoUpdateTime hook cannot reshuffle // the sort fixtures. if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id). Updates(map[string]any{"enable": false, "updated_at": rec.UpdatedAt}).Error; err != nil { t.Fatalf("disable %s: %v", s.email, err) } } traffic := xray.ClientTraffic{ Email: s.email, Enable: s.enable, Up: s.used / 2, Down: s.used - s.used/2, Total: s.totalGB, ExpiryTime: s.expiryTime, LastOnline: s.lastOnline, } if err := db.Create(&traffic).Error; err != nil { t.Fatalf("create traffic %s: %v", s.email, err) } for _, id := range s.inbounds { if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: id}).Error; err != nil { t.Fatalf("attach %s to %d: %v", s.email, id, err) } } } return now, seeds } func pagedEmails(items []ClientSlim) []string { out := make([]string, 0, len(items)) for _, it := range items { out = append(out, it.Email) } return out } func setupPagingServices(t *testing.T) (*ClientService, *InboundService, *SettingService) { t.Helper() setupBulkDB(t) settingSvc := &SettingService{} if err := settingSvc.setInt("expireDiff", 3); err != nil { t.Fatalf("set expireDiff: %v", err) } if err := settingSvc.setInt("trafficDiff", 1); err != nil { t.Fatalf("set trafficDiff: %v", err) } return &ClientService{}, &InboundService{}, settingSvc } func TestListPagedFilters(t *testing.T) { svc, inboundSvc, settingSvc := setupPagingServices(t) now, _ := seedPagingClients(t) tests := []struct { name string params ClientPageParams want []string }{ { name: "no filter returns every client in id order", params: ClientPageParams{PageSize: 50}, want: []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"}, }, { name: "depleted bucket covers quota and expiry", params: ClientPageParams{PageSize: 50, Filter: "depleted"}, want: []string{"charlie@x", "delta@x", "foxtrot@x"}, }, { name: "deactive bucket is every disabled client", params: ClientPageParams{PageSize: 50, Filter: "deactive"}, want: []string{"echo@x", "foxtrot@x"}, }, { name: "expiring bucket covers near expiry and near quota", params: ClientPageParams{PageSize: 50, Filter: "expiring"}, want: []string{"golf@x", "hotel@x"}, }, { name: "active bucket keeps enabled clients that still have room", params: ClientPageParams{PageSize: 50, Filter: "active"}, want: []string{"alpha@x", "bravo@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"}, }, { name: "buckets are ORed", params: ClientPageParams{PageSize: 50, Filter: "depleted,expiring"}, want: []string{"charlie@x", "delta@x", "foxtrot@x", "golf@x", "hotel@x"}, }, { name: "unknown bucket keeps matching everything", params: ClientPageParams{PageSize: 50, Filter: "nonsense"}, want: []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"}, }, { name: "protocol filter follows the attachments", params: ClientPageParams{PageSize: 50, Protocol: "trojan"}, want: []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"}, }, { name: "inbound filter follows the attachments", params: ClientPageParams{PageSize: 50, Inbound: "2"}, want: []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"}, }, { name: "search matches the email", params: ClientPageParams{PageSize: 50, Search: "KILO"}, want: []string{"kilo_1@x", "kilo1@x"}, }, { name: "search treats LIKE wildcards literally", params: ClientPageParams{PageSize: 50, Search: "kilo_1"}, want: []string{"kilo_1@x"}, }, { name: "search matches the subId", params: ClientPageParams{PageSize: 50, Search: "sub-alpha"}, want: []string{"alpha@x"}, }, { name: "search matches the uuid", params: ClientPageParams{PageSize: 50, Search: "uuid-alpha"}, want: []string{"alpha@x"}, }, { name: "search matches the password", params: ClientPageParams{PageSize: 50, Search: "pw-bravo"}, want: []string{"bravo@x"}, }, { name: "search matches the auth", params: ClientPageParams{PageSize: 50, Search: "auth-charlie"}, want: []string{"charlie@x"}, }, { name: "search matches the comment", params: ClientPageParams{PageSize: 50, Search: "vip customer"}, want: []string{"juliet@x"}, }, { name: "search matches the telegram id", params: ClientPageParams{PageSize: 50, Search: "555"}, want: []string{"juliet@x"}, }, { name: "group filter is case insensitive", params: ClientPageParams{PageSize: 50, Group: "vip"}, want: []string{"juliet@x", "kilo_1@x"}, }, { name: "hasComment yes", params: ClientPageParams{PageSize: 50, HasComment: "yes"}, want: []string{"juliet@x"}, }, { name: "hasTgId yes", params: ClientPageParams{PageSize: 50, HasTgID: "yes"}, want: []string{"juliet@x"}, }, { name: "autoRenew on", params: ClientPageParams{PageSize: 50, AutoRenew: "on"}, want: []string{"juliet@x"}, }, { name: "usage range is inclusive on both bounds", params: ClientPageParams{PageSize: 50, UsageFrom: pagingGB, UsageTo: 5 * pagingGB}, want: []string{"alpha@x", "bravo@x", "delta@x"}, }, { name: "expiry range excludes never and delayed start", params: ClientPageParams{PageSize: 50, ExpiryFrom: now, ExpiryTo: now + 10*pagingDay}, want: []string{"golf@x"}, }, { name: "filters combine with AND", params: ClientPageParams{PageSize: 50, Filter: "depleted", Protocol: "trojan"}, want: []string{"charlie@x", "foxtrot@x"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { resp, err := svc.ListPaged(inboundSvc, settingSvc, tc.params) if err != nil { t.Fatalf("ListPaged: %v", err) } got := pagedEmails(resp.Items) if !slices.Equal(got, tc.want) { t.Fatalf("emails = %v, want %v", got, tc.want) } if resp.Filtered != len(tc.want) { t.Fatalf("filtered = %d, want %d", resp.Filtered, len(tc.want)) } if resp.Total != 12 { t.Fatalf("total = %d, want 12", resp.Total) } }) } } func TestListPagedSorting(t *testing.T) { svc, inboundSvc, settingSvc := setupPagingServices(t) seedPagingClients(t) tests := []struct { name string sort string order string want []string }{ { name: "no sort key keeps insertion order", want: []string{"alpha@x", "bravo@x", "charlie@x"}, }, { name: "email ascending", sort: "email", order: "ascend", want: []string{"alpha@x", "bravo@x", "charlie@x"}, }, { name: "email descending", sort: "email", order: "descend", want: []string{"kilo_1@x", "kilo1@x", "juliet@x"}, }, { name: "traffic descending", sort: "traffic", order: "descend", want: []string{"charlie@x", "hotel@x", "alpha@x"}, }, { name: "remaining descending puts unlimited quotas first", sort: "remaining", order: "descend", want: []string{"alpha@x", "india@x", "juliet@x"}, }, { name: "expiry ascending starts with the expired", sort: "expiryTime", order: "ascend", want: []string{"delta@x", "foxtrot@x", "golf@x"}, }, { name: "createdAt ascending follows insertion", sort: "createdAt", order: "ascend", want: []string{"alpha@x", "bravo@x", "charlie@x"}, }, { name: "updatedAt descending starts with the newest", sort: "updatedAt", order: "descend", want: []string{"alpha@x", "bravo@x", "charlie@x"}, }, { name: "lastOnline descending breaks ties on the id, reversed too", sort: "lastOnline", order: "descend", want: []string{"bravo@x", "alpha@x", "kilo1@x"}, }, { name: "enable ascending puts disabled first", sort: "enable", order: "ascend", want: []string{"echo@x", "foxtrot@x", "alpha@x"}, }, { name: "inboundIds descending puts the widest attachment first", sort: "inboundIds", order: "descend", want: []string{"bravo@x", "alpha@x", "charlie@x"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 3, Sort: tc.sort, Order: tc.order}) if err != nil { t.Fatalf("ListPaged: %v", err) } got := pagedEmails(resp.Items) if !slices.Equal(got, tc.want) { t.Fatalf("emails = %v, want %v", got, tc.want) } }) } } func TestListPagedPagination(t *testing.T) { svc, inboundSvc, settingSvc := setupPagingServices(t) seedPagingClients(t) t.Run("second page continues where the first stopped", func(t *testing.T) { resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 2, PageSize: 5, Sort: "email", Order: "ascend"}) if err != nil { t.Fatalf("ListPaged: %v", err) } want := []string{"foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x"} if got := pagedEmails(resp.Items); !slices.Equal(got, want) { t.Fatalf("emails = %v, want %v", got, want) } if resp.Page != 2 || resp.PageSize != 5 { t.Fatalf("page/pageSize = %d/%d, want 2/5", resp.Page, resp.PageSize) } }) t.Run("page past the end is empty but keeps the counts", func(t *testing.T) { resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 9, PageSize: 5}) if err != nil { t.Fatalf("ListPaged: %v", err) } if len(resp.Items) != 0 { t.Fatalf("items = %v, want none", pagedEmails(resp.Items)) } if resp.Filtered != 12 || resp.Total != 12 { t.Fatalf("filtered/total = %d/%d, want 12/12", resp.Filtered, resp.Total) } }) t.Run("page size is clamped to the maximum", func(t *testing.T) { resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 5000}) if err != nil { t.Fatalf("ListPaged: %v", err) } if resp.PageSize != clientPageMaxSize { t.Fatalf("pageSize = %d, want %d", resp.PageSize, clientPageMaxSize) } }) } func TestListPagedRowContents(t *testing.T) { svc, inboundSvc, settingSvc := setupPagingServices(t) seedPagingClients(t) resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50}) if err != nil { t.Fatalf("ListPaged: %v", err) } byEmail := make(map[string]ClientSlim, len(resp.Items)) for _, it := range resp.Items { byEmail[it.Email] = it } t.Run("attachments are reported in inbound order", func(t *testing.T) { got := byEmail["bravo@x"].InboundIds if !slices.Equal(got, []int{1, 2}) { t.Fatalf("inboundIds = %v, want [1 2]", got) } }) t.Run("an unattached client reports no inbounds", func(t *testing.T) { if got := byEmail["kilo_1@x"].InboundIds; len(got) != 0 { t.Fatalf("inboundIds = %v, want none", got) } }) t.Run("traffic counters ride along with the row", func(t *testing.T) { got := byEmail["hotel@x"].Traffic if got == nil { t.Fatal("traffic = nil, want the seeded counters") } if want := 10*pagingGB - pagingGB/2; got.Up+got.Down != want { t.Fatalf("used = %d, want %d", got.Up+got.Down, want) } }) t.Run("groups list every name in use", func(t *testing.T) { if !slices.Equal(resp.Groups, []string{"vip", "VIP"}) && !slices.Equal(resp.Groups, []string{"VIP", "vip"}) { t.Fatalf("groups = %v, want VIP and vip", resp.Groups) } }) } func TestListPagedSummary(t *testing.T) { svc, inboundSvc, settingSvc := setupPagingServices(t) seedPagingClients(t) resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 5, Filter: "depleted"}) if err != nil { t.Fatalf("ListPaged: %v", err) } s := resp.Summary t.Run("counts stay whole-panel while the page is filtered", func(t *testing.T) { if s.Total != 12 { t.Fatalf("total = %d, want 12", s.Total) } if s.DepletedCount != 3 { t.Fatalf("depletedCount = %d, want 3", s.DepletedCount) } if s.ExpiringCount != 2 { t.Fatalf("expiringCount = %d, want 2", s.ExpiringCount) } if s.DeactiveCount != 1 { t.Fatalf("deactiveCount = %d, want 1", s.DeactiveCount) } if s.Active != 6 { t.Fatalf("active = %d, want 6", s.Active) } }) t.Run("every client lands in exactly one counter", func(t *testing.T) { if sum := s.Active + s.DepletedCount + s.ExpiringCount + s.DeactiveCount; sum != s.Total { t.Fatalf("buckets sum to %d, want %d", sum, s.Total) } }) t.Run("bucket lists carry the matching emails", func(t *testing.T) { if want := []string{"charlie@x", "delta@x", "foxtrot@x"}; !slices.Equal(s.Depleted, want) { t.Fatalf("depleted = %v, want %v", s.Depleted, want) } if want := []string{"golf@x", "hotel@x"}; !slices.Equal(s.Expiring, want) { t.Fatalf("expiring = %v, want %v", s.Expiring, want) } if want := []string{"echo@x"}; !slices.Equal(s.Deactive, want) { t.Fatalf("deactive = %v, want %v", s.Deactive, want) } }) } func TestListPagedSummaryEmailListsAreCapped(t *testing.T) { svc, inboundSvc, settingSvc := setupPagingServices(t) db := database.GetDB() const n = clientSummaryEmailCap + 25 past := time.Now().UnixMilli() - pagingDay for i := range n { rec := model.ClientRecord{Email: "bulk-" + strconv.Itoa(i) + "@x", Enable: true, TotalGB: pagingGB, ExpiryTime: past} if err := db.Create(&rec).Error; err != nil { t.Fatalf("create client %d: %v", i, err) } } resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 25}) if err != nil { t.Fatalf("ListPaged: %v", err) } if resp.Summary.DepletedCount != n { t.Fatalf("depletedCount = %d, want %d", resp.Summary.DepletedCount, n) } if len(resp.Summary.Depleted) != clientSummaryEmailCap { t.Fatalf("depleted list = %d entries, want %d", len(resp.Summary.Depleted), clientSummaryEmailCap) } } func TestListPagedGlobalTrafficOverlay(t *testing.T) { svc, inboundSvc, settingSvc := setupPagingServices(t) seedPagingClients(t) // bravo has used 1GB of its 10GB locally; a master reporting 10GB of // cross-panel usage has to move it into the depleted bucket. if err := inboundSvc.AcceptGlobalTraffic("master-guid", []*xray.ClientTraffic{ {Email: "bravo@x", Up: 4 * pagingGB, Down: 6 * pagingGB}, }); err != nil { t.Fatalf("AcceptGlobalTraffic: %v", err) } resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50, Filter: "depleted"}) if err != nil { t.Fatalf("ListPaged: %v", err) } want := []string{"bravo@x", "charlie@x", "delta@x", "foxtrot@x"} if got := pagedEmails(resp.Items); !slices.Equal(got, want) { t.Fatalf("depleted = %v, want %v", got, want) } if resp.Summary.DepletedCount != 4 { t.Fatalf("depletedCount = %d, want 4", resp.Summary.DepletedCount) } for _, it := range resp.Items { if it.Email != "bravo@x" { continue } if it.Traffic == nil || it.Traffic.Up+it.Traffic.Down != 10*pagingGB { t.Fatalf("bravo traffic = %+v, want the overlaid 10GB", it.Traffic) } } } func TestClientQueryOnlineEmails(t *testing.T) { _, _, _ = setupPagingServices(t) seedPagingClients(t) q := newClientQuery(database.GetDB(), time.Now().UnixMilli(), 0, 0) emails, count, err := q.onlineEmails([]string{"alpha@x", "echo@x", "ghost@x", "kilo1@x"}) if err != nil { t.Fatalf("onlineEmails: %v", err) } if want := []string{"alpha@x", "kilo1@x"}; !slices.Equal(emails, want) { t.Fatalf("online = %v, want %v (disabled and unknown emails drop out)", emails, want) } if count != 2 { t.Fatalf("count = %d, want 2", count) } } func TestEmailInCondChunksLargeSets(t *testing.T) { emails := make([]string, sqlInChunk+1) for i := range emails { emails[i] = "e" + strconv.Itoa(i) } cond, args := emailInCond("c.email", emails) if want := "(c.email IN ? OR c.email IN ?)"; cond != want { t.Fatalf("cond = %q, want %q", cond, want) } if len(args) != 2 { t.Fatalf("args = %d chunks, want 2", len(args)) } if first, ok := args[0].([]string); !ok || len(first) != sqlInChunk { t.Fatalf("first chunk = %v, want %d entries", args[0], sqlInChunk) } emptyCond, emptyArgs := emailInCond("c.email", nil) if emptyCond != "1 = 0" || emptyArgs != nil { t.Fatalf("empty set = %q/%v, want an always-false predicate", emptyCond, emptyArgs) } } func TestEscapeLikeLiteral(t *testing.T) { tests := []struct { in string want string }{ {"plain", "plain"}, {"a_b", `a\_b`}, {"50%", `50\%`}, {`back\slash`, `back\\slash`}, } for _, tc := range tests { if got := escapeLikeLiteral(tc.in); got != tc.want { t.Fatalf("escapeLikeLiteral(%q) = %q, want %q", tc.in, got, tc.want) } } } func TestListPagedEmptyPanel(t *testing.T) { svc, inboundSvc, settingSvc := setupPagingServices(t) resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{}) if err != nil { t.Fatalf("ListPaged on a panel with no clients: %v", err) } if len(resp.Items) != 0 || resp.Total != 0 || resp.Filtered != 0 { t.Fatalf("items/total/filtered = %d/%d/%d, want 0/0/0", len(resp.Items), resp.Total, resp.Filtered) } if resp.Summary.Active != 0 || resp.Summary.DepletedCount != 0 { t.Fatalf("summary = %+v, want zeroed counters", resp.Summary) } if resp.Groups == nil { t.Fatal("groups = nil, want an empty list so the filter drawer renders") } }