Stage 17: test-contour verification & defect fixes #19
@@ -0,0 +1,95 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserListItem is the admin user-list projection: a small subset of the account plus
|
||||||
|
// whether it is a robot (derived from its identities), so the console can label the kind
|
||||||
|
// without a per-row identity query.
|
||||||
|
type UserListItem struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
DisplayName string
|
||||||
|
PreferredLanguage string
|
||||||
|
IsGuest bool
|
||||||
|
IsRobot bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
|
||||||
|
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
|
||||||
|
// one char) matched case-insensitively against the display name / any identity's external
|
||||||
|
// id. An empty mask means no filter on that field.
|
||||||
|
type UserFilter struct {
|
||||||
|
Robots bool
|
||||||
|
NameMask string
|
||||||
|
ExternalIDMask string
|
||||||
|
}
|
||||||
|
|
||||||
|
// robotExists is the correlated subquery testing whether account a is a robot.
|
||||||
|
const robotExists = `EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot')`
|
||||||
|
|
||||||
|
// userListWhere builds the shared WHERE clause and its positional args (from $1).
|
||||||
|
func userListWhere(f UserFilter) (string, []any) {
|
||||||
|
args := []any{f.Robots}
|
||||||
|
where := robotExists + ` = $1`
|
||||||
|
if name := likePattern(f.NameMask); name != "" {
|
||||||
|
args = append(args, name)
|
||||||
|
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
|
||||||
|
}
|
||||||
|
if ext := likePattern(f.ExternalIDMask); ext != "" {
|
||||||
|
args = append(args, ext)
|
||||||
|
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
|
||||||
|
}
|
||||||
|
return where, args
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns the filtered admin user list, newest first, paginated.
|
||||||
|
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
|
||||||
|
where, args := userListWhere(f)
|
||||||
|
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.created_at, ` + robotExists + ` AS is_robot
|
||||||
|
FROM backend.accounts a WHERE ` + where +
|
||||||
|
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("account: list users: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []UserListItem
|
||||||
|
for rows.Next() {
|
||||||
|
var it UserListItem
|
||||||
|
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &it.CreatedAt, &it.IsRobot); err != nil {
|
||||||
|
return nil, fmt.Errorf("account: scan user: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUsers counts the filtered admin user list, for pagination.
|
||||||
|
func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) {
|
||||||
|
where, args := userListWhere(f)
|
||||||
|
var n int
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM backend.accounts a WHERE `+where, args...).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("account: count users: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// likePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern,
|
||||||
|
// escaping the SQL wildcards already in the input first. An empty/blank mask returns "".
|
||||||
|
func likePattern(mask string) string {
|
||||||
|
mask = strings.TrimSpace(mask)
|
||||||
|
if mask == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(mask)
|
||||||
|
escaped = strings.ReplaceAll(escaped, "*", "%")
|
||||||
|
return strings.ReplaceAll(escaped, "?", "_")
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
{{define "content" -}}
|
{{define "content" -}}
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
|
<nav class="subnav">
|
||||||
|
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
|
||||||
|
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
|
||||||
|
</nav>
|
||||||
|
<form class="form" method="get" action="/_gm/users">
|
||||||
|
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
|
||||||
|
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
||||||
|
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
</form>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th><th title="per-move think time across all games">Move min</th><th>avg</th><th>max</th></tr></thead>
|
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th><th title="per-move think time across all games">Move min</th><th>avg</th><th>max</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -19,9 +29,9 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<nav class="pager">
|
<nav class="pager">
|
||||||
{{if .Pager.HasPrev}}<a href="/_gm/users?page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
{{if .Pager.HasPrev}}<a href="/_gm/users?{{.FilterQuery}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||||
{{if .Pager.HasNext}}<a href="/_gm/users?page={{.Pager.NextPage}}">next »</a>{{end}}
|
{{if .Pager.HasNext}}<a href="/_gm/users?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ type DashboardView struct {
|
|||||||
type UsersView struct {
|
type UsersView struct {
|
||||||
Items []UserRow
|
Items []UserRow
|
||||||
Pager Pager
|
Pager Pager
|
||||||
|
// Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current
|
||||||
|
// glob filters; FilterQuery is those encoded for pager/toggle links.
|
||||||
|
Robots bool
|
||||||
|
NameMask string
|
||||||
|
ExternalIDMask string
|
||||||
|
FilterQuery string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUserListFilter checks the admin user-list filter: the people/robots split (by a
|
||||||
|
// robot identity) and the case-insensitive glob masks on display name and external id.
|
||||||
|
func TestUserListFilter(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := account.NewStore(testDB)
|
||||||
|
uniq := uuid.NewString()
|
||||||
|
|
||||||
|
human, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision human: %v", err)
|
||||||
|
}
|
||||||
|
robot, err := st.ProvisionRobot(ctx, "robot-uxz-"+uniq, "Zzqxbot")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision robot: %v", err)
|
||||||
|
}
|
||||||
|
guest, err := st.ProvisionGuest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision guest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collect := func(f account.UserFilter) map[uuid.UUID]account.UserListItem {
|
||||||
|
items, err := st.ListUsers(ctx, f, 5000, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list users %+v: %v", f, err)
|
||||||
|
}
|
||||||
|
m := make(map[uuid.UUID]account.UserListItem, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
m[it.ID] = it
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
people := collect(account.UserFilter{})
|
||||||
|
if _, ok := people[human.ID]; !ok {
|
||||||
|
t.Error("human missing from people")
|
||||||
|
}
|
||||||
|
if _, ok := people[guest.ID]; !ok {
|
||||||
|
t.Error("guest missing from people")
|
||||||
|
}
|
||||||
|
if _, ok := people[robot.ID]; ok {
|
||||||
|
t.Error("robot must not appear in people")
|
||||||
|
}
|
||||||
|
if it := people[human.ID]; it.IsRobot || it.IsGuest {
|
||||||
|
t.Errorf("human flags wrong: robot=%v guest=%v (want both false)", it.IsRobot, it.IsGuest)
|
||||||
|
}
|
||||||
|
|
||||||
|
robots := collect(account.UserFilter{Robots: true})
|
||||||
|
if it, ok := robots[robot.ID]; !ok || !it.IsRobot {
|
||||||
|
t.Errorf("robot missing from robots or IsRobot=false (ok=%v)", ok)
|
||||||
|
}
|
||||||
|
if _, ok := robots[human.ID]; ok {
|
||||||
|
t.Error("human must not appear in robots")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name mask (people).
|
||||||
|
if _, ok := collect(account.UserFilter{NameMask: "Zzqx*"})[human.ID]; !ok {
|
||||||
|
t.Error("name mask Zzqx* should match the human")
|
||||||
|
}
|
||||||
|
if _, ok := collect(account.UserFilter{NameMask: "nomatch*"})[human.ID]; ok {
|
||||||
|
t.Error("name mask nomatch* should not match the human")
|
||||||
|
}
|
||||||
|
// External-id mask (robots).
|
||||||
|
if _, ok := collect(account.UserFilter{Robots: true, ExternalIDMask: "robot-uxz-*"})[robot.ID]; !ok {
|
||||||
|
t.Error("external-id mask robot-uxz-* should match the robot")
|
||||||
|
}
|
||||||
|
if _, ok := collect(account.UserFilter{Robots: true, ExternalIDMask: "robot-zzz-*"})[robot.ID]; ok {
|
||||||
|
t.Error("external-id mask robot-zzz-* should not match the robot")
|
||||||
|
}
|
||||||
|
// CountUsers agrees that robots exist.
|
||||||
|
if n, err := st.CountUsers(ctx, account.UserFilter{Robots: true, ExternalIDMask: "robot-uxz-*"}); err != nil || n != 1 {
|
||||||
|
t.Errorf("count robots robot-uxz-* = (%d, %v), want 1", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -75,24 +76,45 @@ func (s *Server) consoleDashboard(c *gin.Context) {
|
|||||||
func (s *Server) consoleUsers(c *gin.Context) {
|
func (s *Server) consoleUsers(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
page := consolePage(c)
|
page := consolePage(c)
|
||||||
total, _ := s.accounts.CountAccounts(ctx)
|
filter := account.UserFilter{
|
||||||
accs, err := s.accounts.ListAccounts(ctx, adminPageSize, (page-1)*adminPageSize)
|
Robots: c.Query("kind") == "robots",
|
||||||
|
NameMask: c.Query("name"),
|
||||||
|
ExternalIDMask: c.Query("ext"),
|
||||||
|
}
|
||||||
|
total, _ := s.accounts.CountUsers(ctx, filter)
|
||||||
|
items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
view := adminconsole.UsersView{Pager: adminconsole.NewPager(page, adminPageSize, total)}
|
q := url.Values{}
|
||||||
ids := make([]uuid.UUID, 0, len(accs))
|
if filter.Robots {
|
||||||
for _, a := range accs {
|
q.Set("kind", "robots")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(filter.NameMask) != "" {
|
||||||
|
q.Set("name", filter.NameMask)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(filter.ExternalIDMask) != "" {
|
||||||
|
q.Set("ext", filter.ExternalIDMask)
|
||||||
|
}
|
||||||
|
view := adminconsole.UsersView{
|
||||||
|
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
||||||
|
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
|
||||||
|
FilterQuery: q.Encode(),
|
||||||
|
}
|
||||||
|
ids := make([]uuid.UUID, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
kind := "registered"
|
kind := "registered"
|
||||||
if a.IsGuest {
|
if it.IsRobot {
|
||||||
|
kind = "robot"
|
||||||
|
} else if it.IsGuest {
|
||||||
kind = "guest"
|
kind = "guest"
|
||||||
}
|
}
|
||||||
view.Items = append(view.Items, adminconsole.UserRow{
|
view.Items = append(view.Items, adminconsole.UserRow{
|
||||||
ID: a.ID.String(), DisplayName: a.DisplayName, Kind: kind,
|
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
|
||||||
Language: a.PreferredLanguage, Guest: a.IsGuest, CreatedAt: fmtTime(a.CreatedAt),
|
Language: it.PreferredLanguage, Guest: it.IsGuest, CreatedAt: fmtTime(it.CreatedAt),
|
||||||
})
|
})
|
||||||
ids = append(ids, a.ID)
|
ids = append(ids, it.ID)
|
||||||
}
|
}
|
||||||
if stats, err := s.games.MoveDurationStats(ctx, ids); err == nil {
|
if stats, err := s.games.MoveDurationStats(ctx, ids); err == nil {
|
||||||
for i := range view.Items {
|
for i := range view.Items {
|
||||||
|
|||||||
Reference in New Issue
Block a user