Files
Erupe/server/channelserver/svc_guild_test.go
Houmgaor dbbfb927f8 feat(guild): separate scout invitations into guild_invites table
Scout invitations were stored in guild_applications with type 'invited',
forcing the scout list response to use charID as the invitation ID — a
known hack that made CancelGuildScout semantically incorrect.

Introduce a dedicated guild_invites table (migration 0012) with a serial
PK. The scout list now returns real invite IDs and actual InvitedAt
timestamps. CancelGuildScout cancels by PK. AcceptInvite and DeclineInvite
operate on guild_invites while player-applied applications remain in
guild_applications unchanged.
2026-03-21 17:59:25 +01:00

571 lines
16 KiB
Go

package channelserver
import (
"errors"
"testing"
"go.uber.org/zap"
)
func newTestMailService(mr MailRepo, gr GuildRepo) *MailService {
logger, _ := zap.NewDevelopment()
return NewMailService(mr, gr, logger)
}
func newTestGuildService(gr GuildRepo, mr MailRepo) *GuildService {
logger, _ := zap.NewDevelopment()
ms := newTestMailService(mr, gr)
return NewGuildService(gr, ms, 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 := &mockGuildRepo{
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 := &mockGuildRepo{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 := &mockGuildRepo{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 := &mockGuildRepo{
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
hasInvite bool
hasInviteErr 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"},
hasInvite: 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 := &mockGuildRepo{
membership: tt.membership,
hasInviteResult: tt.hasInvite,
hasInviteErr: tt.hasInviteErr,
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
hasInvite bool
acceptErr error
rejectErr error
sendErr error
getErr error
wantSuccess bool
wantErr error
wantMailCount int
wantAccepted uint32
wantDeclined uint32
}{
{
name: "accept invitation",
accept: true,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
hasInvite: true,
wantSuccess: true,
wantMailCount: 2,
wantAccepted: 1,
},
{
name: "decline invitation",
accept: false,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
hasInvite: true,
wantSuccess: true,
wantMailCount: 2,
wantDeclined: 1,
},
{
name: "application missing",
accept: true,
guild: &Guild{ID: 10, Name: "TestGuild", GuildLeader: GuildLeader{LeaderCharID: 50}},
hasInvite: false,
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}},
hasInvite: true,
sendErr: errors.New("mail failed"),
wantSuccess: true,
wantMailCount: 2,
wantAccepted: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
guildMock := &mockGuildRepo{
hasInviteResult: tt.hasInvite,
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.acceptInviteCharID != tt.wantAccepted {
t.Errorf("acceptInviteCharID = %d, want %d", guildMock.acceptInviteCharID, tt.wantAccepted)
}
if tt.wantDeclined != 0 && guildMock.declineInviteCharID != tt.wantDeclined {
t.Errorf("declineInviteCharID = %d, want %d", guildMock.declineInviteCharID, tt.wantDeclined)
}
})
}
}