mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 15:43:49 +01:00
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.
565 lines
16 KiB
Go
565 lines
16 KiB
Go
package channelserver
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func newTestGuildService(gr GuildRepo, mr MailRepo) *GuildService {
|
|
logger, _ := zap.NewDevelopment()
|
|
return NewGuildService(gr, mr, nil, logger)
|
|
}
|
|
|
|
func TestGuildService_OperateMember(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
actorCharID uint32
|
|
targetCharID uint32
|
|
action GuildMemberAction
|
|
guild *Guild
|
|
membership *GuildMember
|
|
acceptErr error
|
|
rejectErr error
|
|
removeErr error
|
|
sendErr error
|
|
wantErr bool
|
|
wantErrIs error
|
|
wantAccepted uint32
|
|
wantRejected uint32
|
|
wantRemoved uint32
|
|
wantMailCount int
|
|
wantRecipient uint32
|
|
wantMailSubj string
|
|
}{
|
|
{
|
|
name: "accept application as leader",
|
|
actorCharID: 1,
|
|
targetCharID: 42,
|
|
action: GuildMemberActionAccept,
|
|
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 1}},
|
|
membership: &GuildMember{GuildID: 10, CharID: 1, IsLeader: true, OrderIndex: 1},
|
|
wantAccepted: 42,
|
|
wantMailCount: 1,
|
|
wantRecipient: 42,
|
|
wantMailSubj: "Accepted!",
|
|
},
|
|
{
|
|
name: "reject application as sub-leader",
|
|
actorCharID: 2,
|
|
targetCharID: 42,
|
|
action: GuildMemberActionReject,
|
|
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 1}},
|
|
membership: &GuildMember{GuildID: 10, CharID: 2, OrderIndex: 2}, // sub-leader
|
|
wantRejected: 42,
|
|
wantMailCount: 1,
|
|
wantRecipient: 42,
|
|
wantMailSubj: "Rejected",
|
|
},
|
|
{
|
|
name: "kick member as leader",
|
|
actorCharID: 1,
|
|
targetCharID: 42,
|
|
action: GuildMemberActionKick,
|
|
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 1}},
|
|
membership: &GuildMember{GuildID: 10, CharID: 1, IsLeader: true, OrderIndex: 1},
|
|
wantRemoved: 42,
|
|
wantMailCount: 1,
|
|
wantRecipient: 42,
|
|
wantMailSubj: "Kicked",
|
|
},
|
|
{
|
|
name: "unauthorized - not leader or sub",
|
|
actorCharID: 5,
|
|
targetCharID: 42,
|
|
action: GuildMemberActionAccept,
|
|
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 1}},
|
|
membership: &GuildMember{GuildID: 10, CharID: 5, OrderIndex: 10},
|
|
wantErr: true,
|
|
wantErrIs: ErrUnauthorized,
|
|
},
|
|
{
|
|
name: "repo error on accept",
|
|
actorCharID: 1,
|
|
targetCharID: 42,
|
|
action: GuildMemberActionAccept,
|
|
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 1}},
|
|
membership: &GuildMember{GuildID: 10, CharID: 1, IsLeader: true, OrderIndex: 1},
|
|
acceptErr: errors.New("db error"),
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "mail error is best-effort",
|
|
actorCharID: 1,
|
|
targetCharID: 42,
|
|
action: GuildMemberActionAccept,
|
|
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 1}},
|
|
membership: &GuildMember{GuildID: 10, CharID: 1, IsLeader: true, OrderIndex: 1},
|
|
sendErr: errors.New("mail failed"),
|
|
wantAccepted: 42,
|
|
wantMailCount: 1,
|
|
wantRecipient: 42,
|
|
wantMailSubj: "Accepted!",
|
|
},
|
|
{
|
|
name: "unknown action",
|
|
actorCharID: 1,
|
|
targetCharID: 42,
|
|
action: GuildMemberAction(99),
|
|
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 1}},
|
|
membership: &GuildMember{GuildID: 10, CharID: 1, IsLeader: true, OrderIndex: 1},
|
|
wantErr: true,
|
|
wantErrIs: ErrUnknownAction,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
guildMock := &mockGuildRepoOps{
|
|
membership: tt.membership,
|
|
acceptErr: tt.acceptErr,
|
|
rejectErr: tt.rejectErr,
|
|
removeErr: tt.removeErr,
|
|
}
|
|
guildMock.guild = tt.guild
|
|
mailMock := &mockMailRepo{sendErr: tt.sendErr}
|
|
|
|
svc := newTestGuildService(guildMock, mailMock)
|
|
|
|
result, err := svc.OperateMember(tt.actorCharID, tt.targetCharID, tt.action)
|
|
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatal("Expected error, got nil")
|
|
}
|
|
if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) {
|
|
t.Errorf("Expected error %v, got %v", tt.wantErrIs, err)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error: %v", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
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.Fatalf("sentMails count = %d, want %d", len(mailMock.sentMails), tt.wantMailCount)
|
|
}
|
|
if tt.wantMailCount > 0 {
|
|
if mailMock.sentMails[0].recipientID != tt.wantRecipient {
|
|
t.Errorf("mail recipientID = %d, want %d", mailMock.sentMails[0].recipientID, tt.wantRecipient)
|
|
}
|
|
if mailMock.sentMails[0].subject != tt.wantMailSubj {
|
|
t.Errorf("mail subject = %q, want %q", mailMock.sentMails[0].subject, tt.wantMailSubj)
|
|
}
|
|
}
|
|
if result.MailRecipientID != tt.targetCharID {
|
|
t.Errorf("result.MailRecipientID = %d, want %d", result.MailRecipientID, tt.targetCharID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|