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 "" }