Enhance Discord role management by adding hoist functionality
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 `hoist` attribute for Discord roles, allowing team roles and the registered role to be displayed in separate groups within the Discord member list. The `RoleWorker` has been updated to ensure team roles are positioned above the registered role, improving visibility and organization. Database schema changes have been made to support the new `hoist` field, and corresponding updates have been implemented in the service layer and tests to validate the new behavior.
This commit is contained in:
2026-07-19 11:52:57 +03:00
parent 966c81ba12
commit e5646ba33d
9 changed files with 139 additions and 23 deletions

View File

@@ -125,6 +125,8 @@ func (s *roleStoreFake) GetDiscordRoleRegistration(_ context.Context, eventID st
func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
var mu sync.Mutex
roleNames := make(map[string]string)
roleHoists := make(map[string]bool)
rolePositions := map[string]int{"role-rsvp": 1}
methods := make([]string, 0)
nextID := 0
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
@@ -137,15 +139,38 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
nextID++
id := "role-" + string(rune('0'+nextID))
roleNames[id], _ = payload["name"].(string)
roleHoists[id], _ = payload["hoist"].(bool)
rolePositions[id] = 0
response.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(response).Encode(map[string]string{"id": id})
return
}
if request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/roles") {
roles := []map[string]any{{"id": "role-rsvp", "position": rolePositions["role-rsvp"]}}
for id := range roleNames {
roles = append(roles, map[string]any{"id": id, "position": rolePositions[id]})
}
response.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(response).Encode(roles)
return
}
if request.Method == http.MethodPatch {
var payload map[string]string
_ = json.NewDecoder(request.Body).Decode(&payload)
parts := strings.Split(request.URL.Path, "/")
roleNames[parts[len(parts)-1]] = payload["name"]
roleID := parts[len(parts)-1]
if roleID == "roles" {
var payload []struct {
ID string `json:"id"`
Position int `json:"position"`
}
_ = json.NewDecoder(request.Body).Decode(&payload)
for _, item := range payload {
rolePositions[item.ID] = item.Position
}
} else {
var payload map[string]any
_ = json.NewDecoder(request.Body).Decode(&payload)
roleNames[roleID], _ = payload["name"].(string)
}
}
response.WriteHeader(http.StatusNoContent)
}))
@@ -157,7 +182,7 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
store.active = []application.DiscordRoleRoster{item}
rsvpRole := application.DiscordManagedRole{
Scope: application.DiscordRoleScopeEvent, EventID: "event-1",
Kind: application.DiscordRoleKindRegistered, DiscordRoleID: "role-rsvp", RoleName: "Зарегистрирован: Mix",
Kind: application.DiscordRoleKindRegistered, DiscordRoleID: "role-rsvp", RoleName: "Зарегистрирован: Mix", Hoist: true,
}
store.roles[managedRoleKey(rsvpRole)] = rsvpRole
store.jobs = []application.DiscordRoleSyncJob{{ID: 1, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
@@ -179,6 +204,14 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
if strings.Join(names, ",") != strings.Join(expectedNames, ",") {
t.Fatalf("unexpected role names: %v", names)
}
for id, name := range roleNames {
if roleHoists[id] != (name == "Alpha") {
t.Fatalf("unexpected hoist for %s: %v", name, roleHoists[id])
}
if name == "Alpha" && rolePositions[id] <= rolePositions["role-rsvp"] {
t.Fatalf("team role was not moved above registered role: team=%d registered=%d", rolePositions[id], rolePositions["role-rsvp"])
}
}
if got := assignmentCount(store.assignments); got != 11 {
t.Fatalf("expected 11 managed assignments, got %d", got)
}
@@ -191,8 +224,10 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
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)
for _, method := range methods[firstRequestCount:] {
if !strings.HasPrefix(method, http.MethodGet+" ") {
t.Fatalf("idempotent reconcile made a mutating Discord call: %s", method)
}
}
renamed := testDiscordRoster("Night Owls")
@@ -236,6 +271,13 @@ func TestRoleWorkerReconcilesRSVPStatusAndEventRename(t *testing.T) {
patches := 0
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost {
var payload map[string]any
_ = json.NewDecoder(request.Body).Decode(&payload)
name, _ := payload["name"].(string)
hoist, _ := payload["hoist"].(bool)
if hoist != strings.HasPrefix(name, "Зарегистрирован:") {
t.Errorf("unexpected RSVP role hoist: name=%q hoist=%v", name, hoist)
}
nextID++
_ = json.NewEncoder(response).Encode(map[string]string{"id": "rsvp-role-" + string(rune('0'+nextID))})
return