Add RSVP role synchronization and localization support for Discord
This commit introduces functionality for managing event-specific RSVP roles in Discord, including roles for "Registered", "Going", "Maybe", and "Not Going". The `RoleWorker` has been enhanced to handle these roles based on user registrations, with localization support for role names in Russian and English. Additionally, the database schema has been updated to accommodate new role types and actions, and the service layer has been modified to trigger role synchronization upon relevant event updates. Tests have been added to ensure the correct behavior of the new RSVP handling logic.
This commit is contained in:
@@ -17,20 +17,22 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
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),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +114,14 @@ func (s *roleStoreFake) ListActiveDiscordRoleRosters(context.Context) ([]applica
|
||||
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)
|
||||
@@ -145,6 +155,11 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
|
||||
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(),
|
||||
@@ -167,6 +182,9 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
|
||||
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}}
|
||||
@@ -213,6 +231,93 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -366,6 +471,16 @@ func testDiscordRoster(name string) application.DiscordRoleRoster {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -382,3 +497,12 @@ func managedRoleID(roles map[string]application.DiscordManagedRole, kind string)
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user