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.
551 lines
21 KiB
Go
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 ""
|
|
}
|