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) 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) response.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(response).Encode(map[string]string{"id": id}) return } if request.Method == http.MethodPatch { var payload map[string]string _ = json.NewDecoder(request.Body).Decode(&payload) parts := strings.Split(request.URL.Path, "/") roleNames[parts[len(parts)-1]] = payload["name"] } 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", } 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) } 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) } if len(methods) != firstRequestCount { t.Fatalf("idempotent reconcile made %d extra Discord calls", len(methods)-firstRequestCount) } 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 { 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 "" }