Add player profiles and community settings
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -65,6 +65,9 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu
|
||||
api.Get("/api/me", s.me)
|
||||
api.Patch("/api/me/player", s.updateProfile)
|
||||
api.Get("/api/players", s.players)
|
||||
api.Get("/api/players/{playerID}", s.playerProfile)
|
||||
api.Get("/api/community/settings", s.communitySettings)
|
||||
api.Put("/api/community/settings", s.updateCommunitySettings)
|
||||
api.Get("/api/accounts", s.accounts)
|
||||
api.Patch("/api/accounts/{accountID}/moderator", s.setModerator)
|
||||
api.Get("/api/events", s.events)
|
||||
@@ -246,7 +249,8 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
DisplayName *string `json:"displayName"`
|
||||
BattleTag *string `json:"battleTag"`
|
||||
Ratings domain.Ratings `json:"ratings"`
|
||||
PreferredRoles []domain.Role `json:"preferredRoles"`
|
||||
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
|
||||
@@ -256,7 +260,10 @@ func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
id := who(r)
|
||||
out, err := s.service.UpdateOwnProfile(r.Context(), id.account, id.player, in.DisplayName, in.Ratings, in.PreferredRoles, in.PreferredPlayerIDs, in.AvoidedPlayerIDs)
|
||||
out, err := s.service.UpdateOwnProfile(r.Context(), id.account, id.player, application.UpdateOwnProfileInput{
|
||||
DisplayName: in.DisplayName, BattleTag: in.BattleTag, Ratings: in.Ratings,
|
||||
PreferredRoles: in.PreferredRoles, PreferredPlayerIDs: in.PreferredPlayerIDs, AvoidedPlayerIDs: in.AvoidedPlayerIDs,
|
||||
})
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -265,6 +272,27 @@ func (s *Server) players(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) playerProfile(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.service.GetPlayerProfile(r.Context(), chi.URLParam(r, "playerID"))
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) communitySettings(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.service.GetCommunitySettings(r.Context())
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) updateCommunitySettings(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
DiscordInviteURL string `json:"discordInviteUrl"`
|
||||
}
|
||||
if !decode(w, r, &input) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.UpdateCommunitySettings(r.Context(), who(r).account, input.DiscordInviteURL)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) accounts(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.service.ListAccounts(r.Context(), who(r).account)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
package httpapi
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mixmaker/backend/internal/application"
|
||||
"mixmaker/backend/internal/domain"
|
||||
"mixmaker/backend/internal/realtime"
|
||||
)
|
||||
|
||||
func TestCreateEventRequestDefaultsEveryonePing(t *testing.T) {
|
||||
if !((createEventRequest{}).options().PingEveryone) {
|
||||
@@ -14,3 +26,67 @@ func TestCreateEventRequestCanDisableEveryonePing(t *testing.T) {
|
||||
t.Fatal("explicit false pingEveryone must disable the ping")
|
||||
}
|
||||
}
|
||||
|
||||
type profileHTTPStore struct {
|
||||
application.Store
|
||||
account domain.Account
|
||||
player domain.Player
|
||||
}
|
||||
|
||||
func (s *profileHTTPStore) AccountBySession(context.Context, string) (domain.Account, domain.Player, error) {
|
||||
return s.account, s.player, nil
|
||||
}
|
||||
|
||||
func (s *profileHTTPStore) GetPlayer(context.Context, string) (domain.Player, error) {
|
||||
return s.player, nil
|
||||
}
|
||||
|
||||
func (s *profileHTTPStore) ListEvents(context.Context, time.Time) ([]domain.Event, error) {
|
||||
return []domain.Event{}, nil
|
||||
}
|
||||
|
||||
func TestPlayerProfileHTTPResponseIsPublicSafe(t *testing.T) {
|
||||
store := &profileHTTPStore{
|
||||
account: domain.Account{ID: "account", Role: domain.RolePlayer},
|
||||
player: domain.Player{
|
||||
ID: "player", AccountID: "account", DisplayName: "Ana", BattleTag: "Ana#1234",
|
||||
Ratings: domain.Ratings{Tank: 10, Damage: 20, Support: 30},
|
||||
PreferredPlayerIDs: []string{"secret-preference"}, AvoidedPlayerIDs: []string{"secret-avoid"},
|
||||
},
|
||||
}
|
||||
hub := realtime.New()
|
||||
handler := New(application.New(store, hub, nil), store, hub, Config{CookieName: "session"})
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/players/player", nil)
|
||||
request.AddCookie(&http.Cookie{Name: "session", Value: "valid"})
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded := response.Body.String()
|
||||
if strings.Contains(encoded, "preferred") || strings.Contains(encoded, "avoid") || strings.Contains(encoded, "accountId") {
|
||||
t.Fatalf("profile leaked private fields: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeratorCannotUpdateCommunitySettings(t *testing.T) {
|
||||
store := &profileHTTPStore{
|
||||
account: domain.Account{ID: "moderator", Role: domain.RoleModerator},
|
||||
player: domain.Player{ID: "player", AccountID: "moderator"},
|
||||
}
|
||||
hub := realtime.New()
|
||||
handler := New(application.New(store, hub, nil), store, hub, Config{CookieName: "session"})
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/community/settings", strings.NewReader(`{"discordInviteUrl":""}`))
|
||||
request.AddCookie(&http.Cookie{Name: "session", Value: "valid"})
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("moderator update returned %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"mixmaker/backend/internal/application"
|
||||
@@ -62,13 +63,27 @@ func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account
|
||||
if err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
p := domain.Player{ID: application.NewID(), AccountID: account.ID, DisplayName: account.Username, Ratings: domain.Ratings{Tank: 13, Damage: 13, Support: 13}, CreatedAt: account.CreatedAt, UpdatedAt: account.CreatedAt}
|
||||
p, err := playerByAccount(ctx, tx, account.ID)
|
||||
if err == nil {
|
||||
return account, p, tx.Commit(ctx)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('mixmaker-player-display-names'))`); err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
displayName, err := uniqueDefaultDisplayName(ctx, tx, account.Username)
|
||||
if err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
p = domain.Player{ID: application.NewID(), AccountID: account.ID, DisplayName: displayName, Ratings: domain.Ratings{Tank: 13, Damage: 13, Support: 13}, CreatedAt: account.CreatedAt, UpdatedAt: account.CreatedAt}
|
||||
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(account_id) DO UPDATE SET account_id=excluded.account_id
|
||||
RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
|
||||
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,created_at,updated_at)
|
||||
VALUES($1,$2,$3,'',$4,$5,$6,$7,$8) ON CONFLICT(account_id) DO UPDATE SET account_id=excluded.account_id
|
||||
RETURNING id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
|
||||
p.ID, p.AccountID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
@@ -84,6 +99,51 @@ func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account
|
||||
return account, p, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func playerByAccount(ctx context.Context, tx pgx.Tx, accountID string) (domain.Player, error) {
|
||||
var p domain.Player
|
||||
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||
err := tx.QueryRow(ctx, `SELECT id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,
|
||||
preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players WHERE account_id=$1`, accountID).
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
|
||||
&preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
if err = json.Unmarshal(preferredRoles, &p.PreferredRoles); err == nil {
|
||||
err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs)
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs)
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func uniqueDefaultDisplayName(ctx context.Context, tx pgx.Tx, username string) (string, error) {
|
||||
baseRunes := []rune(strings.TrimSpace(username))
|
||||
if len(baseRunes) < 2 {
|
||||
baseRunes = []rune("Player")
|
||||
}
|
||||
if len(baseRunes) > 32 {
|
||||
baseRunes = baseRunes[:32]
|
||||
}
|
||||
base := string(baseRunes)
|
||||
for number := 1; ; number++ {
|
||||
candidate := base
|
||||
if number > 1 {
|
||||
suffix := fmt.Sprintf("-%d", number)
|
||||
limit := 32 - len([]rune(suffix))
|
||||
candidate = string([]rune(base)[:min(len([]rune(base)), limit)]) + suffix
|
||||
}
|
||||
var exists bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM players WHERE lower(display_name)=lower($1))`, candidate).Scan(&exists); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) ListAccounts(ctx context.Context) ([]domain.Account, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id,discord_id,username,avatar_url,role,created_at FROM accounts ORDER BY lower(username),id`)
|
||||
if err != nil {
|
||||
@@ -124,11 +184,11 @@ func (s *Store) AccountBySession(ctx context.Context, token string) (domain.Acco
|
||||
var p domain.Player
|
||||
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT a.id,a.discord_id,a.username,a.avatar_url,a.role,a.created_at,
|
||||
p.id,p.account_id,p.display_name,p.tank_rating,p.damage_rating,p.support_rating,p.preferred_roles,p.preferred_player_ids,p.avoided_player_ids,p.created_at,p.updated_at
|
||||
p.id,p.account_id,p.display_name,p.battle_tag,p.tank_rating,p.damage_rating,p.support_rating,p.preferred_roles,p.preferred_player_ids,p.avoided_player_ids,p.created_at,p.updated_at
|
||||
FROM sessions s JOIN accounts a ON a.id=s.account_id JOIN players p ON p.account_id=a.id
|
||||
WHERE s.token_hash=$1 AND s.expires_at>now()`, HashToken(token)).
|
||||
Scan(&a.ID, &a.DiscordID, &a.Username, &a.AvatarURL, &a.Role, &a.CreatedAt,
|
||||
&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
|
||||
}
|
||||
@@ -145,16 +205,35 @@ func (s *Store) UpdatePlayer(ctx context.Context, p domain.Player) (domain.Playe
|
||||
preferredRoles, _ := json.Marshal(p.PreferredRoles)
|
||||
preferredPlayers, _ := json.Marshal(p.PreferredPlayerIDs)
|
||||
avoidedPlayers, _ := json.Marshal(p.AvoidedPlayerIDs)
|
||||
err := s.pool.QueryRow(ctx, `UPDATE players SET display_name=$2,tank_rating=$3,damage_rating=$4,support_rating=$5,
|
||||
preferred_roles=$6,preferred_player_ids=$7,avoided_player_ids=$8,updated_at=$9
|
||||
WHERE id=$1 RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at`,
|
||||
p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, preferredRoles, preferredPlayers, avoidedPlayers, p.UpdatedAt).
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &p.CreatedAt, &p.UpdatedAt)
|
||||
err := s.pool.QueryRow(ctx, `UPDATE players SET display_name=$2,battle_tag=$3,tank_rating=$4,damage_rating=$5,support_rating=$6,
|
||||
preferred_roles=$7,preferred_player_ids=$8,avoided_player_ids=$9,updated_at=$10
|
||||
WHERE id=$1 RETURNING id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,created_at,updated_at`,
|
||||
p.ID, p.DisplayName, p.BattleTag, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, preferredRoles, preferredPlayers, avoidedPlayers, p.UpdatedAt).
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &p.CreatedAt, &p.UpdatedAt)
|
||||
return p, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) GetPlayer(ctx context.Context, id string) (domain.Player, error) {
|
||||
var p domain.Player
|
||||
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,
|
||||
preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players WHERE id=$1`, id).
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
|
||||
&preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs)
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs)
|
||||
}
|
||||
return p, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,
|
||||
rows, err := s.pool.Query(ctx, `SELECT id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,
|
||||
preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -164,7 +243,7 @@ func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
|
||||
for rows.Next() {
|
||||
var p domain.Player
|
||||
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||
if err := rows.Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
|
||||
if err := rows.Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
|
||||
&preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -189,11 +268,11 @@ func (s *Store) CreateParticipant(ctx context.Context, p domain.Player, r domain
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at)
|
||||
VALUES($1,NULL,$2,$3,$4,$5,$6,$7)
|
||||
RETURNING id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
|
||||
p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,created_at,updated_at)
|
||||
VALUES($1,NULL,$2,$3,$4,$5,$6,$7,$8)
|
||||
RETURNING id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
|
||||
p.ID, p.DisplayName, p.BattleTag, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
|
||||
}
|
||||
@@ -638,6 +717,27 @@ func (s *Store) GetSeries(ctx context.Context, id string) (domain.Series, error)
|
||||
return out, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) ListSeriesByEvent(ctx context.Context, eventID string) ([]domain.Series, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT body FROM series WHERE body->>'eventId'=$1 ORDER BY id`, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.Series, 0)
|
||||
for rows.Next() {
|
||||
var body []byte
|
||||
var series domain.Series
|
||||
if err := rows.Scan(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &series); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, series)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SaveTournament(ctx context.Context, t domain.Tournament) (domain.Tournament, error) {
|
||||
body, _ := json.Marshal(t)
|
||||
tag, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body,version) VALUES($1,$2,$3,$4)
|
||||
@@ -749,6 +849,20 @@ func (s *Store) GetDraft(ctx context.Context, id string, target any) (string, in
|
||||
return kind, version, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) GetCommunitySettings(ctx context.Context) (domain.CommunitySettings, error) {
|
||||
var settings domain.CommunitySettings
|
||||
err := s.pool.QueryRow(ctx, `SELECT discord_invite_url,updated_at,COALESCE(updated_by,'') FROM community_settings WHERE singleton=true`).
|
||||
Scan(&settings.DiscordInviteURL, &settings.UpdatedAt, &settings.UpdatedBy)
|
||||
return settings, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateCommunitySettings(ctx context.Context, settings domain.CommunitySettings) (domain.CommunitySettings, error) {
|
||||
err := s.pool.QueryRow(ctx, `UPDATE community_settings SET discord_invite_url=$1,updated_at=$2,updated_by=$3 WHERE singleton=true
|
||||
RETURNING discord_invite_url,updated_at,updated_by`, settings.DiscordInviteURL, settings.UpdatedAt, settings.UpdatedBy).
|
||||
Scan(&settings.DiscordInviteURL, &settings.UpdatedAt, &settings.UpdatedBy)
|
||||
return settings, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) AppendAudit(ctx context.Context, actor, action, subject string, payload any) error {
|
||||
body, _ := json.Marshal(payload)
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO audit_log(actor_account_id,action,subject_id,payload) VALUES($1,$2,$3,$4)`, actor, action, subject, body)
|
||||
@@ -806,5 +920,9 @@ func mapError(err error) error {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return fmt.Errorf("%w: value already exists", domain.ErrConflict)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -63,6 +63,24 @@ func TestMigrationsAndReadiness(t *testing.T) {
|
||||
t.Fatalf("%s table is missing", table)
|
||||
}
|
||||
}
|
||||
var identityColumns int
|
||||
if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM information_schema.columns
|
||||
WHERE table_name='players' AND column_name IN ('display_name','battle_tag')`).Scan(&identityColumns); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if identityColumns != 2 {
|
||||
t.Fatalf("player identity migration is incomplete: found %d columns", identityColumns)
|
||||
}
|
||||
var settingsTable, nicknameIndex, seriesEventIndex bool
|
||||
if err := store.pool.QueryRow(ctx, `SELECT
|
||||
to_regclass('public.community_settings') IS NOT NULL,
|
||||
to_regclass('public.players_display_name_lower_uidx') IS NOT NULL,
|
||||
to_regclass('public.series_event_id_idx') IS NOT NULL`).Scan(&settingsTable, &nicknameIndex, &seriesEventIndex); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !settingsTable || !nicknameIndex || !seriesEventIndex {
|
||||
t.Fatalf("profile/settings migrations missing: settings=%v nickname=%v series=%v", settingsTable, nicknameIndex, seriesEventIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscordRoleSyncPersistence(t *testing.T) {
|
||||
|
||||
@@ -20,6 +20,7 @@ type Store interface {
|
||||
CreateSession(context.Context, string, string, time.Time) error
|
||||
DeleteSession(context.Context, string) error
|
||||
UpdatePlayer(context.Context, domain.Player) (domain.Player, error)
|
||||
GetPlayer(context.Context, string) (domain.Player, error)
|
||||
ListPlayers(context.Context) ([]domain.Player, error)
|
||||
EnqueueDiscordRoleSync(context.Context, string, string, int64) error
|
||||
CreateParticipant(context.Context, domain.Player, domain.RSVP) (domain.Player, domain.RSVP, error)
|
||||
@@ -49,11 +50,14 @@ type Store interface {
|
||||
GetRuleset(context.Context, string) (domain.Ruleset, error)
|
||||
SaveSeries(context.Context, domain.Series) (domain.Series, error)
|
||||
GetSeries(context.Context, string) (domain.Series, error)
|
||||
ListSeriesByEvent(context.Context, string) ([]domain.Series, error)
|
||||
SaveTournament(context.Context, domain.Tournament) (domain.Tournament, error)
|
||||
GetTournament(context.Context, string) (domain.Tournament, error)
|
||||
GetTournamentByEvent(context.Context, string) (domain.Tournament, error)
|
||||
SaveDraft(context.Context, string, string, any, int) error
|
||||
GetDraft(context.Context, string, any) (string, int, error)
|
||||
GetCommunitySettings(context.Context) (domain.CommunitySettings, error)
|
||||
UpdateCommunitySettings(context.Context, domain.CommunitySettings) (domain.CommunitySettings, error)
|
||||
AppendAudit(context.Context, string, string, string, any) error
|
||||
}
|
||||
|
||||
@@ -125,20 +129,35 @@ func (s *Service) SetModerator(ctx context.Context, actor domain.Account, accoun
|
||||
return account, err
|
||||
}
|
||||
|
||||
func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, displayName string, ratings domain.Ratings, preferredRoles []domain.Role, preferredPlayerIDs, avoidedPlayerIDs []string) (domain.Player, error) {
|
||||
type UpdateOwnProfileInput struct {
|
||||
DisplayName *string
|
||||
BattleTag *string
|
||||
Ratings domain.Ratings
|
||||
PreferredRoles []domain.Role
|
||||
PreferredPlayerIDs []string
|
||||
AvoidedPlayerIDs []string
|
||||
}
|
||||
|
||||
func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, input UpdateOwnProfileInput) (domain.Player, error) {
|
||||
if actor.ID != current.AccountID {
|
||||
return domain.Player{}, domain.ErrForbidden
|
||||
}
|
||||
if err := ratings.Validate(); err != nil {
|
||||
if err := input.Ratings.Validate(); err != nil {
|
||||
return domain.Player{}, err
|
||||
}
|
||||
if displayName != "" {
|
||||
current.DisplayName = displayName
|
||||
if input.DisplayName != nil {
|
||||
current.DisplayName = *input.DisplayName
|
||||
}
|
||||
current.Ratings = ratings
|
||||
current.PreferredRoles = preferredRoles
|
||||
current.PreferredPlayerIDs = preferredPlayerIDs
|
||||
current.AvoidedPlayerIDs = avoidedPlayerIDs
|
||||
if input.BattleTag != nil {
|
||||
current.BattleTag = *input.BattleTag
|
||||
}
|
||||
if err := current.ValidateIdentity(); err != nil {
|
||||
return domain.Player{}, err
|
||||
}
|
||||
current.Ratings = input.Ratings
|
||||
current.PreferredRoles = input.PreferredRoles
|
||||
current.PreferredPlayerIDs = input.PreferredPlayerIDs
|
||||
current.AvoidedPlayerIDs = input.AvoidedPlayerIDs
|
||||
current.UpdatedAt = s.Now()
|
||||
if err := current.ValidatePreferences(); err != nil {
|
||||
return domain.Player{}, err
|
||||
@@ -168,6 +187,75 @@ func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, cu
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) GetPlayerProfile(ctx context.Context, playerID string) (domain.PlayerProfile, error) {
|
||||
player, err := s.Store.GetPlayer(ctx, playerID)
|
||||
if err != nil {
|
||||
return domain.PlayerProfile{}, err
|
||||
}
|
||||
events, err := s.Store.ListEvents(ctx, time.Unix(0, 0).UTC())
|
||||
if err != nil {
|
||||
return domain.PlayerProfile{}, err
|
||||
}
|
||||
matches := make([]domain.PlayerSeriesContext, 0)
|
||||
for _, event := range events {
|
||||
if event.State != domain.Completed {
|
||||
continue
|
||||
}
|
||||
teams, listErr := s.Store.ListTeams(ctx, event.ID)
|
||||
if listErr != nil {
|
||||
return domain.PlayerProfile{}, listErr
|
||||
}
|
||||
var ownTeam *domain.Team
|
||||
for index := range teams {
|
||||
for _, slot := range teams[index].Slots {
|
||||
if slot.PlayerID == playerID {
|
||||
ownTeam = &teams[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ownTeam == nil {
|
||||
continue
|
||||
}
|
||||
series, listErr := s.Store.ListSeriesByEvent(ctx, event.ID)
|
||||
if listErr != nil {
|
||||
return domain.PlayerProfile{}, listErr
|
||||
}
|
||||
byID := make(map[string]domain.Team, len(teams))
|
||||
for _, team := range teams {
|
||||
byID[team.ID] = team
|
||||
}
|
||||
for _, item := range series {
|
||||
teamA, okA := byID[item.TeamAID]
|
||||
teamB, okB := byID[item.TeamBID]
|
||||
if okA && okB {
|
||||
matches = append(matches, domain.PlayerSeriesContext{Event: event, Series: item, TeamA: teamA, TeamB: teamB})
|
||||
}
|
||||
}
|
||||
}
|
||||
return domain.BuildPlayerProfile(player, matches, 10), nil
|
||||
}
|
||||
|
||||
func (s *Service) GetCommunitySettings(ctx context.Context) (domain.CommunitySettings, error) {
|
||||
return s.Store.GetCommunitySettings(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateCommunitySettings(ctx context.Context, actor domain.Account, inviteURL string) (domain.CommunitySettings, error) {
|
||||
if !actor.IsAdmin() {
|
||||
return domain.CommunitySettings{}, domain.ErrForbidden
|
||||
}
|
||||
settings := domain.CommunitySettings{DiscordInviteURL: inviteURL, UpdatedAt: s.Now(), UpdatedBy: actor.ID}
|
||||
if err := settings.Validate(); err != nil {
|
||||
return domain.CommunitySettings{}, fmt.Errorf("%w: invalid Discord invite URL", err)
|
||||
}
|
||||
out, err := s.Store.UpdateCommunitySettings(ctx, settings)
|
||||
if err == nil {
|
||||
_ = s.Store.AppendAudit(ctx, actor.ID, "community.settings_updated", "community", out)
|
||||
s.Bus.Publish("community-settings", out)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event, options CreateEventOptions) (domain.Event, error) {
|
||||
if !actor.IsStaff() {
|
||||
return domain.Event{}, domain.ErrForbidden
|
||||
@@ -300,6 +388,9 @@ func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, e
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := player.ValidateIdentity(); err != nil {
|
||||
return domain.Player{}, domain.RSVP{}, err
|
||||
}
|
||||
rsvp := domain.RSVP{
|
||||
EventID: eventID,
|
||||
PlayerID: player.ID,
|
||||
|
||||
@@ -121,6 +121,85 @@ func TestCreateEventCanAnnounceWithoutEveryonePing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type communitySettingsStore struct {
|
||||
Store
|
||||
saved domain.CommunitySettings
|
||||
audits int
|
||||
}
|
||||
|
||||
func (s *communitySettingsStore) UpdateCommunitySettings(_ context.Context, settings domain.CommunitySettings) (domain.CommunitySettings, error) {
|
||||
s.saved = settings
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *communitySettingsStore) AppendAudit(_ context.Context, _, _, _ string, _ any) error {
|
||||
s.audits++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUpdateCommunitySettingsRequiresAdminAndAudits(t *testing.T) {
|
||||
store := &communitySettingsStore{}
|
||||
service := New(store, &recordingPublisher{}, nil)
|
||||
now := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC)
|
||||
service.Now = func() time.Time { return now }
|
||||
|
||||
if _, err := service.UpdateCommunitySettings(context.Background(), domain.Account{Role: domain.RoleModerator}, ""); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Fatalf("moderator must be forbidden, got %v", err)
|
||||
}
|
||||
out, err := service.UpdateCommunitySettings(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, " https://discord.gg/mixmaker ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.DiscordInviteURL != "https://discord.gg/mixmaker" || out.UpdatedBy != "admin" || !out.UpdatedAt.Equal(now) {
|
||||
t.Fatalf("unexpected settings: %+v", out)
|
||||
}
|
||||
if store.audits != 1 {
|
||||
t.Fatalf("expected one audit entry, got %d", store.audits)
|
||||
}
|
||||
}
|
||||
|
||||
type playerProfileStore struct {
|
||||
Store
|
||||
players []domain.Player
|
||||
saved domain.Player
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *playerProfileStore) ListPlayers(context.Context) ([]domain.Player, error) {
|
||||
return s.players, nil
|
||||
}
|
||||
|
||||
func (s *playerProfileStore) UpdatePlayer(_ context.Context, player domain.Player) (domain.Player, error) {
|
||||
s.saved = player
|
||||
return player, s.err
|
||||
}
|
||||
|
||||
func TestUpdateOwnProfileTrimsIdentityAndClearsBattleTag(t *testing.T) {
|
||||
store := &playerProfileStore{players: []domain.Player{{ID: "player"}}}
|
||||
service := New(store, &recordingPublisher{}, nil)
|
||||
name, battleTag := " New Name ", ""
|
||||
out, err := service.UpdateOwnProfile(context.Background(),
|
||||
domain.Account{ID: "account"},
|
||||
domain.Player{ID: "player", AccountID: "account", DisplayName: "Old", BattleTag: "Old#1234"},
|
||||
UpdateOwnProfileInput{
|
||||
DisplayName: &name, BattleTag: &battleTag,
|
||||
Ratings: domain.Ratings{Tank: 1, Damage: 20, Support: 40},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.DisplayName != "New Name" || out.BattleTag != "" {
|
||||
t.Fatalf("identity was not normalized and cleared: %+v", out)
|
||||
}
|
||||
if _, err = service.UpdateOwnProfile(context.Background(),
|
||||
domain.Account{ID: "other"}, domain.Player{AccountID: "account"},
|
||||
UpdateOwnProfileInput{},
|
||||
); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Fatalf("profile updates must remain self-only, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validEvent(now time.Time) domain.Event {
|
||||
return domain.Event{
|
||||
Name: "Sunday Mix",
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -63,6 +65,7 @@ type Player struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"accountId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
BattleTag string `json:"battleTag"`
|
||||
Ratings Ratings `json:"ratings"`
|
||||
PreferredRoles []Role `json:"preferredRoles"`
|
||||
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
|
||||
@@ -71,6 +74,21 @@ type Player struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
var battleTagPattern = regexp.MustCompile(`^[^#\s]{2,20}#[0-9]{3,12}$`)
|
||||
|
||||
func (p *Player) ValidateIdentity() error {
|
||||
p.DisplayName = strings.TrimSpace(p.DisplayName)
|
||||
length := utf8.RuneCountInString(p.DisplayName)
|
||||
if length < 2 || length > 32 {
|
||||
return fmt.Errorf("%w: display name must contain 2 to 32 characters", ErrInvalid)
|
||||
}
|
||||
p.BattleTag = strings.TrimSpace(p.BattleTag)
|
||||
if p.BattleTag != "" && !battleTagPattern.MatchString(p.BattleTag) {
|
||||
return fmt.Errorf("%w: BattleTag must have the form Name#digits", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p Player) ValidatePreferences() error {
|
||||
if len(p.PreferredPlayerIDs) > 3 {
|
||||
return fmt.Errorf("%w: at most three preferred teammates are allowed", ErrInvalid)
|
||||
|
||||
169
backend/internal/domain/player_profile.go
Normal file
169
backend/internal/domain/player_profile.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PublicPlayer struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
BattleTag string `json:"battleTag"`
|
||||
Ratings Ratings `json:"ratings"`
|
||||
IsGuest bool `json:"isGuest"`
|
||||
}
|
||||
|
||||
type PlayerStatsSummary struct {
|
||||
SeriesPlayed int `json:"seriesPlayed"`
|
||||
SeriesWon int `json:"seriesWon"`
|
||||
SeriesLost int `json:"seriesLost"`
|
||||
WinRate float64 `json:"winRate"`
|
||||
MapsPlayed int `json:"mapsPlayed"`
|
||||
MapsWon int `json:"mapsWon"`
|
||||
MapsLost int `json:"mapsLost"`
|
||||
MapsDrawn int `json:"mapsDrawn"`
|
||||
}
|
||||
|
||||
type PlayerMatchHistory struct {
|
||||
SeriesID string `json:"seriesId"`
|
||||
EventID string `json:"eventId"`
|
||||
EventName string `json:"eventName"`
|
||||
EventDate time.Time `json:"eventDate"`
|
||||
OwnTeamID string `json:"ownTeamId"`
|
||||
OwnTeamName string `json:"ownTeamName"`
|
||||
OpponentTeamID string `json:"opponentTeamId"`
|
||||
OpponentTeamName string `json:"opponentTeamName"`
|
||||
OwnScore int `json:"ownScore"`
|
||||
OpponentScore int `json:"opponentScore"`
|
||||
Outcome string `json:"outcome"`
|
||||
}
|
||||
|
||||
type PlayerProfile struct {
|
||||
Player PublicPlayer `json:"player"`
|
||||
Summary PlayerStatsSummary `json:"summary"`
|
||||
Recent []PlayerMatchHistory `json:"recentMatches"`
|
||||
}
|
||||
|
||||
type PlayerSeriesContext struct {
|
||||
Event Event
|
||||
Series Series
|
||||
TeamA Team
|
||||
TeamB Team
|
||||
}
|
||||
|
||||
func BuildPlayerProfile(player Player, matches []PlayerSeriesContext, recentLimit int) PlayerProfile {
|
||||
profile := PlayerProfile{
|
||||
Player: PublicPlayer{
|
||||
ID: player.ID, DisplayName: player.DisplayName, BattleTag: player.BattleTag,
|
||||
Ratings: player.Ratings, IsGuest: player.AccountID == "",
|
||||
},
|
||||
Recent: []PlayerMatchHistory{},
|
||||
}
|
||||
for _, match := range matches {
|
||||
if match.Event.State != Completed || match.Series.Phase != SeriesComplete || match.Series.WinnerTeamID == "" {
|
||||
continue
|
||||
}
|
||||
own, opponent, ownIsA := teamContaining(match.TeamA, player.ID), match.TeamB, true
|
||||
if own.ID == "" {
|
||||
own, opponent, ownIsA = teamContaining(match.TeamB, player.ID), match.TeamA, false
|
||||
}
|
||||
if own.ID == "" || opponent.ID == "" {
|
||||
continue
|
||||
}
|
||||
ownScore, opponentScore, mapsWon, mapsLost, mapsDrawn := scoreEffectiveResults(match.Series, ownIsA)
|
||||
profile.Summary.SeriesPlayed++
|
||||
profile.Summary.MapsPlayed += mapsWon + mapsLost + mapsDrawn
|
||||
profile.Summary.MapsWon += mapsWon
|
||||
profile.Summary.MapsLost += mapsLost
|
||||
profile.Summary.MapsDrawn += mapsDrawn
|
||||
outcome := "loss"
|
||||
if match.Series.WinnerTeamID == own.ID {
|
||||
outcome = "win"
|
||||
profile.Summary.SeriesWon++
|
||||
} else {
|
||||
profile.Summary.SeriesLost++
|
||||
}
|
||||
profile.Recent = append(profile.Recent, PlayerMatchHistory{
|
||||
SeriesID: match.Series.ID, EventID: match.Event.ID, EventName: match.Event.Name, EventDate: match.Event.StartsAt,
|
||||
OwnTeamID: own.ID, OwnTeamName: own.Name, OpponentTeamID: opponent.ID, OpponentTeamName: opponent.Name,
|
||||
OwnScore: ownScore, OpponentScore: opponentScore, Outcome: outcome,
|
||||
})
|
||||
}
|
||||
if profile.Summary.SeriesPlayed > 0 {
|
||||
profile.Summary.WinRate = float64(profile.Summary.SeriesWon) / float64(profile.Summary.SeriesPlayed)
|
||||
}
|
||||
sort.SliceStable(profile.Recent, func(i, j int) bool {
|
||||
if profile.Recent[i].EventDate.Equal(profile.Recent[j].EventDate) {
|
||||
return profile.Recent[i].SeriesID > profile.Recent[j].SeriesID
|
||||
}
|
||||
return profile.Recent[i].EventDate.After(profile.Recent[j].EventDate)
|
||||
})
|
||||
if recentLimit >= 0 && len(profile.Recent) > recentLimit {
|
||||
profile.Recent = profile.Recent[:recentLimit]
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func teamContaining(team Team, playerID string) Team {
|
||||
for _, slot := range team.Slots {
|
||||
if slot.PlayerID == playerID {
|
||||
return team
|
||||
}
|
||||
}
|
||||
return Team{}
|
||||
}
|
||||
|
||||
func scoreEffectiveResults(series Series, ownIsA bool) (ownScore, opponentScore, won, lost, drawn int) {
|
||||
superseded := make(map[int]bool)
|
||||
for _, result := range series.Results {
|
||||
if result.CorrectionOf > 0 {
|
||||
superseded[result.CorrectionOf] = true
|
||||
}
|
||||
}
|
||||
for index, result := range series.Results {
|
||||
if superseded[index+1] {
|
||||
continue
|
||||
}
|
||||
switch result.Outcome {
|
||||
case Draw:
|
||||
drawn++
|
||||
case TeamAWin:
|
||||
if ownIsA {
|
||||
ownScore, won = ownScore+1, won+1
|
||||
} else {
|
||||
opponentScore, lost = opponentScore+1, lost+1
|
||||
}
|
||||
case TeamBWin:
|
||||
if ownIsA {
|
||||
opponentScore, lost = opponentScore+1, lost+1
|
||||
} else {
|
||||
ownScore, won = ownScore+1, won+1
|
||||
}
|
||||
}
|
||||
}
|
||||
return ownScore, opponentScore, won, lost, drawn
|
||||
}
|
||||
|
||||
type CommunitySettings struct {
|
||||
DiscordInviteURL string `json:"discordInviteUrl"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
}
|
||||
|
||||
func (settings *CommunitySettings) Validate() error {
|
||||
settings.DiscordInviteURL = strings.TrimSpace(settings.DiscordInviteURL)
|
||||
if settings.DiscordInviteURL == "" {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasPrefix(settings.DiscordInviteURL, "https://discord.gg/") &&
|
||||
!strings.HasPrefix(settings.DiscordInviteURL, "https://discord.com/invite/") {
|
||||
return ErrInvalid
|
||||
}
|
||||
code := strings.TrimPrefix(settings.DiscordInviteURL, "https://discord.gg/")
|
||||
code = strings.TrimPrefix(code, "https://discord.com/invite/")
|
||||
if code == "" || strings.ContainsAny(code, "/?# \t\r\n") {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
80
backend/internal/domain/player_profile_test.go
Normal file
80
backend/internal/domain/player_profile_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPlayerIdentityValidation(t *testing.T) {
|
||||
player := Player{DisplayName: " Ana Main ", BattleTag: "Ana#1234"}
|
||||
if err := player.ValidateIdentity(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if player.DisplayName != "Ana Main" {
|
||||
t.Fatalf("display name was not trimmed: %q", player.DisplayName)
|
||||
}
|
||||
player.DisplayName = "A"
|
||||
if !errors.Is(player.ValidateIdentity(), ErrInvalid) {
|
||||
t.Fatal("one-character display name must be invalid")
|
||||
}
|
||||
player.DisplayName, player.BattleTag = "Valid", "invalid"
|
||||
if !errors.Is(player.ValidateIdentity(), ErrInvalid) {
|
||||
t.Fatal("invalid BattleTag must be rejected")
|
||||
}
|
||||
player.BattleTag = ""
|
||||
if err := player.ValidateIdentity(); err != nil {
|
||||
t.Fatalf("empty BattleTag must clear the value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlayerProfileUsesEffectiveResultsAndFinalRoster(t *testing.T) {
|
||||
start := time.Date(2026, time.July, 19, 18, 0, 0, 0, time.UTC)
|
||||
event := Event{ID: "event", Name: "Sunday Mix", StartsAt: start, State: Completed}
|
||||
teamA := Team{ID: "a", Name: "Alpha", Slots: []Slot{{PlayerID: "player"}}}
|
||||
teamB := Team{ID: "b", Name: "Bravo", Slots: []Slot{{PlayerID: "opponent"}}}
|
||||
series := Series{
|
||||
ID: "series", EventID: event.ID, TeamAID: teamA.ID, TeamBID: teamB.ID,
|
||||
WinnerTeamID: teamA.ID, Phase: SeriesComplete,
|
||||
Results: []MapResult{
|
||||
{Outcome: TeamBWin},
|
||||
{Outcome: TeamAWin},
|
||||
{Outcome: Draw},
|
||||
{Outcome: TeamAWin, CorrectionOf: 1},
|
||||
},
|
||||
}
|
||||
profile := BuildPlayerProfile(Player{ID: "player", DisplayName: "Player", AccountID: ""}, []PlayerSeriesContext{
|
||||
{Event: event, Series: series, TeamA: teamA, TeamB: teamB},
|
||||
{Event: Event{State: Live}, Series: series, TeamA: teamA, TeamB: teamB},
|
||||
{Event: event, Series: series, TeamA: Team{ID: "a"}, TeamB: teamB},
|
||||
}, 10)
|
||||
|
||||
if profile.Summary.SeriesPlayed != 1 || profile.Summary.SeriesWon != 1 || profile.Summary.WinRate != 1 {
|
||||
t.Fatalf("unexpected series summary: %+v", profile.Summary)
|
||||
}
|
||||
if profile.Summary.MapsPlayed != 3 || profile.Summary.MapsWon != 2 ||
|
||||
profile.Summary.MapsLost != 0 || profile.Summary.MapsDrawn != 1 {
|
||||
t.Fatalf("corrected map result was not aggregated correctly: %+v", profile.Summary)
|
||||
}
|
||||
if len(profile.Recent) != 1 || profile.Recent[0].OwnScore != 2 || profile.Recent[0].OpponentScore != 0 {
|
||||
t.Fatalf("unexpected history: %+v", profile.Recent)
|
||||
}
|
||||
if !profile.Player.IsGuest {
|
||||
t.Fatal("account-less players must be represented as guests")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunitySettingsValidation(t *testing.T) {
|
||||
for _, value := range []string{"", "https://discord.gg/mixmaker", "https://discord.com/invite/mixmaker"} {
|
||||
settings := CommunitySettings{DiscordInviteURL: value}
|
||||
if err := settings.Validate(); err != nil {
|
||||
t.Fatalf("%q should be valid: %v", value, err)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"http://discord.gg/code", "https://example.com/invite", "https://discord.gg/", "https://discord.gg/code/path"} {
|
||||
settings := CommunitySettings{DiscordInviteURL: value}
|
||||
if !errors.Is(settings.Validate(), ErrInvalid) {
|
||||
t.Fatalf("%q should be invalid", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user