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

@@ -1,8 +1,6 @@
package channelserver package channelserver
import ( import (
"fmt"
"sort"
"time" "time"
"erupe-ce/common/byteframe" "erupe-ce/common/byteframe"
@@ -29,41 +27,16 @@ func handleMsgMhfOperateGuild(s *Session, p mhfpacket.MHFPacket) {
switch pkt.Action { switch pkt.Action {
case mhfpacket.OperateGuildDisband: case mhfpacket.OperateGuildDisband:
response := 1 result, _ := s.server.guildService.Disband(s.charID, guild.ID)
if guild.LeaderCharID != s.charID { response := 0
s.logger.Warn("Unauthorized guild management attempt", zap.Uint32("charID", s.charID), zap.Uint32("guildID", guild.ID)) if result != nil && result.Success {
response = 0 response = 1
} else {
err = s.server.guildRepo.Disband(guild.ID)
if err != nil {
response = 0
}
} }
bf.WriteUint32(uint32(response)) bf.WriteUint32(uint32(response))
case mhfpacket.OperateGuildResign: case mhfpacket.OperateGuildResign:
guildMembers, err := s.server.guildRepo.GetMembers(guild.ID, false) result, err := s.server.guildService.ResignLeadership(s.charID, guild.ID)
if err == nil { if err == nil && result.NewLeaderCharID != 0 {
sort.Slice(guildMembers[:], func(i, j int) bool { bf.WriteUint32(result.NewLeaderCharID)
return guildMembers[i].OrderIndex < guildMembers[j].OrderIndex
})
for i := 1; i < len(guildMembers); i++ {
if !guildMembers[i].AvoidLeadership {
guild.LeaderCharID = guildMembers[i].CharID
guildMembers[0].OrderIndex = guildMembers[i].OrderIndex
guildMembers[i].OrderIndex = 1
if err := s.server.guildRepo.SaveMember(guildMembers[0]); err != nil {
s.logger.Error("Failed to save former leader member data", zap.Error(err))
}
if err := s.server.guildRepo.SaveMember(guildMembers[i]); err != nil {
s.logger.Error("Failed to save new leader member data", zap.Error(err))
}
bf.WriteUint32(guildMembers[i].CharID)
break
}
}
if err := s.server.guildRepo.Save(guild); err != nil {
s.logger.Error("Failed to save guild after leadership resign", zap.Error(err))
}
} }
case mhfpacket.OperateGuildApply: case mhfpacket.OperateGuildApply:
err = s.server.guildRepo.CreateApplication(guild.ID, s.charID, s.charID, GuildApplicationTypeApplied) err = s.server.guildRepo.CreateApplication(guild.ID, s.charID, s.charID, GuildApplicationTypeApplied)
@@ -73,20 +46,10 @@ func handleMsgMhfOperateGuild(s *Session, p mhfpacket.MHFPacket) {
bf.WriteUint32(0) bf.WriteUint32(0)
} }
case mhfpacket.OperateGuildLeave: case mhfpacket.OperateGuildLeave:
if characterGuildInfo.IsApplicant { result, _ := s.server.guildService.Leave(s.charID, guild.ID, characterGuildInfo.IsApplicant, guild.Name)
err = s.server.guildRepo.RejectApplication(guild.ID, s.charID) response := 0
} else { if result != nil && result.Success {
err = s.server.guildRepo.RemoveCharacter(s.charID) response = 1
}
response := 1
if err != nil {
response = 0
} else {
if err := s.server.mailRepo.SendMail(0, s.charID, "Withdrawal",
fmt.Sprintf("You have withdrawn from 「%s」.", guild.Name),
0, 0, false, true); err != nil {
s.logger.Warn("Failed to send guild withdrawal notification", zap.Error(err))
}
} }
bf.WriteUint32(uint32(response)) bf.WriteUint32(uint32(response))
case mhfpacket.OperateGuildDonateRank: case mhfpacket.OperateGuildDonateRank:

View File

@@ -17,6 +17,7 @@ func TestOperateGuild_Disband_Success(t *testing.T) {
guildMock.guild = &Guild{ID: 10} guildMock.guild = &Guild{ID: 10}
guildMock.guild.LeaderCharID = 1 guildMock.guild.LeaderCharID = 1
server.guildRepo = guildMock server.guildRepo = guildMock
ensureGuildService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfOperateGuild{ pkt := &mhfpacket.MsgMhfOperateGuild{
@@ -49,6 +50,7 @@ func TestOperateGuild_Disband_NotLeader(t *testing.T) {
guildMock.guild = &Guild{ID: 10} guildMock.guild = &Guild{ID: 10}
guildMock.guild.LeaderCharID = 999 // different from session charID guildMock.guild.LeaderCharID = 999 // different from session charID
server.guildRepo = guildMock server.guildRepo = guildMock
ensureGuildService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfOperateGuild{ pkt := &mhfpacket.MsgMhfOperateGuild{
@@ -79,6 +81,7 @@ func TestOperateGuild_Disband_RepoError(t *testing.T) {
guildMock.guild = &Guild{ID: 10} guildMock.guild = &Guild{ID: 10}
guildMock.guild.LeaderCharID = 1 guildMock.guild.LeaderCharID = 1
server.guildRepo = guildMock server.guildRepo = guildMock
ensureGuildService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfOperateGuild{ pkt := &mhfpacket.MsgMhfOperateGuild{
@@ -109,6 +112,7 @@ func TestOperateGuild_Resign_TransferLeadership(t *testing.T) {
{CharID: 2, OrderIndex: 2, AvoidLeadership: false}, {CharID: 2, OrderIndex: 2, AvoidLeadership: false},
} }
server.guildRepo = guildMock server.guildRepo = guildMock
ensureGuildService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfOperateGuild{ pkt := &mhfpacket.MsgMhfOperateGuild{
@@ -149,6 +153,7 @@ func TestOperateGuild_Resign_SkipsAvoidLeadership(t *testing.T) {
{CharID: 3, OrderIndex: 3, AvoidLeadership: false}, {CharID: 3, OrderIndex: 3, AvoidLeadership: false},
} }
server.guildRepo = guildMock server.guildRepo = guildMock
ensureGuildService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfOperateGuild{ pkt := &mhfpacket.MsgMhfOperateGuild{
@@ -230,6 +235,7 @@ func TestOperateGuild_Leave_AsApplicant(t *testing.T) {
guildMock.guild.LeaderCharID = 999 guildMock.guild.LeaderCharID = 999
server.guildRepo = guildMock server.guildRepo = guildMock
server.mailRepo = mailMock server.mailRepo = mailMock
ensureGuildService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfOperateGuild{ pkt := &mhfpacket.MsgMhfOperateGuild{
@@ -258,6 +264,7 @@ func TestOperateGuild_Leave_AsMember(t *testing.T) {
guildMock.guild.LeaderCharID = 999 guildMock.guild.LeaderCharID = 999
server.guildRepo = guildMock server.guildRepo = guildMock
server.mailRepo = mailMock server.mailRepo = mailMock
ensureGuildService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfOperateGuild{ pkt := &mhfpacket.MsgMhfOperateGuild{
@@ -289,6 +296,7 @@ func TestOperateGuild_Leave_MailError(t *testing.T) {
guildMock.guild.LeaderCharID = 999 guildMock.guild.LeaderCharID = 999
server.guildRepo = guildMock server.guildRepo = guildMock
server.mailRepo = mailMock server.mailRepo = mailMock
ensureGuildService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfOperateGuild{ pkt := &mhfpacket.MsgMhfOperateGuild{

View File

@@ -1,59 +1,29 @@
package channelserver package channelserver
import ( import (
"errors"
"erupe-ce/common/byteframe" "erupe-ce/common/byteframe"
"erupe-ce/common/stringsupport" "erupe-ce/common/stringsupport"
"erupe-ce/network/mhfpacket" "erupe-ce/network/mhfpacket"
"fmt"
"go.uber.org/zap" "go.uber.org/zap"
) )
func handleMsgMhfPostGuildScout(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfPostGuildScout(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfPostGuildScout) pkt := p.(*mhfpacket.MsgMhfPostGuildScout)
actorCharGuildData, err := s.server.guildRepo.GetCharacterMembership(s.charID) err := s.server.guildService.PostScout(s.charID, pkt.CharID, ScoutInviteStrings{
Title: s.server.i18n.guild.invite.title,
Body: s.server.i18n.guild.invite.body,
})
if err != nil { if errors.Is(err, ErrAlreadyInvited) {
s.logger.Error("Failed to get character guild data for scout", zap.Error(err))
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
if actorCharGuildData == nil || !actorCharGuildData.CanRecruit() {
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
guildInfo, err := s.server.guildRepo.GetByID(actorCharGuildData.GuildID)
if err != nil {
s.logger.Error("Failed to get guild info for scout", zap.Error(err))
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
hasApplication, err := s.server.guildRepo.HasApplication(guildInfo.ID, pkt.CharID)
if err != nil {
s.logger.Error("Failed to check application for scout", zap.Error(err))
doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return
}
if hasApplication {
doAckBufSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x04}) doAckBufSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x04})
return return
} }
err = s.server.guildRepo.CreateApplicationWithMail(
guildInfo.ID, pkt.CharID, s.charID, GuildApplicationTypeInvited,
s.charID, pkt.CharID,
s.server.i18n.guild.invite.title,
fmt.Sprintf(s.server.i18n.guild.invite.body, guildInfo.Name))
if err != nil { if err != nil {
s.logger.Error("Failed to create guild scout application with mail", zap.Error(err)) s.logger.Error("Failed to post guild scout", zap.Error(err))
doAckBufFail(s, pkt.AckHandle, nil) doAckBufFail(s, pkt.AckHandle, make([]byte, 4))
return return
} }
@@ -95,64 +65,37 @@ func handleMsgMhfCancelGuildScout(s *Session, p mhfpacket.MHFPacket) {
func handleMsgMhfAnswerGuildScout(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfAnswerGuildScout(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfAnswerGuildScout) pkt := p.(*mhfpacket.MsgMhfAnswerGuildScout)
bf := byteframe.NewByteFrame()
guild, err := s.server.guildRepo.GetByCharID(pkt.LeaderID)
if err != nil { i := s.server.i18n.guild.invite
s.logger.Error("Failed to get guild info for answer scout", zap.Error(err)) result, err := s.server.guildService.AnswerScout(s.charID, pkt.LeaderID, pkt.Answer, AnswerScoutStrings{
SuccessTitle: i.success.title,
SuccessBody: i.success.body,
AcceptedTitle: i.accepted.title,
AcceptedBody: i.accepted.body,
RejectedTitle: i.rejected.title,
RejectedBody: i.rejected.body,
DeclinedTitle: i.declined.title,
DeclinedBody: i.declined.body,
})
if err != nil && !errors.Is(err, ErrApplicationMissing) {
s.logger.Error("Failed to answer guild scout", zap.Error(err))
doAckBufFail(s, pkt.AckHandle, nil) doAckBufFail(s, pkt.AckHandle, nil)
return return
} }
app, err := s.server.guildRepo.GetApplication(guild.ID, s.charID, GuildApplicationTypeInvited) bf := byteframe.NewByteFrame()
if result != nil && result.Success {
if app == nil || err != nil {
s.logger.Warn(
"Guild invite missing, deleted?",
zap.Error(err),
zap.Uint32("guildID", guild.ID),
zap.Uint32("charID", s.charID),
)
bf.WriteUint32(7)
bf.WriteUint32(guild.ID)
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
return
}
type mailMsg struct {
senderID uint32
recipientID uint32
subject string
body string
}
var msgs []mailMsg
if pkt.Answer {
err = s.server.guildRepo.AcceptApplication(guild.ID, s.charID)
msgs = append(msgs,
mailMsg{0, s.charID, s.server.i18n.guild.invite.success.title, fmt.Sprintf(s.server.i18n.guild.invite.success.body, guild.Name)},
mailMsg{s.charID, pkt.LeaderID, s.server.i18n.guild.invite.accepted.title, fmt.Sprintf(s.server.i18n.guild.invite.accepted.body, guild.Name)},
)
} else {
err = s.server.guildRepo.RejectApplication(guild.ID, s.charID)
msgs = append(msgs,
mailMsg{0, s.charID, s.server.i18n.guild.invite.rejected.title, fmt.Sprintf(s.server.i18n.guild.invite.rejected.body, guild.Name)},
mailMsg{s.charID, pkt.LeaderID, s.server.i18n.guild.invite.declined.title, fmt.Sprintf(s.server.i18n.guild.invite.declined.body, guild.Name)},
)
}
if err != nil {
bf.WriteUint32(7)
bf.WriteUint32(guild.ID)
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
} else {
bf.WriteUint32(0) bf.WriteUint32(0)
bf.WriteUint32(guild.ID) } else {
doAckBufSucceed(s, pkt.AckHandle, bf.Data()) if errors.Is(err, ErrApplicationMissing) {
for _, m := range msgs { s.logger.Warn("Guild invite missing, deleted?",
if err := s.server.mailRepo.SendMail(m.senderID, m.recipientID, m.subject, m.body, 0, 0, false, true); err != nil { zap.Uint32("charID", s.charID))
s.logger.Warn("Failed to send guild scout response mail", zap.Error(err))
}
} }
bf.WriteUint32(7)
} }
bf.WriteUint32(result.GuildID)
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
} }
func handleMsgMhfGetGuildScoutList(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfGetGuildScoutList(s *Session, p mhfpacket.MHFPacket) {

View File

@@ -18,6 +18,7 @@ func TestAnswerGuildScout_Accept(t *testing.T) {
guildMock.guild.LeaderCharID = 50 guildMock.guild.LeaderCharID = 50
server.guildRepo = guildMock server.guildRepo = guildMock
server.mailRepo = mailMock server.mailRepo = mailMock
ensureGuildService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfAnswerGuildScout{ pkt := &mhfpacket.MsgMhfAnswerGuildScout{
@@ -52,6 +53,7 @@ func TestAnswerGuildScout_Decline(t *testing.T) {
guildMock.guild.LeaderCharID = 50 guildMock.guild.LeaderCharID = 50
server.guildRepo = guildMock server.guildRepo = guildMock
server.mailRepo = mailMock server.mailRepo = mailMock
ensureGuildService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfAnswerGuildScout{ pkt := &mhfpacket.MsgMhfAnswerGuildScout{
@@ -76,6 +78,7 @@ func TestAnswerGuildScout_GuildNotFound(t *testing.T) {
guildMock.getErr = errNotFound guildMock.getErr = errNotFound
server.guildRepo = guildMock server.guildRepo = guildMock
server.mailRepo = &mockMailRepo{} server.mailRepo = &mockMailRepo{}
ensureGuildService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfAnswerGuildScout{ pkt := &mhfpacket.MsgMhfAnswerGuildScout{
@@ -104,6 +107,7 @@ func TestAnswerGuildScout_ApplicationMissing(t *testing.T) {
guildMock.guild.LeaderCharID = 50 guildMock.guild.LeaderCharID = 50
server.guildRepo = guildMock server.guildRepo = guildMock
server.mailRepo = mailMock server.mailRepo = mailMock
ensureGuildService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfAnswerGuildScout{ pkt := &mhfpacket.MsgMhfAnswerGuildScout{
@@ -136,6 +140,7 @@ func TestAnswerGuildScout_MailError(t *testing.T) {
guildMock.guild.LeaderCharID = 50 guildMock.guild.LeaderCharID = 50
server.guildRepo = guildMock server.guildRepo = guildMock
server.mailRepo = mailMock server.mailRepo = mailMock
ensureGuildService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfAnswerGuildScout{ pkt := &mhfpacket.MsgMhfAnswerGuildScout{

View File

@@ -3,6 +3,7 @@ package channelserver
import ( import (
"errors" "errors"
"fmt" "fmt"
"sort"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -22,12 +23,64 @@ var ErrUnauthorized = errors.New("unauthorized")
// ErrUnknownAction is returned for unrecognized guild member actions. // ErrUnknownAction is returned for unrecognized guild member actions.
var ErrUnknownAction = errors.New("unknown guild member action") var ErrUnknownAction = errors.New("unknown guild member action")
// ErrNoEligibleLeader is returned when no member can accept leadership.
var ErrNoEligibleLeader = errors.New("no eligible leader")
// ErrAlreadyInvited is returned when a scout target already has a pending application.
var ErrAlreadyInvited = errors.New("already invited")
// ErrCannotRecruit is returned when the actor lacks recruit permission.
var ErrCannotRecruit = errors.New("cannot recruit")
// ErrApplicationMissing is returned when the expected guild application is not found.
var ErrApplicationMissing = errors.New("application missing")
// OperateMemberResult holds the outcome of a guild member operation. // OperateMemberResult holds the outcome of a guild member operation.
type OperateMemberResult struct { type OperateMemberResult struct {
MailRecipientID uint32 MailRecipientID uint32
Mail Mail Mail Mail
} }
// DisbandResult holds the outcome of a guild disband operation.
type DisbandResult struct {
Success bool
}
// ResignResult holds the outcome of a leadership resignation.
type ResignResult struct {
NewLeaderCharID uint32
}
// LeaveResult holds the outcome of a guild leave operation.
type LeaveResult struct {
Success bool
}
// ScoutInviteStrings holds i18n strings needed for scout invitation mails.
type ScoutInviteStrings struct {
Title string
Body string // must contain %s for guild name
}
// AnswerScoutStrings holds i18n strings needed for scout answer mails.
type AnswerScoutStrings struct {
SuccessTitle string
SuccessBody string // %s for guild name
AcceptedTitle string
AcceptedBody string // %s for guild name
RejectedTitle string
RejectedBody string // %s for guild name
DeclinedTitle string
DeclinedBody string // %s for guild name
}
// AnswerScoutResult holds the outcome of answering a guild scout invitation.
type AnswerScoutResult struct {
GuildID uint32
Success bool
Mails []Mail
}
// GuildService encapsulates guild business logic, sitting between handlers and repos. // GuildService encapsulates guild business logic, sitting between handlers and repos.
type GuildService struct { type GuildService struct {
guildRepo GuildRepo guildRepo GuildRepo
@@ -104,3 +157,199 @@ func (svc *GuildService) OperateMember(actorCharID, targetCharID uint32, action
Mail: mail, Mail: mail,
}, nil }, nil
} }
// Disband disbands a guild. Only the guild leader may disband.
func (svc *GuildService) Disband(actorCharID, guildID uint32) (*DisbandResult, error) {
guild, err := svc.guildRepo.GetByID(guildID)
if err != nil {
return nil, fmt.Errorf("guild lookup: %w", err)
}
if guild.LeaderCharID != actorCharID {
svc.logger.Warn("Unauthorized guild disband attempt",
zap.Uint32("charID", actorCharID), zap.Uint32("guildID", guildID))
return &DisbandResult{Success: false}, nil
}
if err := svc.guildRepo.Disband(guildID); err != nil {
return &DisbandResult{Success: false}, nil
}
return &DisbandResult{Success: true}, nil
}
// ResignLeadership transfers guild leadership to the next eligible member.
// Members are sorted by order index; those with AvoidLeadership set are skipped.
func (svc *GuildService) ResignLeadership(actorCharID, guildID uint32) (*ResignResult, error) {
guild, err := svc.guildRepo.GetByID(guildID)
if err != nil {
return nil, fmt.Errorf("guild lookup: %w", err)
}
members, err := svc.guildRepo.GetMembers(guildID, false)
if err != nil {
return nil, fmt.Errorf("get members: %w", err)
}
sort.Slice(members, func(i, j int) bool {
return members[i].OrderIndex < members[j].OrderIndex
})
// Find current leader in sorted list (should be index 0)
var leaderIdx int
for i, m := range members {
if m.CharID == actorCharID {
leaderIdx = i
break
}
}
// Find first eligible successor (skip leader and anyone avoiding leadership)
var newLeaderIdx int
found := false
for i := 1; i < len(members); i++ {
if i == leaderIdx {
continue
}
if !members[i].AvoidLeadership {
newLeaderIdx = i
found = true
break
}
}
if !found {
return &ResignResult{NewLeaderCharID: 0}, nil
}
// Swap order indices
guild.LeaderCharID = members[newLeaderIdx].CharID
members[leaderIdx].OrderIndex, members[newLeaderIdx].OrderIndex =
members[newLeaderIdx].OrderIndex, 1
if err := svc.guildRepo.SaveMember(members[leaderIdx]); err != nil {
svc.logger.Error("Failed to save former leader member data", zap.Error(err))
}
if err := svc.guildRepo.SaveMember(members[newLeaderIdx]); err != nil {
svc.logger.Error("Failed to save new leader member data", zap.Error(err))
}
if err := svc.guildRepo.Save(guild); err != nil {
svc.logger.Error("Failed to save guild after leadership resign", zap.Error(err))
}
return &ResignResult{NewLeaderCharID: members[newLeaderIdx].CharID}, nil
}
// Leave removes a character from their guild. If the character is an applicant,
// their application is rejected; otherwise they are removed as a member.
// A withdrawal notification mail is sent on success.
func (svc *GuildService) Leave(charID, guildID uint32, isApplicant bool, guildName string) (*LeaveResult, error) {
if isApplicant {
if err := svc.guildRepo.RejectApplication(guildID, charID); err != nil {
return &LeaveResult{Success: false}, nil
}
} else {
if err := svc.guildRepo.RemoveCharacter(charID); err != nil {
return &LeaveResult{Success: false}, nil
}
}
// Best-effort withdrawal notification
if err := svc.mailRepo.SendMail(0, charID, "Withdrawal",
fmt.Sprintf("You have withdrawn from 「%s」.", guildName),
0, 0, false, true); err != nil {
svc.logger.Warn("Failed to send guild withdrawal notification", zap.Error(err))
}
return &LeaveResult{Success: true}, nil
}
// PostScout sends a guild scout invitation to a target character.
// The actor must have recruit permission. Returns ErrAlreadyInvited if the target
// already has a pending application.
func (svc *GuildService) PostScout(actorCharID, targetCharID uint32, strings ScoutInviteStrings) error {
actorMember, err := svc.guildRepo.GetCharacterMembership(actorCharID)
if err != nil {
return fmt.Errorf("actor membership lookup: %w", err)
}
if actorMember == nil || !actorMember.CanRecruit() {
return ErrCannotRecruit
}
guild, err := svc.guildRepo.GetByID(actorMember.GuildID)
if err != nil {
return fmt.Errorf("guild lookup: %w", err)
}
hasApp, err := svc.guildRepo.HasApplication(guild.ID, targetCharID)
if err != nil {
return fmt.Errorf("check application: %w", err)
}
if hasApp {
return ErrAlreadyInvited
}
err = svc.guildRepo.CreateApplicationWithMail(
guild.ID, targetCharID, actorCharID, GuildApplicationTypeInvited,
actorCharID, targetCharID,
strings.Title,
fmt.Sprintf(strings.Body, guild.Name))
if err != nil {
return fmt.Errorf("create scout application: %w", err)
}
return nil
}
// AnswerScout processes a character's response to a guild scout invitation.
// If accept is true, the character joins the guild; otherwise the invitation is rejected.
// Notification mails are sent to both the character and the leader.
func (svc *GuildService) AnswerScout(charID, leaderID uint32, accept bool, strings AnswerScoutStrings) (*AnswerScoutResult, error) {
guild, err := svc.guildRepo.GetByCharID(leaderID)
if err != nil {
return nil, fmt.Errorf("guild lookup for leader %d: %w", leaderID, err)
}
app, err := svc.guildRepo.GetApplication(guild.ID, charID, GuildApplicationTypeInvited)
if app == nil || err != nil {
return &AnswerScoutResult{
GuildID: guild.ID,
Success: false,
}, ErrApplicationMissing
}
var mails []Mail
if accept {
err = svc.guildRepo.AcceptApplication(guild.ID, charID)
mails = []Mail{
{SenderID: 0, RecipientID: charID, Subject: strings.SuccessTitle, Body: fmt.Sprintf(strings.SuccessBody, guild.Name), IsSystemMessage: true},
{SenderID: charID, RecipientID: leaderID, Subject: strings.AcceptedTitle, Body: fmt.Sprintf(strings.AcceptedBody, guild.Name), IsSystemMessage: true},
}
} else {
err = svc.guildRepo.RejectApplication(guild.ID, charID)
mails = []Mail{
{SenderID: 0, RecipientID: charID, Subject: strings.RejectedTitle, Body: fmt.Sprintf(strings.RejectedBody, guild.Name), IsSystemMessage: true},
{SenderID: charID, RecipientID: leaderID, Subject: strings.DeclinedTitle, Body: fmt.Sprintf(strings.DeclinedBody, guild.Name), IsSystemMessage: true},
}
}
if err != nil {
return &AnswerScoutResult{
GuildID: guild.ID,
Success: false,
}, nil
}
// Send mails best-effort
for _, m := range mails {
if mailErr := svc.mailRepo.SendMail(m.SenderID, m.RecipientID, m.Subject, m.Body, 0, 0, false, true); mailErr != nil {
svc.logger.Warn("Failed to send guild scout response mail", zap.Error(mailErr))
}
}
return &AnswerScoutResult{
GuildID: guild.ID,
Success: true,
Mails: mails,
}, nil
}

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)
}
})
}
}