mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-04 05:02:06 +03:00
7802167443
* Match non-ASCII capitals in the clients group filter and search SQLite LOWER() only folds ASCII, so a group or search term with a Cyrillic, Persian or other non-ASCII capital never matched after being lower-cased in Go. Also compare the value as typed. * Match lower, upper and title-case spellings for non-ASCII search and group filter
671 lines
22 KiB
Go
671 lines
22 KiB
Go
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 leaves a disabled client that ran out to depleted",
|
|
params: ClientPageParams{PageSize: 50, Filter: "deactive"},
|
|
want: []string{"echo@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 leaves clients near depletion to expiring",
|
|
params: ClientPageParams{PageSize: 50, Filter: "active"},
|
|
want: []string{"alpha@x", "bravo@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("clicking a stat card filters to exactly the clients it counts", func(t *testing.T) {
|
|
cards := map[string]int{"active": s.Active, "depleted": s.DepletedCount, "expiring": s.ExpiringCount, "deactive": s.DeactiveCount}
|
|
for bucket, count := range cards {
|
|
page, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50, Filter: bucket})
|
|
if err != nil {
|
|
t.Fatalf("ListPaged(%s): %v", bucket, err)
|
|
}
|
|
if page.Filtered != count {
|
|
t.Fatalf("filter %q matched %d clients, card counts %d", bucket, page.Filtered, count)
|
|
}
|
|
}
|
|
})
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// SQLite's LOWER() folds ASCII only, so non-ASCII capitals must still match.
|
|
func TestListPagedNonASCIICase(t *testing.T) {
|
|
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
|
rec := model.ClientRecord{Email: "lima@x", Comment: "Привет", Group: "Тест", Enable: true}
|
|
if err := database.GetDB().Create(&rec).Error; err != nil {
|
|
t.Fatalf("create client: %v", err)
|
|
}
|
|
for name, params := range map[string]ClientPageParams{
|
|
"group": {PageSize: 50, Group: "Тест"},
|
|
"group other case": {PageSize: 50, Group: "ТЕСТ"},
|
|
"search": {PageSize: 50, Search: "Привет"},
|
|
"search lower case": {PageSize: 50, Search: "привет"},
|
|
"search upper case": {PageSize: 50, Search: "ПРИВЕТ"},
|
|
} {
|
|
resp, err := svc.ListPaged(inboundSvc, settingSvc, params)
|
|
if err != nil {
|
|
t.Fatalf("%s: ListPaged: %v", name, err)
|
|
}
|
|
if got := pagedEmails(resp.Items); !slices.Equal(got, []string{"lima@x"}) {
|
|
t.Fatalf("%s: emails = %v, want [lima@x]", name, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCaseVariants(t *testing.T) {
|
|
if got, want := caseVariants("пРИвет"), []string{"привет", "пРИвет", "ПРИВЕТ", "Привет"}; !slices.Equal(got, want) {
|
|
t.Fatalf("caseVariants = %v, want %v", got, want)
|
|
}
|
|
if got := caseVariants("abc"); !slices.Equal(got, []string{"abc", "ABC", "Abc"}) {
|
|
t.Fatalf("ASCII variants = %v", got)
|
|
}
|
|
}
|