fix(api): ListUsers 的 total 按同一组过滤条件计数,面板不再翻出空页
This commit is contained in:
3 files changed
+54
-12
No files matched your search
+7
-10
@@ -1850,17 +1850,9 @@ func (p *PGRepo) DeleteAllPasskeyCredentialsForUser(ctx context.Context, userID
|
|||||||
// ---- user admin (spec §7, admin-only) ----
|
// ---- user admin (spec §7, admin-only) ----
|
||||||
|
|
||||||
// ListUsers returns a page of non-deleted users matching the optional filters,
|
// ListUsers returns a page of non-deleted users matching the optional filters,
|
||||||
// newest first. total is the unfiltered count so the admin page can render
|
// newest first. total counts every user the same filters match, so the admin
|
||||||
// pagination without a second round-trip.
|
// page can size its pagination without a second round-trip.
|
||||||
func (p *PGRepo) ListUsers(ctx context.Context, opts ListUsersOpts) ([]UserView, int, error) {
|
func (p *PGRepo) ListUsers(ctx context.Context, opts ListUsersOpts) ([]UserView, int, error) {
|
||||||
var total int
|
|
||||||
{
|
|
||||||
q := `SELECT count(*) FROM users WHERE deleted_at IS NULL`
|
|
||||||
if err := p.db.QueryRowContext(ctx, q).Scan(&total); err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := opts.Limit
|
limit := opts.Limit
|
||||||
if limit <= 0 || limit > 100 {
|
if limit <= 0 || limit > 100 {
|
||||||
limit = 20
|
limit = 20
|
||||||
@@ -1893,6 +1885,11 @@ func (p *PGRepo) ListUsers(ctx context.Context, opts ListUsersOpts) ([]UserView,
|
|||||||
where += ` AND u.disabled = false`
|
where += ` AND u.disabled = false`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var total int
|
||||||
|
if err := p.db.QueryRowContext(ctx, `SELECT count(*) FROM users u`+where, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
q := `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text,
|
q := `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text,
|
||||||
u.disabled, u.email_verified,
|
u.disabled, u.email_verified,
|
||||||
u.created_at, u.updated_at,
|
u.created_at, u.updated_at,
|
||||||
|
|||||||
@@ -734,8 +734,8 @@ type Repo interface {
|
|||||||
// ---- user admin (spec §7, admin-only) ----
|
// ---- user admin (spec §7, admin-only) ----
|
||||||
|
|
||||||
// ListUsers returns a page of non-deleted users matching the optional filters,
|
// ListUsers returns a page of non-deleted users matching the optional filters,
|
||||||
// newest first. total is the unfiltered count so the admin page can render
|
// newest first. total counts every user the same filters match, so the admin
|
||||||
// pagination without a second round-trip.
|
// page can size its pagination without a second round-trip.
|
||||||
ListUsers(ctx context.Context, opts ListUsersOpts) ([]UserView, int, error)
|
ListUsers(ctx context.Context, opts ListUsersOpts) ([]UserView, int, error)
|
||||||
// UserDetail loads one user with its linked MC accounts, or ErrNotFound.
|
// UserDetail loads one user with its linked MC accounts, or ErrNotFound.
|
||||||
// A deleted user is returned (the row lives for audit) but flagged.
|
// A deleted user is returned (the row lives for audit) but flagged.
|
||||||
|
|||||||
@@ -192,3 +192,48 @@ func TestBackupListPaging(t *testing.T) {
|
|||||||
t.Fatalf("other's s2 = %s of %d (%v), want nothing", idsOf(vs), total, err)
|
t.Fatalf("other's s2 = %s of %d (%v), want nothing", idsOf(vs), total, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListUsers reports as total every live user the same filters match, past the
|
||||||
|
// last page too, so the admin page does not size its pagination from the whole
|
||||||
|
// table and offer pages that come back empty.
|
||||||
|
func TestListUsersTotalFollowsFilters(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tag := suffix(t)
|
||||||
|
newUser(t, "user", "lu"+tag)
|
||||||
|
newUser(t, "admin", "lu"+tag)
|
||||||
|
bob := newUser(t, "user", "lu"+tag)
|
||||||
|
newUser(t, "user", "other") // live, but matches no query below
|
||||||
|
gone := newUser(t, "user", "lu"+tag)
|
||||||
|
if err := repo.SetUserDisabled(ctx, bob.ID, true); err != nil {
|
||||||
|
t.Fatalf("SetUserDisabled: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.DeleteUser(ctx, gone.ID, "pgint"); err != nil {
|
||||||
|
t.Fatalf("DeleteUser: %v", err)
|
||||||
|
}
|
||||||
|
var live int
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM users WHERE deleted_at IS NULL`).Scan(&live); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
opts api.ListUsersOpts
|
||||||
|
rows, all int
|
||||||
|
}{
|
||||||
|
{"no filter", api.ListUsersOpts{Limit: 1}, 1, live},
|
||||||
|
{"query", api.ListUsersOpts{Query: tag, Limit: 2}, 2, 3},
|
||||||
|
{"query past the last page", api.ListUsersOpts{Query: tag, Limit: 2, Offset: 4}, 0, 3},
|
||||||
|
{"query and role", api.ListUsersOpts{Query: tag, Role: "admin"}, 1, 1},
|
||||||
|
{"query and disabled", api.ListUsersOpts{Query: tag, Hidden: "true"}, 1, 1},
|
||||||
|
{"query and enabled", api.ListUsersOpts{Query: tag, Hidden: "false"}, 2, 2},
|
||||||
|
} {
|
||||||
|
users, total, err := repo.ListUsers(ctx, c.opts)
|
||||||
|
if err != nil || len(users) != c.rows || total != c.all {
|
||||||
|
t.Errorf("%s: %d rows, total %d, err %v; want %d rows, total %d", c.name, len(users), total, err, c.rows, c.all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
users, _, err := repo.ListUsers(ctx, api.ListUsersOpts{Query: tag, Hidden: "true"})
|
||||||
|
if err != nil || len(users) != 1 || users[0].ID != bob.ID {
|
||||||
|
t.Fatalf("disabled filter = %+v, %v; want only %s", users, err, bob.Username)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user