Files
mixmaker/backend/internal/adapter/discord/rolesync_test.go
lemintare e5646ba33d
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled
Enhance Discord role management by adding hoist functionality
This commit introduces a new `hoist` attribute for Discord roles, allowing team roles and the registered role to be displayed in separate groups within the Discord member list. The `RoleWorker` has been updated to ensure team roles are positioned above the registered role, improving visibility and organization. Database schema changes have been made to support the new `hoist` field, and corresponding updates have been implemented in the service layer and tests to validate the new behavior.
2026-07-19 11:52:57 +03:00

551 lines
21 KiB
Go

package discord
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sort"
"strings"
"sync"
"testing"
"time"
"mixmaker/backend/internal/application"
"mixmaker/backend/internal/domain"
)
type roleStoreFake struct {
mu sync.Mutex
jobs []application.DiscordRoleSyncJob
roles map[string]application.DiscordManagedRole
assignments map[string]map[string]bool
rosters map[string]application.DiscordRoleRoster
registrations map[string]application.DiscordRoleRegistration
active []application.DiscordRoleRoster
warnings []string
}
func newRoleStoreFake() *roleStoreFake {
return &roleStoreFake{
roles: make(map[string]application.DiscordManagedRole),
assignments: make(map[string]map[string]bool),
rosters: make(map[string]application.DiscordRoleRoster),
registrations: make(map[string]application.DiscordRoleRegistration),
}
}
func (s *roleStoreFake) SeedDiscordRoleSyncJobs(context.Context) error { return nil }
func (s *roleStoreFake) ClaimDiscordRoleSyncJob(context.Context) (application.DiscordRoleSyncJob, error) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.jobs) == 0 {
return application.DiscordRoleSyncJob{}, domain.ErrNotFound
}
job := s.jobs[0]
s.jobs = s.jobs[1:]
job.Attempts++
return job, nil
}
func (s *roleStoreFake) CompleteDiscordRoleSyncJob(_ context.Context, _ int64, warning string) error {
s.warnings = append(s.warnings, warning)
return nil
}
func (s *roleStoreFake) RetryDiscordRoleSyncJob(_ context.Context, _ int64, message string, _ time.Time) error {
s.warnings = append(s.warnings, message)
return nil
}
func (s *roleStoreFake) ListDiscordManagedRoles(_ context.Context, eventID string) ([]application.DiscordManagedRole, error) {
out := make([]application.DiscordManagedRole, 0)
for _, role := range s.roles {
if role.Scope == application.DiscordRoleScopeGlobal || role.EventID == eventID {
out = append(out, role)
}
}
return out, nil
}
func (s *roleStoreFake) UpsertDiscordManagedRole(_ context.Context, role application.DiscordManagedRole) error {
s.roles[managedRoleKey(role)] = role
return nil
}
func (s *roleStoreFake) DeleteDiscordManagedRole(_ context.Context, role application.DiscordManagedRole) error {
delete(s.assignments, role.DiscordRoleID)
delete(s.roles, managedRoleKey(role))
return nil
}
func (s *roleStoreFake) ListDiscordRoleAssignments(_ context.Context, roleID string) ([]string, error) {
out := make([]string, 0)
for userID := range s.assignments[roleID] {
out = append(out, userID)
}
return out, nil
}
func (s *roleStoreFake) UpsertDiscordRoleAssignment(_ context.Context, roleID, userID string) error {
if s.assignments[roleID] == nil {
s.assignments[roleID] = make(map[string]bool)
}
s.assignments[roleID][userID] = true
return nil
}
func (s *roleStoreFake) DeleteDiscordRoleAssignment(_ context.Context, roleID, userID string) error {
delete(s.assignments[roleID], userID)
return nil
}
func (s *roleStoreFake) GetDiscordRoleRoster(_ context.Context, eventID string) (application.DiscordRoleRoster, error) {
roster, ok := s.rosters[eventID]
if !ok {
return roster, domain.ErrNotFound
}
return roster, nil
}
func (s *roleStoreFake) ListActiveDiscordRoleRosters(context.Context) ([]application.DiscordRoleRoster, error) {
return s.active, nil
}
func (s *roleStoreFake) GetDiscordRoleRegistration(_ context.Context, eventID string) (application.DiscordRoleRegistration, error) {
registration, ok := s.registrations[eventID]
if !ok {
return registration, domain.ErrNotFound
}
return registration, nil
}
func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
var mu sync.Mutex
roleNames := make(map[string]string)
roleHoists := make(map[string]bool)
rolePositions := map[string]int{"role-rsvp": 1}
methods := make([]string, 0)
nextID := 0
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
mu.Lock()
defer mu.Unlock()
methods = append(methods, request.Method+" "+request.URL.Path)
if request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/roles") {
var payload map[string]any
_ = json.NewDecoder(request.Body).Decode(&payload)
nextID++
id := "role-" + string(rune('0'+nextID))
roleNames[id], _ = payload["name"].(string)
roleHoists[id], _ = payload["hoist"].(bool)
rolePositions[id] = 0
response.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(response).Encode(map[string]string{"id": id})
return
}
if request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/roles") {
roles := []map[string]any{{"id": "role-rsvp", "position": rolePositions["role-rsvp"]}}
for id := range roleNames {
roles = append(roles, map[string]any{"id": id, "position": rolePositions[id]})
}
response.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(response).Encode(roles)
return
}
if request.Method == http.MethodPatch {
parts := strings.Split(request.URL.Path, "/")
roleID := parts[len(parts)-1]
if roleID == "roles" {
var payload []struct {
ID string `json:"id"`
Position int `json:"position"`
}
_ = json.NewDecoder(request.Body).Decode(&payload)
for _, item := range payload {
rolePositions[item.ID] = item.Position
}
} else {
var payload map[string]any
_ = json.NewDecoder(request.Body).Decode(&payload)
roleNames[roleID], _ = payload["name"].(string)
}
}
response.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
store := newRoleStoreFake()
item := testDiscordRoster("Alpha")
store.rosters["event-1"] = item
store.active = []application.DiscordRoleRoster{item}
rsvpRole := application.DiscordManagedRole{
Scope: application.DiscordRoleScopeEvent, EventID: "event-1",
Kind: application.DiscordRoleKindRegistered, DiscordRoleID: "role-rsvp", RoleName: "Зарегистрирован: Mix", Hoist: true,
}
store.roles[managedRoleKey(rsvpRole)] = rsvpRole
store.jobs = []application.DiscordRoleSyncJob{{ID: 1, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
worker, err := NewRoleWorker(store, RoleSyncConfig{
BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if processed, err := worker.ProcessNext(context.Background()); err != nil || !processed {
t.Fatalf("first reconcile: processed=%v err=%v", processed, err)
}
names := make([]string, 0, len(roleNames))
for _, name := range roleNames {
names = append(names, name)
}
sort.Strings(names)
expectedNames := []string{"Alpha", "Alpha Captain", "Damage", "Support", "Tank"}
if strings.Join(names, ",") != strings.Join(expectedNames, ",") {
t.Fatalf("unexpected role names: %v", names)
}
for id, name := range roleNames {
if roleHoists[id] != (name == "Alpha") {
t.Fatalf("unexpected hoist for %s: %v", name, roleHoists[id])
}
if name == "Alpha" && rolePositions[id] <= rolePositions["role-rsvp"] {
t.Fatalf("team role was not moved above registered role: team=%d registered=%d", rolePositions[id], rolePositions["role-rsvp"])
}
}
if got := assignmentCount(store.assignments); got != 11 {
t.Fatalf("expected 11 managed assignments, got %d", got)
}
if _, ok := store.roles[managedRoleKey(rsvpRole)]; !ok {
t.Fatal("roster reconcile deleted an RSVP role")
}
firstRequestCount := len(methods)
store.jobs = []application.DiscordRoleSyncJob{{ID: 2, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
for _, method := range methods[firstRequestCount:] {
if !strings.HasPrefix(method, http.MethodGet+" ") {
t.Fatalf("idempotent reconcile made a mutating Discord call: %s", method)
}
}
renamed := testDiscordRoster("Night Owls")
store.rosters["event-1"] = renamed
store.active = []application.DiscordRoleRoster{renamed}
store.jobs = []application.DiscordRoleSyncJob{{ID: 3, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
patches := 0
for _, method := range methods[firstRequestCount:] {
if strings.HasPrefix(method, http.MethodPatch+" ") {
patches++
}
}
if patches != 2 {
t.Fatalf("expected team and captain role rename, got %d PATCH calls", patches)
}
roleChanged := testDiscordRoster("Night Owls")
roleChanged.Roster.Teams[0].Slots[0].Role = domain.Damage
roleChanged.Roster.Teams[0].Slots[1].Role = domain.Tank
store.rosters["event-1"] = roleChanged
store.active = []application.DiscordRoleRoster{roleChanged}
store.jobs = []application.DiscordRoleSyncJob{{ID: 4, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
tankRoleID := managedRoleID(store.roles, application.DiscordRoleKindTank)
damageRoleID := managedRoleID(store.roles, application.DiscordRoleKindDamage)
if !store.assignments[tankRoleID]["user-2"] || store.assignments[tankRoleID]["user-1"] {
t.Fatalf("tank assignment did not follow changed slot: %v", store.assignments[tankRoleID])
}
if !store.assignments[damageRoleID]["user-1"] || store.assignments[damageRoleID]["user-2"] {
t.Fatalf("damage assignment did not follow changed slot: %v", store.assignments[damageRoleID])
}
}
func TestRoleWorkerReconcilesRSVPStatusAndEventRename(t *testing.T) {
nextID := 0
patches := 0
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost {
var payload map[string]any
_ = json.NewDecoder(request.Body).Decode(&payload)
name, _ := payload["name"].(string)
hoist, _ := payload["hoist"].(bool)
if hoist != strings.HasPrefix(name, "Зарегистрирован:") {
t.Errorf("unexpected RSVP role hoist: name=%q hoist=%v", name, hoist)
}
nextID++
_ = json.NewEncoder(response).Encode(map[string]string{"id": "rsvp-role-" + string(rune('0'+nextID))})
return
}
if request.Method == http.MethodPatch {
patches++
}
response.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
store := newRoleStoreFake()
store.registrations["event-1"] = testDiscordRegistration("Sunday Mix", domain.Going)
store.jobs = []application.DiscordRoleSyncJob{{ID: 1, EventID: "event-1", Action: application.DiscordRoleActionRSVP}}
worker, err := NewRoleWorker(store, RoleSyncConfig{
BotToken: "token", GuildID: "guild", Locale: "ru", APIBaseURL: server.URL, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
registeredID := eventRoleID(store.roles, "event-1", application.DiscordRoleKindRegistered)
goingID := eventRoleID(store.roles, "event-1", application.DiscordRoleKindGoing)
maybeID := eventRoleID(store.roles, "event-1", application.DiscordRoleKindMaybe)
notGoingID := eventRoleID(store.roles, "event-1", application.DiscordRoleKindNotGoing)
if !store.assignments[registeredID]["user-1"] || !store.assignments[goingID]["user-1"] {
t.Fatalf("initial Going roles were not assigned: %v", store.assignments)
}
store.registrations["event-1"] = testDiscordRegistration("Sunday Mix", domain.Maybe)
store.jobs = []application.DiscordRoleSyncJob{{ID: 2, EventID: "event-1", Action: application.DiscordRoleActionRSVP}}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
if store.assignments[goingID]["user-1"] || !store.assignments[maybeID]["user-1"] || !store.assignments[registeredID]["user-1"] {
t.Fatalf("Going to Maybe transition was not reconciled: %v", store.assignments)
}
store.registrations["event-1"] = testDiscordRegistration("Sunday Mix", domain.NotGoing)
store.jobs = []application.DiscordRoleSyncJob{{ID: 3, EventID: "event-1", Action: application.DiscordRoleActionRSVP}}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
if store.assignments[maybeID]["user-1"] || !store.assignments[notGoingID]["user-1"] || !store.assignments[registeredID]["user-1"] {
t.Fatalf("Maybe to NotGoing transition was not reconciled: %v", store.assignments)
}
renamed := testDiscordRegistration("Night Mix", domain.NotGoing)
store.registrations["event-1"] = renamed
store.jobs = []application.DiscordRoleSyncJob{{ID: 4, EventID: "event-1", Action: application.DiscordRoleActionRSVP}}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
if patches != 4 {
t.Fatalf("expected four RSVP role renames, got %d", patches)
}
renamed.Registrations = nil
renamed.PlayerDiscordIDs = map[string]string{}
store.registrations["event-1"] = renamed
store.jobs = []application.DiscordRoleSyncJob{{ID: 5, EventID: "event-1", Action: application.DiscordRoleActionRSVP}}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
for _, roleID := range []string{registeredID, goingID, maybeID, notGoingID} {
if len(store.assignments[roleID]) != 0 {
t.Fatalf("RSVP deletion left assignments on role %s: %v", roleID, store.assignments[roleID])
}
}
renamed.Registrations = []domain.RSVP{{EventID: "event-1", PlayerID: "guest", Status: domain.Going}}
store.registrations["event-1"] = renamed
store.jobs = []application.DiscordRoleSyncJob{{ID: 6, EventID: "event-1", Action: application.DiscordRoleActionRSVP}}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
if warning := store.warnings[len(store.warnings)-1]; !strings.Contains(warning, "has no linked Discord account") {
t.Fatalf("guest warning was not stored: %q", warning)
}
}
func TestRoleWorkerStoresMissingGuildMemberWarning(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost {
_ = json.NewEncoder(response).Encode(map[string]string{"id": strings.ReplaceAll(request.URL.Path, "/", "-") + time.Now().String()})
return
}
if request.Method == http.MethodPut && strings.Contains(request.URL.Path, "/members/missing/") {
http.Error(response, `{"message":"Unknown Member"}`, http.StatusNotFound)
return
}
response.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
store := newRoleStoreFake()
item := testDiscordRoster("Alpha")
item.PlayerDiscordIDs["player-1"] = "missing"
store.rosters["event-1"] = item
store.active = []application.DiscordRoleRoster{item}
store.jobs = []application.DiscordRoleSyncJob{{ID: 1, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
worker, err := NewRoleWorker(store, RoleSyncConfig{BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client()})
if err != nil {
t.Fatal(err)
}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
if len(store.warnings) != 1 || !strings.Contains(store.warnings[0], "not in the guild") {
t.Fatalf("missing member warning was not stored: %v", store.warnings)
}
}
func TestRoleWorkerTeardownPreservesOtherEventGlobalAssignments(t *testing.T) {
nextID := 0
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost {
nextID++
_ = json.NewEncoder(response).Encode(map[string]string{"id": "role-" + string(rune('0'+nextID))})
return
}
response.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
store := newRoleStoreFake()
first := testDiscordRoster("Alpha")
second := testDiscordRoster("Bravo")
second.Roster.EventID = "event-2"
second.Roster.Teams[0].EventID = "event-2"
second.Roster.Teams[0].ID = "team-2"
second.PlayerDiscordIDs = make(map[string]string)
for index := range second.Roster.Teams[0].Slots {
playerID := "other-" + string(rune('1'+index))
second.Roster.Teams[0].Slots[index].PlayerID = playerID
second.PlayerDiscordIDs[playerID] = "other-user-" + string(rune('1'+index))
}
second.Roster.Teams[0].CaptainPlayerID = second.Roster.Teams[0].Slots[0].PlayerID
store.rosters["event-1"], store.rosters["event-2"] = first, second
store.active = []application.DiscordRoleRoster{first, second}
store.jobs = []application.DiscordRoleSyncJob{
{ID: 1, EventID: "event-1", Action: application.DiscordRoleActionReconcile},
{ID: 2, EventID: "event-2", Action: application.DiscordRoleActionReconcile},
}
worker, err := NewRoleWorker(store, RoleSyncConfig{BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client()})
if err != nil {
t.Fatal(err)
}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
store.active = []application.DiscordRoleRoster{second}
store.jobs = []application.DiscordRoleSyncJob{{ID: 3, EventID: "event-1", Action: application.DiscordRoleActionTeardown}}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
globalAssignments := 0
for _, role := range store.roles {
if role.Scope == application.DiscordRoleScopeEvent && role.EventID == "event-1" {
t.Fatalf("event-1 role was not deleted: %+v", role)
}
if role.Scope == application.DiscordRoleScopeGlobal {
globalAssignments += len(store.assignments[role.DiscordRoleID])
for userID := range store.assignments[role.DiscordRoleID] {
if !strings.HasPrefix(userID, "other-user-") {
t.Fatalf("stale global assignment remained after teardown: %s", userID)
}
}
}
}
if globalAssignments != 5 {
t.Fatalf("expected five global assignments for the other event, got %d", globalAssignments)
}
}
func TestRoleManagerReturnsRateLimit(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("Retry-After", "2")
http.Error(response, `{"message":"rate limited"}`, http.StatusTooManyRequests)
}))
defer server.Close()
manager, err := newRoleManager(RoleSyncConfig{BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client()})
if err != nil {
t.Fatal(err)
}
err = manager.AddMemberRole(context.Background(), "user", "role")
var httpErr *discordHTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusTooManyRequests || retryDelay(1, err) != 2*time.Second {
t.Fatalf("unexpected rate limit error: %#v", err)
}
}
func TestRoleManagerReturnsServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
http.Error(response, `{"message":"temporary failure"}`, http.StatusBadGateway)
}))
defer server.Close()
manager, err := newRoleManager(RoleSyncConfig{BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client()})
if err != nil {
t.Fatal(err)
}
err = manager.DeleteRole(context.Background(), "role")
var httpErr *discordHTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusBadGateway {
t.Fatalf("unexpected server error: %#v", err)
}
}
func testDiscordRoster(name string) application.DiscordRoleRoster {
slots := []domain.Slot{
{PlayerID: "player-1", Role: domain.Tank},
{PlayerID: "player-2", Role: domain.Damage},
{PlayerID: "player-3", Role: domain.Damage},
{PlayerID: "player-4", Role: domain.Support},
{PlayerID: "player-5", Role: domain.Support},
}
discordIDs := make(map[string]string)
for index := 1; index <= 5; index++ {
discordIDs["player-"+string(rune('0'+index))] = "user-" + string(rune('0'+index))
}
return application.DiscordRoleRoster{
Roster: domain.RosterDraft{
EventID: "event-1",
Teams: []domain.Team{{
ID: "team-1", EventID: "event-1", Name: name, CaptainPlayerID: "player-1", Slots: slots,
}},
Confirmed: true,
},
PlayerDiscordIDs: discordIDs,
}
}
func testDiscordRegistration(name string, status domain.RSVPStatus) application.DiscordRoleRegistration {
return application.DiscordRoleRegistration{
Event: domain.Event{ID: "event-1", Name: name, State: domain.RegistrationOpen},
Registrations: []domain.RSVP{{
EventID: "event-1", PlayerID: "player-1", Status: status,
}},
PlayerDiscordIDs: map[string]string{"player-1": "user-1"},
}
}
func assignmentCount(assignments map[string]map[string]bool) int {
total := 0
for _, users := range assignments {
total += len(users)
}
return total
}
func managedRoleID(roles map[string]application.DiscordManagedRole, kind string) string {
for _, role := range roles {
if role.Scope == application.DiscordRoleScopeGlobal && role.Kind == kind {
return role.DiscordRoleID
}
}
return ""
}
func eventRoleID(roles map[string]application.DiscordManagedRole, eventID, kind string) string {
for _, role := range roles {
if role.Scope == application.DiscordRoleScopeEvent && role.EventID == eventID && role.Kind == kind {
return role.DiscordRoleID
}
}
return ""
}