Add Discord guild ID configuration and enhance event announcement options
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

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:
2026-07-19 11:34:11 +03:00
parent ae19c03542
commit c3624376b0
29 changed files with 1524 additions and 37 deletions

View File

@@ -116,16 +116,16 @@ func New(config Config) (*Announcer, error) {
}, nil
}
func (a *Announcer) AnnounceEventCreated(ctx context.Context, event domain.Event) error {
err := a.announceEventCreated(ctx, event)
func (a *Announcer) AnnounceEventCreated(ctx context.Context, event domain.Event, pingEveryone bool) error {
err := a.announceEventCreated(ctx, event, pingEveryone)
if err != nil {
slog.Error("Discord event announcement failed", "event_id", event.ID, "error", err)
}
return err
}
func (a *Announcer) announceEventCreated(ctx context.Context, event domain.Event) error {
payload, err := json.Marshal(a.createdMessage(event))
func (a *Announcer) announceEventCreated(ctx context.Context, event domain.Event, pingEveryone bool) error {
payload, err := json.Marshal(a.createdMessage(event, pingEveryone))
if err != nil {
return fmt.Errorf("encode Discord message: %w", err)
}
@@ -149,11 +149,10 @@ func (a *Announcer) announceEventCreated(ctx context.Context, event domain.Event
return nil
}
func (a *Announcer) createdMessage(event domain.Event) messagePayload {
func (a *Announcer) createdMessage(event domain.Event, pingEveryone bool) messagePayload {
text := announcementTranslations(a.locale)
eventURL := a.publicURL + "/events/" + url.PathEscape(event.ID)
return messagePayload{
Content: "@everyone",
message := messagePayload{
Embeds: []embed{{
Title: truncate(text.titlePrefix+event.Name, 256),
Description: truncate(event.Description, 4096),
@@ -173,8 +172,13 @@ func (a *Announcer) createdMessage(event domain.Event) messagePayload {
URL: eventURL,
}},
}},
AllowedMentions: allowedMentions{Parse: []string{"everyone"}},
AllowedMentions: allowedMentions{Parse: []string{}},
}
if pingEveryone {
message.Content = "@everyone"
message.AllowedMentions.Parse = []string{"everyone"}
}
return message
}
func announcementTranslations(locale string) translations {

View File

@@ -47,7 +47,7 @@ func TestAnnounceEventCreated(t *testing.T) {
RegistrationDeadline: start.Add(-2 * time.Hour),
}
if err := announcer.AnnounceEventCreated(context.Background(), event); err != nil {
if err := announcer.AnnounceEventCreated(context.Background(), event, true); err != nil {
t.Fatal(err)
}
if received.Content != "@everyone" {
@@ -75,6 +75,26 @@ func TestAnnounceEventCreated(t *testing.T) {
}
}
func TestCreatedMessageCanOmitEveryonePing(t *testing.T) {
announcer, err := New(Config{
BotToken: "secret-token",
ChannelID: "channel-123",
PublicURL: "https://mix.example.com",
})
if err != nil {
t.Fatal(err)
}
event := domain.Event{ID: "event-456", Name: "Quiet mix"}
message := announcer.createdMessage(event, false)
if message.Content != "" || len(message.AllowedMentions.Parse) != 0 {
t.Fatalf("unexpected mention in quiet announcement: content=%q mentions=%+v", message.Content, message.AllowedMentions)
}
if len(message.Embeds) != 1 || len(message.Components) != 1 {
t.Fatalf("quiet announcement must retain embed and registration button: %+v", message)
}
}
func TestAnnounceEventCreatedReturnsDiscordError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
http.Error(response, `{"message":"Missing Permissions"}`, http.StatusForbidden)
@@ -91,7 +111,7 @@ func TestAnnounceEventCreatedReturnsDiscordError(t *testing.T) {
t.Fatal(err)
}
if err := announcer.AnnounceEventCreated(context.Background(), domain.Event{ID: "event-456"}); err == nil {
if err := announcer.AnnounceEventCreated(context.Background(), domain.Event{ID: "event-456"}, true); err == nil {
t.Fatal("expected Discord error")
}
}

View File

@@ -0,0 +1,518 @@
package discord
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"mixmaker/backend/internal/application"
"mixmaker/backend/internal/domain"
)
type RoleSyncConfig struct {
BotToken string
GuildID string
APIBaseURL string
HTTPClient *http.Client
PollInterval time.Duration
JobTimeout time.Duration
}
type RoleWorker struct {
store application.DiscordRoleStore
manager *RoleManager
pollInterval time.Duration
jobTimeout time.Duration
now func() time.Time
}
type RoleManager struct {
botToken string
guildID string
apiBaseURL string
httpClient *http.Client
}
type discordRoleResponse struct {
ID string `json:"id"`
}
type discordHTTPError struct {
StatusCode int
Status string
Body string
RetryAfter time.Duration
}
func (e *discordHTTPError) Error() string {
return fmt.Sprintf("Discord returned %s: %s", e.Status, e.Body)
}
func NewRoleWorker(store application.DiscordRoleStore, config RoleSyncConfig) (*RoleWorker, error) {
if store == nil {
return nil, errors.New("Discord role store is required")
}
manager, err := newRoleManager(config)
if err != nil {
return nil, err
}
pollInterval := config.PollInterval
if pollInterval <= 0 {
pollInterval = 2 * time.Second
}
jobTimeout := config.JobTimeout
if jobTimeout <= 0 {
jobTimeout = 30 * time.Second
}
return &RoleWorker{
store: store,
manager: manager,
pollInterval: pollInterval,
jobTimeout: jobTimeout,
now: func() time.Time { return time.Now().UTC() },
}, nil
}
func newRoleManager(config RoleSyncConfig) (*RoleManager, error) {
if strings.TrimSpace(config.BotToken) == "" {
return nil, errors.New("discord bot token is required for role sync")
}
if strings.TrimSpace(config.GuildID) == "" {
return nil, errors.New("discord guild ID is required for role sync")
}
apiBaseURL := strings.TrimRight(config.APIBaseURL, "/")
if apiBaseURL == "" {
apiBaseURL = defaultAPIBaseURL
}
httpClient := config.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
return &RoleManager{
botToken: config.BotToken,
guildID: config.GuildID,
apiBaseURL: apiBaseURL,
httpClient: httpClient,
}, nil
}
func (w *RoleWorker) Run(ctx context.Context) {
if err := w.store.SeedDiscordRoleSyncJobs(ctx); err != nil {
slog.Error("Could not seed Discord role sync jobs", "error", err)
}
ticker := time.NewTicker(w.pollInterval)
defer ticker.Stop()
for {
for {
processed, err := w.ProcessNext(ctx)
if err != nil {
slog.Error("Discord role sync worker failed", "error", err)
}
if !processed {
break
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (w *RoleWorker) ProcessNext(ctx context.Context) (bool, error) {
job, err := w.store.ClaimDiscordRoleSyncJob(ctx)
if errors.Is(err, domain.ErrNotFound) {
return false, nil
}
if err != nil {
return false, err
}
jobCtx, cancel := context.WithTimeout(ctx, w.jobTimeout)
defer cancel()
var warnings []string
if job.Action == application.DiscordRoleActionTeardown {
warnings, err = w.teardown(jobCtx, job)
} else {
warnings, err = w.reconcile(jobCtx, job.EventID)
}
if err == nil {
return true, w.store.CompleteDiscordRoleSyncJob(ctx, job.ID, strings.Join(warnings, "; "))
}
delay := retryDelay(job.Attempts, err)
if retryErr := w.store.RetryDiscordRoleSyncJob(ctx, job.ID, err.Error(), w.now().Add(delay)); retryErr != nil {
return true, fmt.Errorf("role sync failed: %v; schedule retry: %w", err, retryErr)
}
return true, nil
}
func (w *RoleWorker) reconcile(ctx context.Context, eventID string) ([]string, error) {
current, err := w.store.GetDiscordRoleRoster(ctx, eventID)
if errors.Is(err, domain.ErrNotFound) {
return w.teardown(ctx, application.DiscordRoleSyncJob{EventID: eventID})
}
if err != nil {
return nil, err
}
roles, err := w.store.ListDiscordManagedRoles(ctx, eventID)
if err != nil {
return nil, err
}
roles, err = w.ensureDesiredRoles(ctx, current.Roster, roles)
if err != nil {
return nil, err
}
desired, warnings, err := w.desiredAssignments(ctx, current, roles)
if err != nil {
return warnings, err
}
for _, role := range roles {
if role.Scope == application.DiscordRoleScopeEvent && role.EventID != eventID {
continue
}
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
warnings = append(warnings, roleWarnings...)
if syncErr != nil {
return warnings, syncErr
}
}
return warnings, nil
}
func (w *RoleWorker) ensureDesiredRoles(ctx context.Context, roster domain.RosterDraft, existing []application.DiscordManagedRole) ([]application.DiscordManagedRole, error) {
desired := []application.DiscordManagedRole{
{Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindTank, RoleName: "Tank"},
{Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindDamage, RoleName: "Damage"},
{Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindSupport, RoleName: "Support"},
}
for _, team := range roster.Teams {
teamName := truncate(strings.TrimSpace(team.Name), 100)
desired = append(desired,
application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindTeam, RoleName: teamName},
application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain, RoleName: truncate(teamName+" Captain", 100)},
)
}
existingByKey := make(map[string]application.DiscordManagedRole, len(existing))
for _, role := range existing {
existingByKey[managedRoleKey(role)] = role
}
desiredKeys := make(map[string]bool, len(desired))
result := make([]application.DiscordManagedRole, 0, len(desired))
for _, role := range desired {
key := managedRoleKey(role)
desiredKeys[key] = true
if saved, ok := existingByKey[key]; ok {
role.DiscordRoleID = saved.DiscordRoleID
if saved.RoleName != role.RoleName {
if err := w.manager.RenameRole(ctx, saved.DiscordRoleID, role.RoleName); err != nil {
return nil, err
}
if err := w.store.UpsertDiscordManagedRole(ctx, role); err != nil {
return nil, err
}
}
} else {
roleID, err := w.manager.CreateRole(ctx, role.RoleName)
if err != nil {
return nil, err
}
role.DiscordRoleID = roleID
if err = w.store.UpsertDiscordManagedRole(ctx, role); err != nil {
_ = w.manager.DeleteRole(ctx, roleID)
return nil, err
}
}
result = append(result, role)
}
for _, role := range existing {
if role.Scope != application.DiscordRoleScopeEvent || role.EventID != roster.EventID || desiredKeys[managedRoleKey(role)] {
continue
}
if err := w.deleteManagedRole(ctx, role); err != nil {
return nil, err
}
}
return result, nil
}
func (w *RoleWorker) desiredAssignments(ctx context.Context, current application.DiscordRoleRoster, roles []application.DiscordManagedRole) (map[string]map[string]bool, []string, error) {
desired := make(map[string]map[string]bool)
byKey := make(map[string]application.DiscordManagedRole, len(roles))
for _, role := range roles {
byKey[managedRoleKey(role)] = role
desired[role.DiscordRoleID] = make(map[string]bool)
}
warnings := appendRosterAssignments(desired, byKey, current, true)
active, err := w.store.ListActiveDiscordRoleRosters(ctx)
if err != nil {
return nil, warnings, err
}
for _, roster := range active {
warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...)
}
return desired, uniqueStrings(warnings), nil
}
func appendRosterAssignments(desired map[string]map[string]bool, roles map[string]application.DiscordManagedRole, item application.DiscordRoleRoster, includeEventRoles bool) []string {
warnings := make([]string, 0)
for _, team := range item.Roster.Teams {
for _, slot := range team.Slots {
if slot.PlayerID == "" {
continue
}
discordID := item.PlayerDiscordIDs[slot.PlayerID]
if discordID == "" {
warnings = append(warnings, "player "+slot.PlayerID+" has no linked Discord account")
continue
}
globalRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeGlobal, Kind: roleKind(slot.Role)})]
if globalRole.DiscordRoleID != "" {
desired[globalRole.DiscordRoleID][discordID] = true
}
if !includeEventRoles {
continue
}
teamRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: item.Roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindTeam})]
if teamRole.DiscordRoleID != "" {
desired[teamRole.DiscordRoleID][discordID] = true
}
if slot.PlayerID == team.CaptainPlayerID {
captainRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: item.Roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain})]
if captainRole.DiscordRoleID != "" {
desired[captainRole.DiscordRoleID][discordID] = true
}
}
}
}
return warnings
}
func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired map[string]bool) ([]string, error) {
current, err := w.store.ListDiscordRoleAssignments(ctx, roleID)
if err != nil {
return nil, err
}
currentSet := make(map[string]bool, len(current))
for _, userID := range current {
currentSet[userID] = true
}
warnings := make([]string, 0)
for userID := range desired {
if currentSet[userID] {
continue
}
if err = w.manager.AddMemberRole(ctx, userID, roleID); err != nil {
var httpErr *discordHTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
warnings = append(warnings, "Discord member "+userID+" is not in the guild")
continue
}
return warnings, err
}
if err = w.store.UpsertDiscordRoleAssignment(ctx, roleID, userID); err != nil {
return warnings, err
}
}
for userID := range currentSet {
if desired[userID] {
continue
}
if err = w.manager.RemoveMemberRole(ctx, userID, roleID); err != nil && !isDiscordNotFound(err) {
return warnings, err
}
if err = w.store.DeleteDiscordRoleAssignment(ctx, roleID, userID); err != nil {
return warnings, err
}
}
return warnings, nil
}
func (w *RoleWorker) teardown(ctx context.Context, job application.DiscordRoleSyncJob) ([]string, error) {
roles, err := w.store.ListDiscordManagedRoles(ctx, job.EventID)
if err != nil {
return nil, err
}
deleted := make(map[string]bool)
for _, role := range roles {
if role.Scope != application.DiscordRoleScopeEvent || role.EventID != job.EventID {
continue
}
if err = w.deleteManagedRole(ctx, role); err != nil {
return nil, err
}
deleted[role.DiscordRoleID] = true
}
for _, roleID := range job.RoleSnapshot {
if deleted[roleID] {
continue
}
if err = w.manager.DeleteRole(ctx, roleID); err != nil && !isDiscordNotFound(err) {
return nil, err
}
}
globalRoles := make([]application.DiscordManagedRole, 0)
for _, role := range roles {
if role.Scope == application.DiscordRoleScopeGlobal {
globalRoles = append(globalRoles, role)
}
}
active, err := w.store.ListActiveDiscordRoleRosters(ctx)
if err != nil {
return nil, err
}
desired := make(map[string]map[string]bool)
byKey := make(map[string]application.DiscordManagedRole)
for _, role := range globalRoles {
desired[role.DiscordRoleID] = make(map[string]bool)
byKey[managedRoleKey(role)] = role
}
warnings := make([]string, 0)
for _, roster := range active {
warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...)
}
for _, role := range globalRoles {
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
warnings = append(warnings, roleWarnings...)
if syncErr != nil {
return warnings, syncErr
}
}
return uniqueStrings(warnings), nil
}
func (w *RoleWorker) deleteManagedRole(ctx context.Context, role application.DiscordManagedRole) error {
if err := w.manager.DeleteRole(ctx, role.DiscordRoleID); err != nil && !isDiscordNotFound(err) {
return err
}
return w.store.DeleteDiscordManagedRole(ctx, role)
}
func (m *RoleManager) CreateRole(ctx context.Context, name string) (string, error) {
var response discordRoleResponse
if err := m.request(ctx, http.MethodPost, m.guildPath("/roles"), map[string]any{
"name": name, "permissions": "0", "hoist": false, "mentionable": false,
}, &response); err != nil {
return "", err
}
if response.ID == "" {
return "", errors.New("Discord create role response has no ID")
}
return response.ID, nil
}
func (m *RoleManager) RenameRole(ctx context.Context, roleID, name string) error {
return m.request(ctx, http.MethodPatch, m.guildPath("/roles/"+url.PathEscape(roleID)), map[string]string{"name": name}, nil)
}
func (m *RoleManager) DeleteRole(ctx context.Context, roleID string) error {
return m.request(ctx, http.MethodDelete, m.guildPath("/roles/"+url.PathEscape(roleID)), nil, nil)
}
func (m *RoleManager) AddMemberRole(ctx context.Context, userID, roleID string) error {
path := "/members/" + url.PathEscape(userID) + "/roles/" + url.PathEscape(roleID)
return m.request(ctx, http.MethodPut, m.guildPath(path), nil, nil)
}
func (m *RoleManager) RemoveMemberRole(ctx context.Context, userID, roleID string) error {
path := "/members/" + url.PathEscape(userID) + "/roles/" + url.PathEscape(roleID)
return m.request(ctx, http.MethodDelete, m.guildPath(path), nil, nil)
}
func (m *RoleManager) guildPath(path string) string {
return m.apiBaseURL + "/guilds/" + url.PathEscape(m.guildID) + path
}
func (m *RoleManager) request(ctx context.Context, method, endpoint string, payload any, result any) error {
var body io.Reader
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(encoded)
}
request, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return err
}
request.Header.Set("Authorization", "Bot "+m.botToken)
if payload != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := m.httpClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
responseBody, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
httpErr := &discordHTTPError{StatusCode: response.StatusCode, Status: response.Status, Body: strings.TrimSpace(string(responseBody))}
if seconds, parseErr := strconv.ParseFloat(response.Header.Get("Retry-After"), 64); parseErr == nil {
httpErr.RetryAfter = time.Duration(seconds * float64(time.Second))
}
return httpErr
}
if result != nil {
return json.NewDecoder(response.Body).Decode(result)
}
return nil
}
func retryDelay(attempts int, err error) time.Duration {
var httpErr *discordHTTPError
if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 {
return httpErr.RetryAfter
}
if attempts < 1 {
attempts = 1
}
if attempts > 6 {
attempts = 6
}
return time.Duration(1<<uint(attempts-1)) * time.Second
}
func managedRoleKey(role application.DiscordManagedRole) string {
return strings.Join([]string{role.Scope, role.EventID, role.TeamID, role.Kind}, "\x00")
}
func roleKind(role domain.Role) string {
switch role {
case domain.Tank:
return application.DiscordRoleKindTank
case domain.Damage:
return application.DiscordRoleKindDamage
case domain.Support:
return application.DiscordRoleKindSupport
default:
return ""
}
}
func isDiscordNotFound(err error) bool {
var httpErr *discordHTTPError
return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound
}
func uniqueStrings(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
if value != "" && !seen[value] {
seen[value] = true
out = append(out, value)
}
}
sort.Strings(out)
return out
}

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