diff --git a/server/channelserver/handlers_guild_ops.go b/server/channelserver/handlers_guild_ops.go index 5adc7f4a0..af6d71c53 100644 --- a/server/channelserver/handlers_guild_ops.go +++ b/server/channelserver/handlers_guild_ops.go @@ -1,8 +1,6 @@ package channelserver import ( - "fmt" - "sort" "time" "erupe-ce/common/byteframe" @@ -29,41 +27,16 @@ func handleMsgMhfOperateGuild(s *Session, p mhfpacket.MHFPacket) { switch pkt.Action { case mhfpacket.OperateGuildDisband: - response := 1 - if guild.LeaderCharID != s.charID { - s.logger.Warn("Unauthorized guild management attempt", zap.Uint32("charID", s.charID), zap.Uint32("guildID", guild.ID)) - response = 0 - } else { - err = s.server.guildRepo.Disband(guild.ID) - if err != nil { - response = 0 - } + result, _ := s.server.guildService.Disband(s.charID, guild.ID) + response := 0 + if result != nil && result.Success { + response = 1 } bf.WriteUint32(uint32(response)) case mhfpacket.OperateGuildResign: - guildMembers, err := s.server.guildRepo.GetMembers(guild.ID, false) - if err == nil { - sort.Slice(guildMembers[:], func(i, j int) bool { - 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)) - } + result, err := s.server.guildService.ResignLeadership(s.charID, guild.ID) + if err == nil && result.NewLeaderCharID != 0 { + bf.WriteUint32(result.NewLeaderCharID) } case mhfpacket.OperateGuildApply: 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) } case mhfpacket.OperateGuildLeave: - if characterGuildInfo.IsApplicant { - err = s.server.guildRepo.RejectApplication(guild.ID, s.charID) - } else { - err = s.server.guildRepo.RemoveCharacter(s.charID) - } - 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)) - } + result, _ := s.server.guildService.Leave(s.charID, guild.ID, characterGuildInfo.IsApplicant, guild.Name) + response := 0 + if result != nil && result.Success { + response = 1 } bf.WriteUint32(uint32(response)) case mhfpacket.OperateGuildDonateRank: diff --git a/server/channelserver/handlers_guild_ops_test.go b/server/channelserver/handlers_guild_ops_test.go index b9f841c10..bd67ca690 100644 --- a/server/channelserver/handlers_guild_ops_test.go +++ b/server/channelserver/handlers_guild_ops_test.go @@ -17,6 +17,7 @@ func TestOperateGuild_Disband_Success(t *testing.T) { guildMock.guild = &Guild{ID: 10} guildMock.guild.LeaderCharID = 1 server.guildRepo = guildMock + ensureGuildService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfOperateGuild{ @@ -49,6 +50,7 @@ func TestOperateGuild_Disband_NotLeader(t *testing.T) { guildMock.guild = &Guild{ID: 10} guildMock.guild.LeaderCharID = 999 // different from session charID server.guildRepo = guildMock + ensureGuildService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfOperateGuild{ @@ -79,6 +81,7 @@ func TestOperateGuild_Disband_RepoError(t *testing.T) { guildMock.guild = &Guild{ID: 10} guildMock.guild.LeaderCharID = 1 server.guildRepo = guildMock + ensureGuildService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfOperateGuild{ @@ -109,6 +112,7 @@ func TestOperateGuild_Resign_TransferLeadership(t *testing.T) { {CharID: 2, OrderIndex: 2, AvoidLeadership: false}, } server.guildRepo = guildMock + ensureGuildService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfOperateGuild{ @@ -149,6 +153,7 @@ func TestOperateGuild_Resign_SkipsAvoidLeadership(t *testing.T) { {CharID: 3, OrderIndex: 3, AvoidLeadership: false}, } server.guildRepo = guildMock + ensureGuildService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfOperateGuild{ @@ -230,6 +235,7 @@ func TestOperateGuild_Leave_AsApplicant(t *testing.T) { guildMock.guild.LeaderCharID = 999 server.guildRepo = guildMock server.mailRepo = mailMock + ensureGuildService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfOperateGuild{ @@ -258,6 +264,7 @@ func TestOperateGuild_Leave_AsMember(t *testing.T) { guildMock.guild.LeaderCharID = 999 server.guildRepo = guildMock server.mailRepo = mailMock + ensureGuildService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfOperateGuild{ @@ -289,6 +296,7 @@ func TestOperateGuild_Leave_MailError(t *testing.T) { guildMock.guild.LeaderCharID = 999 server.guildRepo = guildMock server.mailRepo = mailMock + ensureGuildService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfOperateGuild{ diff --git a/server/channelserver/handlers_guild_scout.go b/server/channelserver/handlers_guild_scout.go index 292878483..f8cb3a766 100644 --- a/server/channelserver/handlers_guild_scout.go +++ b/server/channelserver/handlers_guild_scout.go @@ -1,59 +1,29 @@ package channelserver import ( + "errors" + "erupe-ce/common/byteframe" "erupe-ce/common/stringsupport" "erupe-ce/network/mhfpacket" - "fmt" "go.uber.org/zap" ) func handleMsgMhfPostGuildScout(s *Session, p mhfpacket.MHFPacket) { 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 { - 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 { + if errors.Is(err, ErrAlreadyInvited) { doAckBufSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x04}) 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 { - s.logger.Error("Failed to create guild scout application with mail", zap.Error(err)) - doAckBufFail(s, pkt.AckHandle, nil) + s.logger.Error("Failed to post guild scout", zap.Error(err)) + doAckBufFail(s, pkt.AckHandle, make([]byte, 4)) return } @@ -95,64 +65,37 @@ func handleMsgMhfCancelGuildScout(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfAnswerGuildScout(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfAnswerGuildScout) - bf := byteframe.NewByteFrame() - guild, err := s.server.guildRepo.GetByCharID(pkt.LeaderID) - if err != nil { - s.logger.Error("Failed to get guild info for answer scout", zap.Error(err)) + i := s.server.i18n.guild.invite + 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) return } - app, err := s.server.guildRepo.GetApplication(guild.ID, s.charID, GuildApplicationTypeInvited) - - 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 := byteframe.NewByteFrame() + if result != nil && result.Success { bf.WriteUint32(0) - bf.WriteUint32(guild.ID) - doAckBufSucceed(s, pkt.AckHandle, bf.Data()) - for _, m := range msgs { - if err := s.server.mailRepo.SendMail(m.senderID, m.recipientID, m.subject, m.body, 0, 0, false, true); err != nil { - s.logger.Warn("Failed to send guild scout response mail", zap.Error(err)) - } + } else { + if errors.Is(err, ErrApplicationMissing) { + s.logger.Warn("Guild invite missing, deleted?", + zap.Uint32("charID", s.charID)) } + bf.WriteUint32(7) } + bf.WriteUint32(result.GuildID) + doAckBufSucceed(s, pkt.AckHandle, bf.Data()) } func handleMsgMhfGetGuildScoutList(s *Session, p mhfpacket.MHFPacket) { diff --git a/server/channelserver/handlers_guild_scout_test.go b/server/channelserver/handlers_guild_scout_test.go index 5250fd18a..b20cc3e14 100644 --- a/server/channelserver/handlers_guild_scout_test.go +++ b/server/channelserver/handlers_guild_scout_test.go @@ -18,6 +18,7 @@ func TestAnswerGuildScout_Accept(t *testing.T) { guildMock.guild.LeaderCharID = 50 server.guildRepo = guildMock server.mailRepo = mailMock + ensureGuildService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfAnswerGuildScout{ @@ -52,6 +53,7 @@ func TestAnswerGuildScout_Decline(t *testing.T) { guildMock.guild.LeaderCharID = 50 server.guildRepo = guildMock server.mailRepo = mailMock + ensureGuildService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfAnswerGuildScout{ @@ -76,6 +78,7 @@ func TestAnswerGuildScout_GuildNotFound(t *testing.T) { guildMock.getErr = errNotFound server.guildRepo = guildMock server.mailRepo = &mockMailRepo{} + ensureGuildService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfAnswerGuildScout{ @@ -104,6 +107,7 @@ func TestAnswerGuildScout_ApplicationMissing(t *testing.T) { guildMock.guild.LeaderCharID = 50 server.guildRepo = guildMock server.mailRepo = mailMock + ensureGuildService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfAnswerGuildScout{ @@ -136,6 +140,7 @@ func TestAnswerGuildScout_MailError(t *testing.T) { guildMock.guild.LeaderCharID = 50 server.guildRepo = guildMock server.mailRepo = mailMock + ensureGuildService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfAnswerGuildScout{ diff --git a/server/channelserver/svc_guild.go b/server/channelserver/svc_guild.go index 7702ca6bf..d57251716 100644 --- a/server/channelserver/svc_guild.go +++ b/server/channelserver/svc_guild.go @@ -3,6 +3,7 @@ package channelserver import ( "errors" "fmt" + "sort" "go.uber.org/zap" ) @@ -22,12 +23,64 @@ var ErrUnauthorized = errors.New("unauthorized") // ErrUnknownAction is returned for unrecognized guild member actions. 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. type OperateMemberResult struct { MailRecipientID uint32 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. type GuildService struct { guildRepo GuildRepo @@ -104,3 +157,199 @@ func (svc *GuildService) OperateMember(actorCharID, targetCharID uint32, action Mail: mail, }, 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 +} diff --git a/server/channelserver/svc_guild_test.go b/server/channelserver/svc_guild_test.go index a8992f4e3..0f82126b4 100644 --- a/server/channelserver/svc_guild_test.go +++ b/server/channelserver/svc_guild_test.go @@ -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) + } + }) + } +}