Add Discord guild ID configuration and enhance event announcement options
This commit introduces the `DISCORD_GUILD_ID` environment variable to the configuration files, allowing for better integration with Discord for role synchronization. The event announcement functionality has been updated to include an option for the `@everyone` mention, which can be toggled during event creation. The backend logic has been modified to handle this new option, and corresponding updates have been made to the frontend to allow users to control the mention behavior. Additionally, tests have been added to ensure the correct functionality of these features.
This commit is contained in:
384
backend/internal/adapter/discord/rolesync_test.go
Normal file
384
backend/internal/adapter/discord/rolesync_test.go
Normal file
@@ -0,0 +1,384 @@
|
||||
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
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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 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}
|
||||
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)
|
||||
}
|
||||
|
||||
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 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 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user