refactor(guild): extract disband, resign, leave, and scout logic into GuildService

Move business logic for guild disband, resign leadership, leave,
post scout, and answer scout from handlers into GuildService methods.
Handlers now delegate to the service layer and handle only protocol
concerns (packet parsing, ACK responses, cross-channel notifications).

Adds 22 new table-driven service tests and sentinel errors for typed
error handling (ErrNoEligibleLeader, ErrAlreadyInvited, etc.).
DonateRP left in handler due to Session coupling.
This commit is contained in:
Houmgaor
2026-02-23 23:35:28 +01:00
parent 2abca9fb23
commit 210cfa1fd1
6 changed files with 699 additions and 137 deletions

View File

@@ -168,3 +168,397 @@ func TestGuildService_OperateMember(t *testing.T) {
})
}
}
func TestGuildService_Disband(t *testing.T) {
tests := []struct {
name string
actorCharID uint32
guild *Guild
disbandErr error
wantSuccess bool
wantDisbID uint32
}{
{
name: "leader disbands successfully",
actorCharID: 1,
guild: &Guild{ID: 10, GuildLeader: GuildLeader{LeaderCharID: 1}},
wantSuccess: true,
wantDisbID: 10,
},
{
name: "non-leader cannot disband",
actorCharID: 5,
guild: &Guild{ID: 10, GuildLeader: GuildLeader{LeaderCharID: 1}},
wantSuccess: false,
},
{
name: "repo error returns failure",
actorCharID: 1,
guild: &Guild{ID: 10, GuildLeader: GuildLeader{LeaderCharID: 1}},
disbandErr: errors.New("db error"),
wantSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
guildMock := &mockGuildRepoOps{disbandErr: tt.disbandErr}
guildMock.guild = tt.guild
svc := newTestGuildService(guildMock, &mockMailRepo{})
result, err := svc.Disband(tt.actorCharID, tt.guild.ID)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if result.Success != tt.wantSuccess {
t.Errorf("Success = %v, want %v", result.Success, tt.wantSuccess)
}
if tt.wantDisbID != 0 && guildMock.disbandedID != tt.wantDisbID {
t.Errorf("disbandedID = %d, want %d", guildMock.disbandedID, tt.wantDisbID)
}
})
}
}
func TestGuildService_ResignLeadership(t *testing.T) {
tests := []struct {
name string
actorCharID uint32
guild *Guild
members []*GuildMember
getMembersErr error
wantNewLeader uint32
wantErr bool
wantSavedCount int
wantGuildSaved bool
}{
{
name: "transfers to next eligible member",
actorCharID: 1,
guild: &Guild{ID: 10, GuildLeader: GuildLeader{LeaderCharID: 1}},
members: []*GuildMember{
{CharID: 1, OrderIndex: 1, IsLeader: true},
{CharID: 2, OrderIndex: 2, AvoidLeadership: false},
},
wantNewLeader: 2,
wantSavedCount: 2,
wantGuildSaved: true,
},
{
name: "skips members avoiding leadership",
actorCharID: 1,
guild: &Guild{ID: 10, GuildLeader: GuildLeader{LeaderCharID: 1}},
members: []*GuildMember{
{CharID: 1, OrderIndex: 1, IsLeader: true},
{CharID: 2, OrderIndex: 2, AvoidLeadership: true},
{CharID: 3, OrderIndex: 3, AvoidLeadership: false},
},
wantNewLeader: 3,
wantSavedCount: 2,
wantGuildSaved: true,
},
{
name: "no eligible successor returns zero",
actorCharID: 1,
guild: &Guild{ID: 10, GuildLeader: GuildLeader{LeaderCharID: 1}},
members: []*GuildMember{
{CharID: 1, OrderIndex: 1, IsLeader: true},
{CharID: 2, OrderIndex: 2, AvoidLeadership: true},
},
wantNewLeader: 0,
},
{
name: "get members error",
actorCharID: 1,
guild: &Guild{ID: 10, GuildLeader: GuildLeader{LeaderCharID: 1}},
getMembersErr: errors.New("db error"),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
guildMock := &mockGuildRepoOps{getMembersErr: tt.getMembersErr}
guildMock.guild = tt.guild
guildMock.members = tt.members
svc := newTestGuildService(guildMock, &mockMailRepo{})
result, err := svc.ResignLeadership(tt.actorCharID, tt.guild.ID)
if tt.wantErr {
if err == nil {
t.Fatal("Expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if result.NewLeaderCharID != tt.wantNewLeader {
t.Errorf("NewLeaderCharID = %d, want %d", result.NewLeaderCharID, tt.wantNewLeader)
}
if tt.wantSavedCount > 0 && len(guildMock.savedMembers) != tt.wantSavedCount {
t.Errorf("savedMembers count = %d, want %d", len(guildMock.savedMembers), tt.wantSavedCount)
}
if tt.wantGuildSaved && guildMock.savedGuild == nil {
t.Error("Guild should be saved")
}
})
}
}
func TestGuildService_Leave(t *testing.T) {
tests := []struct {
name string
isApplicant bool
rejectErr error
removeErr error
sendErr error
wantSuccess bool
wantRejected uint32
wantRemoved uint32
wantMailCount int
}{
{
name: "member leaves successfully",
isApplicant: false,
wantSuccess: true,
wantRemoved: 1,
wantMailCount: 1,
},
{
name: "applicant withdraws via reject",
isApplicant: true,
wantSuccess: true,
wantRejected: 1,
wantMailCount: 1,
},
{
name: "remove error returns failure",
isApplicant: false,
removeErr: errors.New("db error"),
wantSuccess: false,
},
{
name: "mail error is best-effort",
isApplicant: false,
sendErr: errors.New("mail failed"),
wantSuccess: true,
wantRemoved: 1,
wantMailCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
guildMock := &mockGuildRepoOps{
rejectErr: tt.rejectErr,
removeErr: tt.removeErr,
}
guildMock.guild = &Guild{ID: 10, Name: "TestGuild"}
mailMock := &mockMailRepo{sendErr: tt.sendErr}
svc := newTestGuildService(guildMock, mailMock)
result, _ := svc.Leave(1, 10, tt.isApplicant, "TestGuild")
if result.Success != tt.wantSuccess {
t.Errorf("Success = %v, want %v", result.Success, tt.wantSuccess)
}
if tt.wantRejected != 0 && guildMock.rejectedCharID != tt.wantRejected {
t.Errorf("rejectedCharID = %d, want %d", guildMock.rejectedCharID, tt.wantRejected)
}
if tt.wantRemoved != 0 && guildMock.removedCharID != tt.wantRemoved {
t.Errorf("removedCharID = %d, want %d", guildMock.removedCharID, tt.wantRemoved)
}
if len(mailMock.sentMails) != tt.wantMailCount {
t.Errorf("sentMails count = %d, want %d", len(mailMock.sentMails), tt.wantMailCount)
}
})
}
}
func TestGuildService_PostScout(t *testing.T) {
strings := ScoutInviteStrings{Title: "Invite", Body: "Join 「%s」"}
tests := []struct {
name string
membership *GuildMember
guild *Guild
hasApp bool
hasAppErr error
createAppErr error
getMemberErr error
wantErr error
}{
{
name: "successful scout",
membership: &GuildMember{GuildID: 10, CharID: 1, IsLeader: true, OrderIndex: 1},
guild: &Guild{ID: 10, Name: "TestGuild"},
},
{
name: "already invited",
membership: &GuildMember{GuildID: 10, CharID: 1, IsLeader: true, OrderIndex: 1},
guild: &Guild{ID: 10, Name: "TestGuild"},
hasApp: true,
wantErr: ErrAlreadyInvited,
},
{
name: "cannot recruit",
membership: &GuildMember{GuildID: 10, CharID: 1, OrderIndex: 10}, // not recruiter, not sub-leader
guild: &Guild{ID: 10, Name: "TestGuild"},
wantErr: ErrCannotRecruit,
},
{
name: "nil membership",
getMemberErr: errors.New("not found"),
guild: &Guild{ID: 10, Name: "TestGuild"},
wantErr: errors.New("any"), // just check err != nil
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
guildMock := &mockGuildRepoOps{
membership: tt.membership,
hasAppResult: tt.hasApp,
hasAppErr: tt.hasAppErr,
createAppErr: tt.createAppErr,
getMemberErr: tt.getMemberErr,
}
guildMock.guild = tt.guild
svc := newTestGuildService(guildMock, &mockMailRepo{})
err := svc.PostScout(1, 42, strings)
if tt.wantErr != nil {
if err == nil {
t.Fatal("Expected error, got nil")
}
if errors.Is(tt.wantErr, ErrAlreadyInvited) || errors.Is(tt.wantErr, ErrCannotRecruit) {
if !errors.Is(err, tt.wantErr) {
t.Errorf("Expected %v, got %v", tt.wantErr, err)
}
}
return
}
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
})
}
}
func TestGuildService_AnswerScout(t *testing.T) {
strings := AnswerScoutStrings{
SuccessTitle: "Success!",
SuccessBody: "Joined 「%s」.",
AcceptedTitle: "Accepted",
AcceptedBody: "Accepted invite to 「%s」.",
RejectedTitle: "Rejected",
RejectedBody: "Rejected invite to 「%s」.",
DeclinedTitle: "Declined",
DeclinedBody: "Declined invite to 「%s」.",
}
tests := []struct {
name string
accept bool
guild *Guild
application *GuildApplication
acceptErr error
rejectErr error
sendErr error
getErr error
wantSuccess bool
wantErr error
wantMailCount int
wantAccepted uint32
wantRejected uint32
}{
{
name: "accept invitation",
accept: true,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
application: &GuildApplication{GuildID: 10, CharID: 1},
wantSuccess: true,
wantMailCount: 2,
wantAccepted: 1,
},
{
name: "decline invitation",
accept: false,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
application: &GuildApplication{GuildID: 10, CharID: 1},
wantSuccess: true,
wantMailCount: 2,
wantRejected: 1,
},
{
name: "application missing",
accept: true,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
application: nil,
wantSuccess: false,
wantErr: ErrApplicationMissing,
},
{
name: "guild not found",
accept: true,
guild: &Guild{ID: 10, Name: "TestGuild"},
getErr: errors.New("not found"),
wantErr: errors.New("any"),
},
{
name: "mail error is best-effort",
accept: true,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
application: &GuildApplication{GuildID: 10, CharID: 1},
sendErr: errors.New("mail failed"),
wantSuccess: true,
wantMailCount: 2,
wantAccepted: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
guildMock := &mockGuildRepoOps{
application: tt.application,
acceptErr: tt.acceptErr,
rejectErr: tt.rejectErr,
}
guildMock.guild = tt.guild
guildMock.getErr = tt.getErr
mailMock := &mockMailRepo{sendErr: tt.sendErr}
svc := newTestGuildService(guildMock, mailMock)
result, err := svc.AnswerScout(1, 50, tt.accept, strings)
if tt.wantErr != nil {
if err == nil {
t.Fatal("Expected error, got nil")
}
if errors.Is(tt.wantErr, ErrApplicationMissing) && !errors.Is(err, ErrApplicationMissing) {
t.Errorf("Expected ErrApplicationMissing, got %v", err)
}
if result != nil && result.Success {
t.Error("Result should not be successful")
}
return
}
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if result.Success != tt.wantSuccess {
t.Errorf("Success = %v, want %v", result.Success, tt.wantSuccess)
}
if len(mailMock.sentMails) != tt.wantMailCount {
t.Errorf("sentMails count = %d, want %d", len(mailMock.sentMails), tt.wantMailCount)
}
if tt.wantAccepted != 0 && guildMock.acceptedCharID != tt.wantAccepted {
t.Errorf("acceptedCharID = %d, want %d", guildMock.acceptedCharID, tt.wantAccepted)
}
if tt.wantRejected != 0 && guildMock.rejectedCharID != tt.wantRejected {
t.Errorf("rejectedCharID = %d, want %d", guildMock.rejectedCharID, tt.wantRejected)
}
})
}
}