Add global role synchronization for Discord with configurable interval
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit introduces a new feature for global role synchronization in Discord, allowing for periodic reconciliation of managed roles. A new environment variable, `DISCORD_GLOBAL_SYNC_INTERVAL`, has been added to configure the synchronization interval, defaulting to 5 minutes. The `RoleWorker` has been updated to schedule global sync jobs, ensuring that missing managed roles are restored and extra assignments are removed without affecting unrelated server roles. Database schema changes support the new synchronization logic, and tests have been added to validate the functionality of the global reconciliation process.
This commit is contained in:
2026-07-19 12:04:33 +03:00
parent e5646ba33d
commit 6b189bfce4
14 changed files with 568 additions and 42 deletions

View File

@@ -20,22 +20,24 @@ import (
)
type RoleSyncConfig struct {
BotToken string
GuildID string
Locale string
APIBaseURL string
HTTPClient *http.Client
PollInterval time.Duration
JobTimeout time.Duration
BotToken string
GuildID string
Locale string
APIBaseURL string
HTTPClient *http.Client
PollInterval time.Duration
JobTimeout time.Duration
GlobalSyncInterval time.Duration
}
type RoleWorker struct {
store application.DiscordRoleStore
manager *RoleManager
pollInterval time.Duration
jobTimeout time.Duration
locale string
now func() time.Time
store application.DiscordRoleStore
manager *RoleManager
pollInterval time.Duration
jobTimeout time.Duration
locale string
globalSyncInterval time.Duration
now func() time.Time
}
type RoleManager struct {
@@ -47,9 +49,18 @@ type RoleManager struct {
type discordRoleResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Hoist bool `json:"hoist"`
Position int `json:"position"`
}
type discordGuildMember struct {
User struct {
ID string `json:"id"`
} `json:"user"`
Roles []string `json:"roles"`
}
type discordHTTPError struct {
StatusCode int
Status string
@@ -57,6 +68,14 @@ type discordHTTPError struct {
RetryAfter time.Duration
}
type missingGuildMembersError struct {
warnings []string
}
func (e *missingGuildMembersError) Error() string {
return strings.Join(e.warnings, "; ")
}
func (e *discordHTTPError) Error() string {
return fmt.Sprintf("Discord returned %s: %s", e.Status, e.Body)
}
@@ -77,6 +96,10 @@ func NewRoleWorker(store application.DiscordRoleStore, config RoleSyncConfig) (*
if jobTimeout <= 0 {
jobTimeout = 30 * time.Second
}
globalSyncInterval := config.GlobalSyncInterval
if globalSyncInterval <= 0 {
globalSyncInterval = 5 * time.Minute
}
locale := strings.ToLower(strings.TrimSpace(config.Locale))
if locale == "" {
locale = "ru"
@@ -85,12 +108,13 @@ func NewRoleWorker(store application.DiscordRoleStore, config RoleSyncConfig) (*
return nil, fmt.Errorf("unsupported Discord role locale %q", locale)
}
return &RoleWorker{
store: store,
manager: manager,
pollInterval: pollInterval,
jobTimeout: jobTimeout,
locale: locale,
now: func() time.Time { return time.Now().UTC() },
store: store,
manager: manager,
pollInterval: pollInterval,
jobTimeout: jobTimeout,
locale: locale,
globalSyncInterval: globalSyncInterval,
now: func() time.Time { return time.Now().UTC() },
}, nil
}
@@ -121,8 +145,11 @@ 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)
}
w.scheduleGlobalSync(ctx)
ticker := time.NewTicker(w.pollInterval)
defer ticker.Stop()
globalTicker := time.NewTicker(w.globalSyncInterval)
defer globalTicker.Stop()
for {
for {
processed, err := w.ProcessNext(ctx)
@@ -137,10 +164,19 @@ func (w *RoleWorker) Run(ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
case <-globalTicker.C:
w.scheduleGlobalSync(ctx)
}
}
}
func (w *RoleWorker) scheduleGlobalSync(ctx context.Context) {
bucket := w.now().UnixNano() / int64(w.globalSyncInterval)
if err := w.store.ScheduleGlobalDiscordRoleSync(ctx, bucket); err != nil {
slog.Error("Could not schedule global Discord role sync", "error", err)
}
}
func (w *RoleWorker) ProcessNext(ctx context.Context) (bool, error) {
job, err := w.store.ClaimDiscordRoleSyncJob(ctx)
if errors.Is(err, domain.ErrNotFound) {
@@ -157,6 +193,8 @@ func (w *RoleWorker) ProcessNext(ctx context.Context) (bool, error) {
warnings, err = w.teardown(jobCtx, job)
case application.DiscordRoleActionRSVP:
warnings, err = w.reconcileRSVP(jobCtx, job.EventID)
case application.DiscordRoleActionFullReconcile:
warnings, err = w.fullReconcile(jobCtx)
default:
warnings, err = w.reconcile(jobCtx, job.EventID)
}
@@ -194,19 +232,138 @@ func (w *RoleWorker) reconcile(ctx context.Context, eventID string) ([]string, e
if err != nil {
return warnings, err
}
missingMembers := false
for _, role := range roles {
if role.Scope == application.DiscordRoleScopeEvent && role.EventID != eventID {
continue
}
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
roleWarnings, roleMissingMembers, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
warnings = append(warnings, roleWarnings...)
missingMembers = missingMembers || roleMissingMembers
if syncErr != nil {
return warnings, syncErr
}
}
if missingMembers {
return warnings, &missingGuildMembersError{warnings: uniqueStrings(warnings)}
}
return warnings, nil
}
func (w *RoleWorker) fullReconcile(ctx context.Context) ([]string, error) {
actualRoles, err := w.manager.ListGuildRoles(ctx)
if err != nil {
return nil, err
}
actualByID := make(map[string]discordRoleResponse, len(actualRoles))
for _, role := range actualRoles {
actualByID[role.ID] = role
}
mappings, err := w.store.ListAllDiscordManagedRoles(ctx)
if err != nil {
return nil, err
}
for _, mapping := range mappings {
actual, exists := actualByID[mapping.DiscordRoleID]
if !exists {
if err = w.store.DeleteDiscordManagedRole(ctx, mapping); err != nil {
return nil, err
}
continue
}
if actual.Name != mapping.RoleName || actual.Hoist != mapping.Hoist {
if err = w.manager.UpdateRole(ctx, mapping.DiscordRoleID, mapping.RoleName, mapping.Hoist); err != nil {
return nil, err
}
}
}
mappings, err = w.store.ListAllDiscordManagedRoles(ctx)
if err != nil {
return nil, err
}
members, err := w.manager.ListGuildMembers(ctx)
if err != nil {
return nil, err
}
managedIDs := make(map[string]bool, len(mappings))
actualAssignments := make(map[string][]string, len(mappings))
for _, mapping := range mappings {
managedIDs[mapping.DiscordRoleID] = true
}
for _, member := range members {
for _, roleID := range member.Roles {
if managedIDs[roleID] {
actualAssignments[roleID] = append(actualAssignments[roleID], member.User.ID)
}
}
}
for _, mapping := range mappings {
if err = w.store.SetDiscordRoleAssignments(ctx, mapping.DiscordRoleID, actualAssignments[mapping.DiscordRoleID]); err != nil {
return nil, err
}
}
registrations, err := w.store.ListActiveDiscordRoleRegistrations(ctx)
if err != nil {
return nil, err
}
rosters, err := w.store.ListActiveDiscordRoleRosters(ctx)
if err != nil {
return nil, err
}
activeEvents := make(map[string]bool, len(registrations))
for _, registration := range registrations {
activeEvents[registration.Event.ID] = true
}
rosterEvents := make(map[string]bool, len(rosters))
for _, roster := range rosters {
rosterEvents[roster.Roster.EventID] = true
}
for _, mapping := range mappings {
if mapping.Scope != application.DiscordRoleScopeEvent {
continue
}
if !activeEvents[mapping.EventID] || (isRosterRoleKind(mapping.Kind) && !rosterEvents[mapping.EventID]) {
if err = w.deleteManagedRole(ctx, mapping); err != nil {
return nil, err
}
}
}
warnings := make([]string, 0)
for _, registration := range registrations {
itemWarnings, reconcileErr := w.reconcileRSVP(ctx, registration.Event.ID)
warnings = append(warnings, itemWarnings...)
if reconcileErr != nil {
var missing *missingGuildMembersError
if !errors.As(reconcileErr, &missing) {
return warnings, reconcileErr
}
}
}
for _, roster := range rosters {
itemWarnings, reconcileErr := w.reconcile(ctx, roster.Roster.EventID)
warnings = append(warnings, itemWarnings...)
if reconcileErr != nil {
var missing *missingGuildMembersError
if !errors.As(reconcileErr, &missing) {
return warnings, reconcileErr
}
}
}
if len(rosters) == 0 {
for _, mapping := range mappings {
if mapping.Scope != application.DiscordRoleScopeGlobal {
continue
}
itemWarnings, _, syncErr := w.syncAssignments(ctx, mapping.DiscordRoleID, map[string]bool{})
warnings = append(warnings, itemWarnings...)
if syncErr != nil {
return warnings, syncErr
}
}
}
return uniqueStrings(warnings), nil
}
func (w *RoleWorker) reconcileRSVP(ctx context.Context, eventID string) ([]string, error) {
state, err := w.store.GetDiscordRoleRegistration(ctx, eventID)
if errors.Is(err, domain.ErrNotFound) {
@@ -267,13 +424,18 @@ func (w *RoleWorker) reconcileRSVP(ctx context.Context, eventID string) ([]strin
desiredAssignments[role.DiscordRoleID][discordID] = true
}
}
missingMembers := false
for _, role := range roles {
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desiredAssignments[role.DiscordRoleID])
roleWarnings, roleMissingMembers, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desiredAssignments[role.DiscordRoleID])
warnings = append(warnings, roleWarnings...)
missingMembers = missingMembers || roleMissingMembers
if syncErr != nil {
return warnings, syncErr
}
}
if missingMembers {
return warnings, &missingGuildMembersError{warnings: uniqueStrings(warnings)}
}
return uniqueStrings(warnings), nil
}
@@ -391,16 +553,17 @@ func appendRosterAssignments(desired map[string]map[string]bool, roles map[strin
return warnings
}
func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired map[string]bool) ([]string, error) {
func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired map[string]bool) ([]string, bool, error) {
current, err := w.store.ListDiscordRoleAssignments(ctx, roleID)
if err != nil {
return nil, err
return nil, false, err
}
currentSet := make(map[string]bool, len(current))
for _, userID := range current {
currentSet[userID] = true
}
warnings := make([]string, 0)
missingMembers := false
for userID := range desired {
if currentSet[userID] {
continue
@@ -409,12 +572,13 @@ func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired
var httpErr *discordHTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
warnings = append(warnings, "Discord member "+userID+" is not in the guild")
missingMembers = true
continue
}
return warnings, err
return warnings, missingMembers, err
}
if err = w.store.UpsertDiscordRoleAssignment(ctx, roleID, userID); err != nil {
return warnings, err
return warnings, missingMembers, err
}
}
for userID := range currentSet {
@@ -422,13 +586,13 @@ func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired
continue
}
if err = w.manager.RemoveMemberRole(ctx, userID, roleID); err != nil && !isDiscordNotFound(err) {
return warnings, err
return warnings, missingMembers, err
}
if err = w.store.DeleteDiscordRoleAssignment(ctx, roleID, userID); err != nil {
return warnings, err
return warnings, missingMembers, err
}
}
return warnings, nil
return warnings, missingMembers, nil
}
func (w *RoleWorker) teardown(ctx context.Context, job application.DiscordRoleSyncJob) ([]string, error) {
@@ -474,13 +638,18 @@ func (w *RoleWorker) teardown(ctx context.Context, job application.DiscordRoleSy
for _, roster := range active {
warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...)
}
missingMembers := false
for _, role := range globalRoles {
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
roleWarnings, roleMissingMembers, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
warnings = append(warnings, roleWarnings...)
missingMembers = missingMembers || roleMissingMembers
if syncErr != nil {
return warnings, syncErr
}
}
if missingMembers {
return warnings, &missingGuildMembersError{warnings: uniqueStrings(warnings)}
}
return uniqueStrings(warnings), nil
}
@@ -525,8 +694,8 @@ func (m *RoleManager) UpdateRole(ctx context.Context, roleID, name string, hoist
}
func (m *RoleManager) MoveRolesAbove(ctx context.Context, roleIDs []string, anchorRoleID string) error {
var roles []discordRoleResponse
if err := m.request(ctx, http.MethodGet, m.guildPath("/roles"), nil, &roles); err != nil {
roles, err := m.ListGuildRoles(ctx)
if err != nil {
return err
}
positions := make(map[string]int, len(roles))
@@ -554,6 +723,35 @@ func (m *RoleManager) MoveRolesAbove(ctx context.Context, roleIDs []string, anch
return m.request(ctx, http.MethodPatch, m.guildPath("/roles"), payload, nil)
}
func (m *RoleManager) ListGuildRoles(ctx context.Context) ([]discordRoleResponse, error) {
var roles []discordRoleResponse
err := m.request(ctx, http.MethodGet, m.guildPath("/roles"), nil, &roles)
return roles, err
}
func (m *RoleManager) ListGuildMembers(ctx context.Context) ([]discordGuildMember, error) {
members := make([]discordGuildMember, 0)
after := ""
for {
endpoint := m.guildPath("/members") + "?limit=1000"
if after != "" {
endpoint += "&after=" + url.QueryEscape(after)
}
var page []discordGuildMember
if err := m.request(ctx, http.MethodGet, endpoint, nil, &page); err != nil {
return nil, err
}
members = append(members, page...)
if len(page) < 1000 {
return members, nil
}
after = page[len(page)-1].User.ID
if after == "" {
return nil, errors.New("Discord guild member page has no last user ID")
}
}
}
func (m *RoleManager) DeleteRole(ctx context.Context, roleID string) error {
return m.request(ctx, http.MethodDelete, m.guildPath("/roles/"+url.PathEscape(roleID)), nil, nil)
}
@@ -609,6 +807,10 @@ func (m *RoleManager) request(ctx context.Context, method, endpoint string, payl
}
func retryDelay(attempts int, err error) time.Duration {
var missingMembers *missingGuildMembersError
if errors.As(err, &missingMembers) {
return 5 * time.Minute
}
var httpErr *discordHTTPError
if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 {
return httpErr.RetryAfter
@@ -635,6 +837,10 @@ func existingRole(roles []application.DiscordManagedRole, kind string) applicati
return application.DiscordManagedRole{}
}
func isRosterRoleKind(kind string) bool {
return kind == application.DiscordRoleKindTeam || kind == application.DiscordRoleKindCaptain
}
func roleKind(role domain.Role) string {
switch role {
case domain.Tank:

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"sort"
@@ -25,6 +26,8 @@ type roleStoreFake struct {
registrations map[string]application.DiscordRoleRegistration
active []application.DiscordRoleRoster
warnings []string
retries int
globalBuckets []int64
}
func newRoleStoreFake() *roleStoreFake {
@@ -38,6 +41,11 @@ func newRoleStoreFake() *roleStoreFake {
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()
@@ -57,6 +65,7 @@ func (s *roleStoreFake) CompleteDiscordRoleSyncJob(_ context.Context, _ int64, w
func (s *roleStoreFake) RetryDiscordRoleSyncJob(_ context.Context, _ int64, message string, _ time.Time) error {
s.warnings = append(s.warnings, message)
s.retries++
return nil
}
@@ -70,6 +79,14 @@ func (s *roleStoreFake) ListDiscordManagedRoles(_ context.Context, eventID strin
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
@@ -102,6 +119,14 @@ func (s *roleStoreFake) DeleteDiscordRoleAssignment(_ context.Context, roleID, u
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 {
@@ -122,6 +147,14 @@ func (s *roleStoreFake) GetDiscordRoleRegistration(_ context.Context, eventID st
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)
@@ -360,7 +393,115 @@ func TestRoleWorkerReconcilesRSVPStatusAndEventRename(t *testing.T) {
}
}
func TestRoleWorkerStoresMissingGuildMemberWarning(t *testing.T) {
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()})
@@ -389,6 +530,12 @@ func TestRoleWorkerStoresMissingGuildMemberWarning(t *testing.T) {
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) {
@@ -489,6 +636,53 @@ func TestRoleManagerReturnsServerError(t *testing.T) {
}
}
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},