This commit introduces a new feature for global role synchronization in Discord, allowing for periodic reconciliation of managed roles. A new environment variable, `DISCORD_GLOBAL_SYNC_INTERVAL`, has been added to configure the synchronization interval, defaulting to 5 minutes. The `RoleWorker` has been updated to schedule global sync jobs, ensuring that missing managed roles are restored and extra assignments are removed without affecting unrelated server roles. Database schema changes support the new synchronization logic, and tests have been added to validate the functionality of the global reconciliation process.
745 lines
28 KiB
Go
745 lines
28 KiB
Go
package discord
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"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
|
||
retries int
|
||
globalBuckets []int64
|
||
}
|
||
|
||
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) ScheduleGlobalDiscordRoleSync(_ context.Context, bucket int64) error {
|
||
s.globalBuckets = append(s.globalBuckets, bucket)
|
||
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)
|
||
s.retries++
|
||
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) ListAllDiscordManagedRoles(context.Context) ([]application.DiscordManagedRole, error) {
|
||
out := make([]application.DiscordManagedRole, 0, len(s.roles))
|
||
for _, role := range s.roles {
|
||
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) SetDiscordRoleAssignments(_ context.Context, roleID string, userIDs []string) error {
|
||
s.assignments[roleID] = make(map[string]bool, len(userIDs))
|
||
for _, userID := range userIDs {
|
||
s.assignments[roleID][userID] = true
|
||
}
|
||
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 (s *roleStoreFake) ListActiveDiscordRoleRegistrations(context.Context) ([]application.DiscordRoleRegistration, error) {
|
||
out := make([]application.DiscordRoleRegistration, 0, len(s.registrations))
|
||
for _, registration := range s.registrations {
|
||
out = append(out, registration)
|
||
}
|
||
return out, 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 TestGlobalReconcileRestoresExactManagedAssignments(t *testing.T) {
|
||
type actualRole struct {
|
||
Name string
|
||
Hoist bool
|
||
Position int
|
||
}
|
||
actualRoles := map[string]actualRole{
|
||
"going-role": {Name: "Идёт: Sunday Mix"},
|
||
"maybe-role": {Name: "Возможно: Sunday Mix"},
|
||
"not-going-role": {Name: "Не идёт: Sunday Mix"},
|
||
}
|
||
memberRoles := map[string]map[string]bool{
|
||
"user-1": {"foreign-role": true, "maybe-role": true},
|
||
"user-2": {"going-role": true},
|
||
}
|
||
mutations := 0
|
||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||
parts := strings.Split(strings.Trim(request.URL.Path, "/"), "/")
|
||
if request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/roles") {
|
||
payload := make([]map[string]any, 0, len(actualRoles))
|
||
for id, role := range actualRoles {
|
||
payload = append(payload, map[string]any{"id": id, "name": role.Name, "hoist": role.Hoist, "position": role.Position})
|
||
}
|
||
_ = json.NewEncoder(response).Encode(payload)
|
||
return
|
||
}
|
||
if request.Method == http.MethodGet && strings.Contains(request.URL.Path, "/members") {
|
||
payload := make([]map[string]any, 0, len(memberRoles))
|
||
for userID, roles := range memberRoles {
|
||
roleIDs := make([]string, 0, len(roles))
|
||
for roleID := range roles {
|
||
roleIDs = append(roleIDs, roleID)
|
||
}
|
||
payload = append(payload, map[string]any{"user": map[string]string{"id": userID}, "roles": roleIDs})
|
||
}
|
||
_ = json.NewEncoder(response).Encode(payload)
|
||
return
|
||
}
|
||
mutations++
|
||
if request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/roles") {
|
||
var payload map[string]any
|
||
_ = json.NewDecoder(request.Body).Decode(&payload)
|
||
actualRoles["recreated-registered"] = actualRole{
|
||
Name: payload["name"].(string), Hoist: payload["hoist"].(bool),
|
||
}
|
||
_ = json.NewEncoder(response).Encode(map[string]string{"id": "recreated-registered"})
|
||
return
|
||
}
|
||
if len(parts) >= 6 && parts[2] == "members" && parts[4] == "roles" {
|
||
userID, roleID := parts[3], parts[5]
|
||
if memberRoles[userID] == nil {
|
||
memberRoles[userID] = make(map[string]bool)
|
||
}
|
||
if request.Method == http.MethodPut {
|
||
memberRoles[userID][roleID] = true
|
||
} else if request.Method == http.MethodDelete {
|
||
delete(memberRoles[userID], roleID)
|
||
}
|
||
response.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
response.WriteHeader(http.StatusNoContent)
|
||
}))
|
||
defer server.Close()
|
||
|
||
store := newRoleStoreFake()
|
||
for _, role := range []application.DiscordManagedRole{
|
||
{Scope: application.DiscordRoleScopeEvent, EventID: "event-1", Kind: application.DiscordRoleKindRegistered, DiscordRoleID: "deleted-registered", RoleName: "Зарегистрирован: Sunday Mix", Hoist: true},
|
||
{Scope: application.DiscordRoleScopeEvent, EventID: "event-1", Kind: application.DiscordRoleKindGoing, DiscordRoleID: "going-role", RoleName: "Идёт: Sunday Mix"},
|
||
{Scope: application.DiscordRoleScopeEvent, EventID: "event-1", Kind: application.DiscordRoleKindMaybe, DiscordRoleID: "maybe-role", RoleName: "Возможно: Sunday Mix"},
|
||
{Scope: application.DiscordRoleScopeEvent, EventID: "event-1", Kind: application.DiscordRoleKindNotGoing, DiscordRoleID: "not-going-role", RoleName: "Не идёт: Sunday Mix"},
|
||
} {
|
||
store.roles[managedRoleKey(role)] = role
|
||
}
|
||
store.registrations["event-1"] = testDiscordRegistration("Sunday Mix", domain.Going)
|
||
store.jobs = []application.DiscordRoleSyncJob{{ID: 1, EventID: "__global__", Action: application.DiscordRoleActionFullReconcile}}
|
||
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)
|
||
if registeredID != "recreated-registered" {
|
||
t.Fatalf("deleted managed role was not recreated: %q", registeredID)
|
||
}
|
||
if !memberRoles["user-1"][registeredID] || !memberRoles["user-1"]["going-role"] {
|
||
t.Fatalf("missing desired roles were not restored: %v", memberRoles["user-1"])
|
||
}
|
||
if memberRoles["user-1"]["maybe-role"] || memberRoles["user-2"]["going-role"] {
|
||
t.Fatalf("extra managed assignments were not removed: %v", memberRoles)
|
||
}
|
||
if !memberRoles["user-1"]["foreign-role"] {
|
||
t.Fatal("foreign server role was modified")
|
||
}
|
||
firstMutations := mutations
|
||
store.jobs = []application.DiscordRoleSyncJob{{ID: 2, EventID: "__global__", Action: application.DiscordRoleActionFullReconcile}}
|
||
if _, err = worker.ProcessNext(context.Background()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if mutations != firstMutations {
|
||
t.Fatalf("idempotent global reconcile made %d mutations", mutations-firstMutations)
|
||
}
|
||
}
|
||
|
||
func TestRoleWorkerRetriesMissingGuildMember(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)
|
||
}
|
||
if store.retries != 1 {
|
||
t.Fatalf("missing guild member job was not retried: %d", store.retries)
|
||
}
|
||
if delay := retryDelay(1, &missingGuildMembersError{warnings: []string{"missing"}}); delay != 5*time.Minute {
|
||
t.Fatalf("unexpected missing member retry delay: %s", delay)
|
||
}
|
||
}
|
||
|
||
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 TestRoleManagerPaginatesGuildMembers(t *testing.T) {
|
||
requests := 0
|
||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||
requests++
|
||
if request.URL.Query().Get("after") == "" {
|
||
page := make([]map[string]any, 1000)
|
||
for index := range page {
|
||
page[index] = map[string]any{"user": map[string]string{"id": fmt.Sprintf("user-%04d", index)}, "roles": []string{}}
|
||
}
|
||
_ = json.NewEncoder(response).Encode(page)
|
||
return
|
||
}
|
||
_ = json.NewEncoder(response).Encode([]map[string]any{{
|
||
"user": map[string]string{"id": "user-1000"}, "roles": []string{},
|
||
}})
|
||
}))
|
||
defer server.Close()
|
||
manager, err := newRoleManager(RoleSyncConfig{BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client()})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
members, err := manager.ListGuildMembers(context.Background())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(members) != 1001 || requests != 2 {
|
||
t.Fatalf("unexpected pagination result: members=%d requests=%d", len(members), requests)
|
||
}
|
||
}
|
||
|
||
func TestRoleWorkerSchedulesStableGlobalBucket(t *testing.T) {
|
||
store := newRoleStoreFake()
|
||
worker, err := NewRoleWorker(store, RoleSyncConfig{
|
||
BotToken: "token", GuildID: "guild", GlobalSyncInterval: 5 * time.Minute,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
worker.now = func() time.Time { return time.Unix(1_000, 0).UTC() }
|
||
worker.scheduleGlobalSync(context.Background())
|
||
worker.now = func() time.Time { return time.Unix(1_001, 0).UTC() }
|
||
worker.scheduleGlobalSync(context.Background())
|
||
if len(store.globalBuckets) != 2 || store.globalBuckets[0] != store.globalBuckets[1] {
|
||
t.Fatalf("same interval produced different buckets: %v", store.globalBuckets)
|
||
}
|
||
}
|
||
|
||
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 ""
|
||
}
|