Files
3x-ui/internal/web/service/client_paging_test.go
T
Sanaei 5008906c4c feat(clients): filter the client list by clicking a summary stat card
Each card on the Clients page now toggles its status bucket as the sole
filter, and the Clients card clears it. The bucket filters used to be
wider than the card counts: "active" still included clients near
depletion and "deactive" included disabled clients that had run out, so
a filtered list could disagree with the number on the card. Both filters
now reuse the summary expressions, and a test pins each card's count to
the size of its filtered list.
2026-09-15 22:06:22 +02:00

638 lines
21 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")
}
}