From 9a473260b28a83d6a38acd7b008f7ed808a346c8 Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Sat, 21 Feb 2026 14:01:52 +0100 Subject: [PATCH] test(channelserver): add mock-based handler unit tests Leverage the new repository interfaces to test handler logic without a database. Adds shared mock implementations (achievement, mail, character, goocoo, guild) and 32 new handler tests covering achievement, mail, cafe/boost, and goocoo handlers. --- .../handlers_achievement_test.go | 139 ++++++ server/channelserver/handlers_cafe_test.go | 226 ++++++++++ server/channelserver/handlers_goocoo_test.go | 150 +++++++ server/channelserver/handlers_mail_test.go | 410 ++++++++++++++++++ server/channelserver/repo_mocks_test.go | 324 ++++++++++++++ 5 files changed, 1249 insertions(+) create mode 100644 server/channelserver/handlers_goocoo_test.go create mode 100644 server/channelserver/repo_mocks_test.go diff --git a/server/channelserver/handlers_achievement_test.go b/server/channelserver/handlers_achievement_test.go index e7c5d2869..87bed7171 100644 --- a/server/channelserver/handlers_achievement_test.go +++ b/server/channelserver/handlers_achievement_test.go @@ -452,3 +452,142 @@ func TestGetAchData_UpdatedAlwaysFalse(t *testing.T) { } } } + +// --- Mock-based handler tests --- + +func TestHandleMsgMhfGetAchievement_Success(t *testing.T) { + server := createMockServer() + mock := &mockAchievementRepo{ + scores: [33]int32{5, 0, 20, 0, 0, 0, 0, 1}, // A few non-zero scores + } + server.achievementRepo = mock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfGetAchievement{ + AckHandle: 100, + CharID: 1, + } + + handleMsgMhfGetAchievement(session, pkt) + + if !mock.ensureCalled { + t.Error("EnsureExists should have been called") + } + + select { + case p := <-session.sendPackets: + // Response should contain: 16 bytes header + 3 bytes unk + 1 byte count + 33 entries + // Each entry: 1+1+2+4+1+1+2+4 = 16 bytes, so 33*16 = 528 + 20 header = 548 + if len(p.data) < 100 { + t.Errorf("Response too short: %d bytes", len(p.data)) + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfGetAchievement_DBError(t *testing.T) { + server := createMockServer() + mock := &mockAchievementRepo{ + getScoresErr: errNotFound, + } + server.achievementRepo = mock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfGetAchievement{ + AckHandle: 100, + CharID: 1, + } + + handleMsgMhfGetAchievement(session, pkt) + + select { + case p := <-session.sendPackets: + // On error, should return 20 zero bytes + if len(p.data) == 0 { + t.Error("Response should have fallback data") + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfGetAchievement_AllZeroScores(t *testing.T) { + server := createMockServer() + mock := &mockAchievementRepo{} // All scores default to 0 + server.achievementRepo = mock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfGetAchievement{ + AckHandle: 200, + CharID: 1, + } + + handleMsgMhfGetAchievement(session, pkt) + + select { + case p := <-session.sendPackets: + if len(p.data) < 100 { + t.Errorf("Response too short: %d bytes", len(p.data)) + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfAddAchievement_Valid(t *testing.T) { + server := createMockServer() + mock := &mockAchievementRepo{} + server.achievementRepo = mock + session := createMockSession(42, server) + + pkt := &mhfpacket.MsgMhfAddAchievement{ + AchievementID: 5, + } + + handleMsgMhfAddAchievement(session, pkt) + + if !mock.ensureCalled { + t.Error("EnsureExists should have been called") + } + if mock.incrementedID != 5 { + t.Errorf("IncrementScore called with ID %d, want 5", mock.incrementedID) + } +} + +func TestHandleMsgMhfAddAchievement_OutOfRange(t *testing.T) { + server := createMockServer() + mock := &mockAchievementRepo{} + server.achievementRepo = mock + session := createMockSession(42, server) + + pkt := &mhfpacket.MsgMhfAddAchievement{ + AchievementID: 33, // > 32, should be rejected + } + + handleMsgMhfAddAchievement(session, pkt) + + if mock.ensureCalled { + t.Error("EnsureExists should NOT be called for out-of-range ID") + } +} + +func TestHandleMsgMhfAddAchievement_BoundaryID32(t *testing.T) { + server := createMockServer() + mock := &mockAchievementRepo{} + server.achievementRepo = mock + session := createMockSession(42, server) + + pkt := &mhfpacket.MsgMhfAddAchievement{ + AchievementID: 32, // Exactly at boundary, should be accepted + } + + handleMsgMhfAddAchievement(session, pkt) + + if !mock.ensureCalled { + t.Error("EnsureExists should be called for ID 32") + } + if mock.incrementedID != 32 { + t.Errorf("IncrementScore called with ID %d, want 32", mock.incrementedID) + } +} diff --git a/server/channelserver/handlers_cafe_test.go b/server/channelserver/handlers_cafe_test.go index 5b5122159..b00413708 100644 --- a/server/channelserver/handlers_cafe_test.go +++ b/server/channelserver/handlers_cafe_test.go @@ -2,6 +2,7 @@ package channelserver import ( "testing" + "time" "erupe-ce/network/mhfpacket" ) @@ -108,3 +109,228 @@ func TestCafeBonusStruct(t *testing.T) { t.Error("Claimed should be false") } } + +// --- Mock-based handler tests --- + +func TestHandleMsgMhfUpdateCafepoint(t *testing.T) { + server := createMockServer() + charMock := newMockCharacterRepo() + charMock.ints["netcafe_points"] = 150 + server.charRepo = charMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfUpdateCafepoint{AckHandle: 100} + + handleMsgMhfUpdateCafepoint(session, pkt) + + select { + case p := <-session.sendPackets: + if len(p.data) < 4 { + t.Fatal("Response too short") + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfAcquireCafeItem(t *testing.T) { + server := createMockServer() + charMock := newMockCharacterRepo() + charMock.ints["netcafe_points"] = 500 + server.charRepo = charMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfAcquireCafeItem{ + AckHandle: 100, + PointCost: 200, + } + + handleMsgMhfAcquireCafeItem(session, pkt) + + if charMock.ints["netcafe_points"] != 300 { + t.Errorf("netcafe_points = %d, want 300 (500-200)", charMock.ints["netcafe_points"]) + } + + select { + case p := <-session.sendPackets: + if len(p.data) < 4 { + t.Fatal("Response too short") + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfStartBoostTime_Disabled(t *testing.T) { + server := createMockServer() + server.erupeConfig.GameplayOptions.DisableBoostTime = true + charMock := newMockCharacterRepo() + server.charRepo = charMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfStartBoostTime{AckHandle: 100} + + handleMsgMhfStartBoostTime(session, pkt) + + // When disabled, boost_time should NOT be saved + if _, ok := charMock.times["boost_time"]; ok { + t.Error("boost_time should not be saved when disabled") + } + + select { + case p := <-session.sendPackets: + if len(p.data) < 4 { + t.Fatal("Response too short") + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfStartBoostTime_Enabled(t *testing.T) { + server := createMockServer() + server.erupeConfig.GameplayOptions.DisableBoostTime = false + server.erupeConfig.GameplayOptions.BoostTimeDuration = 3600 + charMock := newMockCharacterRepo() + server.charRepo = charMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfStartBoostTime{AckHandle: 100} + + handleMsgMhfStartBoostTime(session, pkt) + + savedTime, ok := charMock.times["boost_time"] + if !ok { + t.Fatal("boost_time should be saved") + } + if savedTime.Before(time.Now()) { + t.Error("boost_time should be in the future") + } + + select { + case p := <-session.sendPackets: + if len(p.data) < 4 { + t.Fatal("Response too short") + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfGetBoostTimeLimit(t *testing.T) { + server := createMockServer() + charMock := newMockCharacterRepo() + future := time.Now().Add(1 * time.Hour) + charMock.times["boost_time"] = future + server.charRepo = charMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfGetBoostTimeLimit{AckHandle: 100} + + handleMsgMhfGetBoostTimeLimit(session, pkt) + + // This handler sends two responses (doAckBufSucceed + doAckSimpleSucceed) + count := 0 + for { + select { + case <-session.sendPackets: + count++ + default: + goto done + } + } +done: + if count != 2 { + t.Errorf("Expected 2 response packets, got %d", count) + } +} + +func TestHandleMsgMhfGetBoostTimeLimit_NoBoost(t *testing.T) { + server := createMockServer() + charMock := newMockCharacterRepo() + charMock.readErr = errNotFound + server.charRepo = charMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfGetBoostTimeLimit{AckHandle: 100} + + handleMsgMhfGetBoostTimeLimit(session, pkt) + + // Should still send responses even on error + count := 0 + for { + select { + case <-session.sendPackets: + count++ + default: + goto done2 + } + } +done2: + if count < 1 { + t.Error("Should queue at least one response packet") + } +} + +func TestHandleMsgMhfGetBoostRight_Active(t *testing.T) { + server := createMockServer() + charMock := newMockCharacterRepo() + charMock.times["boost_time"] = time.Now().Add(1 * time.Hour) // Future = active + server.charRepo = charMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfGetBoostRight{AckHandle: 100} + + handleMsgMhfGetBoostRight(session, pkt) + + select { + case p := <-session.sendPackets: + if len(p.data) < 4 { + t.Fatal("Response too short") + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfGetBoostRight_Expired(t *testing.T) { + server := createMockServer() + charMock := newMockCharacterRepo() + charMock.times["boost_time"] = time.Now().Add(-1 * time.Hour) // Past = expired + server.charRepo = charMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfGetBoostRight{AckHandle: 100} + + handleMsgMhfGetBoostRight(session, pkt) + + select { + case p := <-session.sendPackets: + if len(p.data) < 4 { + t.Fatal("Response too short") + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfGetBoostRight_NoRecord(t *testing.T) { + server := createMockServer() + charMock := newMockCharacterRepo() + charMock.readErr = errNotFound + server.charRepo = charMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfGetBoostRight{AckHandle: 100} + + handleMsgMhfGetBoostRight(session, pkt) + + select { + case p := <-session.sendPackets: + if len(p.data) < 4 { + t.Fatal("Response too short") + } + default: + t.Error("No response packet queued") + } +} diff --git a/server/channelserver/handlers_goocoo_test.go b/server/channelserver/handlers_goocoo_test.go new file mode 100644 index 000000000..1faa31973 --- /dev/null +++ b/server/channelserver/handlers_goocoo_test.go @@ -0,0 +1,150 @@ +package channelserver + +import ( + "testing" + + "erupe-ce/network/mhfpacket" +) + +func TestHandleMsgMhfEnumerateGuacot_Empty(t *testing.T) { + server := createMockServer() + mock := newMockGoocooRepo() + server.goocooRepo = mock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfEnumerateGuacot{AckHandle: 100} + + handleMsgMhfEnumerateGuacot(session, pkt) + + select { + case p := <-session.sendPackets: + if len(p.data) < 4 { + t.Fatal("Response too short") + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfEnumerateGuacot_WithSlots(t *testing.T) { + server := createMockServer() + mock := newMockGoocooRepo() + mock.slots[0] = []byte{0x01, 0x02, 0x03, 0x04} // slot 0 has data + mock.slots[2] = []byte{0x05, 0x06, 0x07, 0x08} // slot 2 has data + server.goocooRepo = mock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfEnumerateGuacot{AckHandle: 100} + + handleMsgMhfEnumerateGuacot(session, pkt) + + select { + case p := <-session.sendPackets: + // Header (4 bytes) + 2 goocoo entries + if len(p.data) < 8 { + t.Errorf("Response too short: %d bytes", len(p.data)) + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfUpdateGuacot_ClearSlot(t *testing.T) { + server := createMockServer() + mock := newMockGoocooRepo() + mock.slots[1] = []byte{0x01, 0x02} // pre-existing data + server.goocooRepo = mock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfUpdateGuacot{ + AckHandle: 100, + Goocoos: []mhfpacket.Goocoo{ + { + Index: 1, + Data1: []int16{0, 0, 0}, // First byte 0 = clear + Data2: []uint32{0}, + Name: []byte("test"), + }, + }, + } + + handleMsgMhfUpdateGuacot(session, pkt) + + if len(mock.clearCalled) != 1 || mock.clearCalled[0] != 1 { + t.Errorf("Expected ClearSlot(1), got %v", mock.clearCalled) + } + + select { + case <-session.sendPackets: + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfUpdateGuacot_SaveSlot(t *testing.T) { + server := createMockServer() + mock := newMockGoocooRepo() + server.goocooRepo = mock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfUpdateGuacot{ + AckHandle: 100, + Goocoos: []mhfpacket.Goocoo{ + { + Index: 2, + Data1: []int16{1, 2, 3}, // First byte non-zero = save + Data2: []uint32{100, 200}, + Name: []byte("MyGoocoo"), + }, + }, + } + + handleMsgMhfUpdateGuacot(session, pkt) + + if _, ok := mock.savedSlots[2]; !ok { + t.Error("Expected SaveSlot to be called for slot 2") + } + if len(mock.clearCalled) != 0 { + t.Error("ClearSlot should not be called for a save operation") + } + + select { + case <-session.sendPackets: + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfUpdateGuacot_SkipInvalidIndex(t *testing.T) { + server := createMockServer() + mock := newMockGoocooRepo() + server.goocooRepo = mock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfUpdateGuacot{ + AckHandle: 100, + Goocoos: []mhfpacket.Goocoo{ + { + Index: 5, // > 4, should be skipped + Data1: []int16{1}, + Data2: []uint32{0}, + Name: []byte("Bad"), + }, + }, + } + + handleMsgMhfUpdateGuacot(session, pkt) + + if len(mock.savedSlots) != 0 { + t.Error("SaveSlot should not be called for index > 4") + } + if len(mock.clearCalled) != 0 { + t.Error("ClearSlot should not be called for index > 4") + } + + select { + case <-session.sendPackets: + default: + t.Error("No response packet queued") + } +} diff --git a/server/channelserver/handlers_mail_test.go b/server/channelserver/handlers_mail_test.go index 8fe726c0d..d444397d4 100644 --- a/server/channelserver/handlers_mail_test.go +++ b/server/channelserver/handlers_mail_test.go @@ -3,6 +3,8 @@ package channelserver import ( "testing" "time" + + "erupe-ce/network/mhfpacket" ) func TestMailStruct(t *testing.T) { @@ -81,3 +83,411 @@ func TestMailStruct_DefaultValues(t *testing.T) { t.Error("Default Read should be false") } } + +// --- Mock-based handler tests --- + +func TestHandleMsgMhfListMail_Empty(t *testing.T) { + server := createMockServer() + mock := &mockMailRepo{mails: []Mail{}} + server.mailRepo = mock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfListMail{AckHandle: 100} + + handleMsgMhfListMail(session, pkt) + + select { + case p := <-session.sendPackets: + if len(p.data) < 4 { + t.Fatal("Response too short") + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfListMail_WithMails(t *testing.T) { + server := createMockServer() + mock := &mockMailRepo{ + mails: []Mail{ + {ID: 10, SenderID: 100, Subject: "Hello", SenderName: "Sender1", CreatedAt: time.Now()}, + {ID: 20, SenderID: 200, Subject: "World", SenderName: "Sender2", CreatedAt: time.Now(), Locked: true}, + }, + } + server.mailRepo = mock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfListMail{AckHandle: 100} + + handleMsgMhfListMail(session, pkt) + + // Verify mailList was populated + if session.mailList == nil { + t.Fatal("mailList should be initialized") + } + if session.mailList[0] != 10 { + t.Errorf("mailList[0] = %d, want 10", session.mailList[0]) + } + if session.mailList[1] != 20 { + t.Errorf("mailList[1] = %d, want 20", session.mailList[1]) + } + if session.mailAccIndex != 2 { + t.Errorf("mailAccIndex = %d, want 2", session.mailAccIndex) + } + + select { + case p := <-session.sendPackets: + if len(p.data) < 10 { + t.Errorf("Response too short: %d bytes", len(p.data)) + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfListMail_DBError(t *testing.T) { + server := createMockServer() + mock := &mockMailRepo{listErr: errNotFound} + server.mailRepo = mock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfListMail{AckHandle: 100} + + handleMsgMhfListMail(session, pkt) + + select { + case p := <-session.sendPackets: + // Should return a fallback response with single zero byte + if len(p.data) == 0 { + t.Error("Should have fallback response data") + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfReadMail_Success(t *testing.T) { + server := createMockServer() + mock := &mockMailRepo{ + mailByID: map[int]*Mail{ + 42: {ID: 42, Body: "Test body content"}, + }, + } + server.mailRepo = mock + session := createMockSession(1, server) + session.mailList = make([]int, 256) + session.mailList[0] = 42 + + pkt := &mhfpacket.MsgMhfReadMail{ + AckHandle: 100, + AccIndex: 0, + } + + handleMsgMhfReadMail(session, pkt) + + if mock.markReadCalled != 42 { + t.Errorf("MarkRead called with %d, want 42", mock.markReadCalled) + } + + select { + case p := <-session.sendPackets: + if len(p.data) == 0 { + t.Error("Response should have body data") + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfReadMail_OutOfBounds(t *testing.T) { + server := createMockServer() + mock := &mockMailRepo{} + server.mailRepo = mock + session := createMockSession(1, server) + // mailList is nil, so any AccIndex is out of bounds + + pkt := &mhfpacket.MsgMhfReadMail{ + AckHandle: 100, + AccIndex: 5, + } + + handleMsgMhfReadMail(session, pkt) + + select { + case p := <-session.sendPackets: + // Should get fallback single-byte response + if len(p.data) == 0 { + t.Error("Should have fallback response") + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfReadMail_ZeroMailID(t *testing.T) { + server := createMockServer() + mock := &mockMailRepo{} + server.mailRepo = mock + session := createMockSession(1, server) + session.mailList = make([]int, 256) + // mailList[0] is 0 (default) + + pkt := &mhfpacket.MsgMhfReadMail{ + AckHandle: 100, + AccIndex: 0, + } + + handleMsgMhfReadMail(session, pkt) + + select { + case p := <-session.sendPackets: + if len(p.data) == 0 { + t.Error("Should have fallback response") + } + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfOprtMail_Delete(t *testing.T) { + server := createMockServer() + mock := &mockMailRepo{ + mailByID: map[int]*Mail{ + 42: {ID: 42}, + }, + } + server.mailRepo = mock + session := createMockSession(1, server) + session.mailList = make([]int, 256) + session.mailList[0] = 42 + + pkt := &mhfpacket.MsgMhfOprtMail{ + AckHandle: 100, + AccIndex: 0, + Operation: mhfpacket.OperateMailDelete, + } + + handleMsgMhfOprtMail(session, pkt) + + if mock.markDeletedID != 42 { + t.Errorf("MarkDeleted called with %d, want 42", mock.markDeletedID) + } + + select { + case <-session.sendPackets: + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfOprtMail_Lock(t *testing.T) { + server := createMockServer() + mock := &mockMailRepo{ + mailByID: map[int]*Mail{ + 42: {ID: 42}, + }, + } + server.mailRepo = mock + session := createMockSession(1, server) + session.mailList = make([]int, 256) + session.mailList[0] = 42 + + pkt := &mhfpacket.MsgMhfOprtMail{ + AckHandle: 100, + AccIndex: 0, + Operation: mhfpacket.OperateMailLock, + } + + handleMsgMhfOprtMail(session, pkt) + + if mock.lockID != 42 || !mock.lockValue { + t.Errorf("SetLocked called with ID=%d locked=%v, want ID=42 locked=true", mock.lockID, mock.lockValue) + } +} + +func TestHandleMsgMhfOprtMail_Unlock(t *testing.T) { + server := createMockServer() + mock := &mockMailRepo{ + mailByID: map[int]*Mail{ + 42: {ID: 42}, + }, + } + server.mailRepo = mock + session := createMockSession(1, server) + session.mailList = make([]int, 256) + session.mailList[0] = 42 + + pkt := &mhfpacket.MsgMhfOprtMail{ + AckHandle: 100, + AccIndex: 0, + Operation: mhfpacket.OperateMailUnlock, + } + + handleMsgMhfOprtMail(session, pkt) + + if mock.lockID != 42 || mock.lockValue { + t.Errorf("SetLocked called with ID=%d locked=%v, want ID=42 locked=false", mock.lockID, mock.lockValue) + } +} + +func TestHandleMsgMhfOprtMail_AcquireItem(t *testing.T) { + server := createMockServer() + mock := &mockMailRepo{ + mailByID: map[int]*Mail{ + 42: {ID: 42, AttachedItemID: 100, AttachedItemAmount: 5}, + }, + } + server.mailRepo = mock + session := createMockSession(1, server) + session.mailList = make([]int, 256) + session.mailList[0] = 42 + + pkt := &mhfpacket.MsgMhfOprtMail{ + AckHandle: 100, + AccIndex: 0, + Operation: mhfpacket.OperateMailAcquireItem, + } + + handleMsgMhfOprtMail(session, pkt) + + if mock.itemReceivedID != 42 { + t.Errorf("MarkItemReceived called with %d, want 42", mock.itemReceivedID) + } +} + +func TestHandleMsgMhfOprtMail_OutOfBounds(t *testing.T) { + server := createMockServer() + mock := &mockMailRepo{} + server.mailRepo = mock + session := createMockSession(1, server) + // No mailList set + + pkt := &mhfpacket.MsgMhfOprtMail{ + AckHandle: 100, + AccIndex: 5, + Operation: mhfpacket.OperateMailDelete, + } + + handleMsgMhfOprtMail(session, pkt) + + // Should not have called any repo methods + if mock.markDeletedID != 0 { + t.Error("Should not have called MarkDeleted for out-of-bounds access") + } + + select { + case <-session.sendPackets: + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfSendMail_Direct(t *testing.T) { + server := createMockServer() + mock := &mockMailRepo{} + server.mailRepo = mock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfSendMail{ + AckHandle: 100, + RecipientID: 42, + Subject: "Hello", + Body: "World", + ItemID: 500, + Quantity: 3, + } + + handleMsgMhfSendMail(session, pkt) + + if len(mock.sentMails) != 1 { + t.Fatalf("Expected 1 sent mail, got %d", len(mock.sentMails)) + } + sent := mock.sentMails[0] + if sent.senderID != 1 { + t.Errorf("SenderID = %d, want 1", sent.senderID) + } + if sent.recipientID != 42 { + t.Errorf("RecipientID = %d, want 42", sent.recipientID) + } + if sent.subject != "Hello" { + t.Errorf("Subject = %s, want Hello", sent.subject) + } + if sent.itemID != 500 { + t.Errorf("ItemID = %d, want 500", sent.itemID) + } + + select { + case <-session.sendPackets: + default: + t.Error("No response packet queued") + } +} + +func TestHandleMsgMhfSendMail_Guild(t *testing.T) { + server := createMockServer() + mailMock := &mockMailRepo{} + guildMock := &mockGuildRepoForMail{ + guild: &Guild{ID: 10}, + members: []*GuildMember{ + {CharID: 100}, + {CharID: 200}, + {CharID: 300}, + }, + } + server.mailRepo = mailMock + server.guildRepo = guildMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfSendMail{ + AckHandle: 100, + RecipientID: 0, // 0 = guild mail + Subject: "Guild News", + Body: "Important update", + } + + handleMsgMhfSendMail(session, pkt) + + if len(mailMock.sentMails) != 3 { + t.Fatalf("Expected 3 sent mails (one per guild member), got %d", len(mailMock.sentMails)) + } + for i, sent := range mailMock.sentMails { + if sent.senderID != 1 { + t.Errorf("Mail %d: SenderID = %d, want 1", i, sent.senderID) + } + } + recipients := map[uint32]bool{} + for _, sent := range mailMock.sentMails { + recipients[sent.recipientID] = true + } + if !recipients[100] || !recipients[200] || !recipients[300] { + t.Errorf("Expected recipients 100, 200, 300, got %v", recipients) + } +} + +func TestHandleMsgMhfSendMail_GuildNotFound(t *testing.T) { + server := createMockServer() + mailMock := &mockMailRepo{} + guildMock := &mockGuildRepoForMail{getErr: errNotFound} + server.mailRepo = mailMock + server.guildRepo = guildMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfSendMail{ + AckHandle: 100, + RecipientID: 0, // Guild mail + Subject: "Guild News", + Body: "Update", + } + + handleMsgMhfSendMail(session, pkt) + + if len(mailMock.sentMails) != 0 { + t.Errorf("No mails should be sent when guild not found, got %d", len(mailMock.sentMails)) + } + + select { + case <-session.sendPackets: + default: + t.Error("No response packet queued") + } +} diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go new file mode 100644 index 000000000..a84386347 --- /dev/null +++ b/server/channelserver/repo_mocks_test.go @@ -0,0 +1,324 @@ +package channelserver + +import ( + "database/sql" + "errors" + "time" +) + +// errNotFound is a sentinel for mock repos that simulate "not found". +var errNotFound = errors.New("not found") + +// --- mockAchievementRepo --- + +type mockAchievementRepo struct { + scores [33]int32 + ensureCalled bool + ensureErr error + getScoresErr error + incrementErr error + incrementedID uint8 +} + +func (m *mockAchievementRepo) EnsureExists(_ uint32) error { + m.ensureCalled = true + return m.ensureErr +} + +func (m *mockAchievementRepo) GetAllScores(_ uint32) ([33]int32, error) { + return m.scores, m.getScoresErr +} + +func (m *mockAchievementRepo) IncrementScore(_ uint32, id uint8) error { + m.incrementedID = id + return m.incrementErr +} + +// --- mockMailRepo --- + +type mockMailRepo struct { + mails []Mail + mailByID map[int]*Mail + listErr error + getByIDErr error + markReadCalled int + markDeletedID int + lockID int + lockValue bool + itemReceivedID int + sentMails []sentMailRecord + sendErr error +} + +type sentMailRecord struct { + senderID, recipientID uint32 + subject, body string + itemID, itemAmount uint16 + isGuildInvite, isSystemMessage bool +} + +func (m *mockMailRepo) GetListForCharacter(_ uint32) ([]Mail, error) { + return m.mails, m.listErr +} + +func (m *mockMailRepo) GetByID(id int) (*Mail, error) { + if m.getByIDErr != nil { + return nil, m.getByIDErr + } + if mail, ok := m.mailByID[id]; ok { + return mail, nil + } + return nil, errNotFound +} + +func (m *mockMailRepo) MarkRead(id int) error { + m.markReadCalled = id + return nil +} + +func (m *mockMailRepo) MarkDeleted(id int) error { + m.markDeletedID = id + return nil +} + +func (m *mockMailRepo) SetLocked(id int, locked bool) error { + m.lockID = id + m.lockValue = locked + return nil +} + +func (m *mockMailRepo) MarkItemReceived(id int) error { + m.itemReceivedID = id + return nil +} + +func (m *mockMailRepo) SendMail(senderID, recipientID uint32, subject, body string, itemID, itemAmount uint16, isGuildInvite, isSystemMessage bool) error { + m.sentMails = append(m.sentMails, sentMailRecord{ + senderID: senderID, recipientID: recipientID, + subject: subject, body: body, + itemID: itemID, itemAmount: itemAmount, + isGuildInvite: isGuildInvite, isSystemMessage: isSystemMessage, + }) + return m.sendErr +} + +func (m *mockMailRepo) SendMailTx(_ *sql.Tx, senderID, recipientID uint32, subject, body string, itemID, itemAmount uint16, isGuildInvite, isSystemMessage bool) error { + return m.SendMail(senderID, recipientID, subject, body, itemID, itemAmount, isGuildInvite, isSystemMessage) +} + +// --- mockCharacterRepo --- + +type mockCharacterRepo struct { + ints map[string]int + times map[string]time.Time + columns map[string][]byte + strings map[string]string + bools map[string]bool + + adjustErr error + readErr error + saveErr error +} + +func newMockCharacterRepo() *mockCharacterRepo { + return &mockCharacterRepo{ + ints: make(map[string]int), + times: make(map[string]time.Time), + columns: make(map[string][]byte), + strings: make(map[string]string), + bools: make(map[string]bool), + } +} + +func (m *mockCharacterRepo) ReadInt(_ uint32, column string) (int, error) { + if m.readErr != nil { + return 0, m.readErr + } + return m.ints[column], nil +} + +func (m *mockCharacterRepo) AdjustInt(_ uint32, column string, delta int) (int, error) { + if m.adjustErr != nil { + return 0, m.adjustErr + } + m.ints[column] += delta + return m.ints[column], nil +} + +func (m *mockCharacterRepo) SaveInt(_ uint32, column string, value int) error { + m.ints[column] = value + return m.saveErr +} + +func (m *mockCharacterRepo) ReadTime(_ uint32, column string, defaultVal time.Time) (time.Time, error) { + if m.readErr != nil { + return defaultVal, m.readErr + } + if t, ok := m.times[column]; ok { + return t, nil + } + return defaultVal, errNotFound +} + +func (m *mockCharacterRepo) SaveTime(_ uint32, column string, value time.Time) error { + m.times[column] = value + return m.saveErr +} + +func (m *mockCharacterRepo) LoadColumn(_ uint32, column string) ([]byte, error) { return m.columns[column], nil } +func (m *mockCharacterRepo) SaveColumn(_ uint32, column string, data []byte) error { m.columns[column] = data; return m.saveErr } +func (m *mockCharacterRepo) GetName(_ uint32) (string, error) { return "TestChar", nil } +func (m *mockCharacterRepo) GetUserID(_ uint32) (uint32, error) { return 1, nil } +func (m *mockCharacterRepo) UpdateLastLogin(_ uint32, _ int64) error { return nil } +func (m *mockCharacterRepo) UpdateTimePlayed(_ uint32, _ int) error { return nil } +func (m *mockCharacterRepo) GetCharIDsByUserID(_ uint32) ([]uint32, error) { return nil, nil } +func (m *mockCharacterRepo) SaveBool(_ uint32, col string, v bool) error { m.bools[col] = v; return nil } +func (m *mockCharacterRepo) SaveString(_ uint32, col string, v string) error { m.strings[col] = v; return nil } +func (m *mockCharacterRepo) ReadBool(_ uint32, col string) (bool, error) { return m.bools[col], nil } +func (m *mockCharacterRepo) ReadString(_ uint32, col string) (string, error) { return m.strings[col], nil } +func (m *mockCharacterRepo) LoadColumnWithDefault(_ uint32, col string, def []byte) ([]byte, error) { + if d, ok := m.columns[col]; ok { + return d, nil + } + return def, nil +} +func (m *mockCharacterRepo) SetDeleted(_ uint32) error { return nil } +func (m *mockCharacterRepo) UpdateDailyCafe(_ uint32, _ time.Time, _, _ uint32) error { return nil } +func (m *mockCharacterRepo) ResetDailyQuests(_ uint32) error { return nil } +func (m *mockCharacterRepo) ReadEtcPoints(_ uint32) (uint32, uint32, uint32, error) { return 0, 0, 0, nil } +func (m *mockCharacterRepo) ResetCafeTime(_ uint32, _ time.Time) error { return nil } +func (m *mockCharacterRepo) UpdateGuildPostChecked(_ uint32) error { return nil } +func (m *mockCharacterRepo) ReadGuildPostChecked(_ uint32) (time.Time, error) { return time.Time{}, nil } +func (m *mockCharacterRepo) SaveMercenary(_ uint32, _ []byte, _ uint32) error { return nil } +func (m *mockCharacterRepo) UpdateGCPAndPact(_ uint32, _ uint32, _ uint32) error { return nil } +func (m *mockCharacterRepo) FindByRastaID(_ int) (uint32, string, error) { return 0, "", nil } +func (m *mockCharacterRepo) SaveCharacterData(_ uint32, _ []byte, _, _ uint16, _ bool, _ uint8, _ uint16) error { return nil } +func (m *mockCharacterRepo) SaveHouseData(_ uint32, _ []byte, _, _, _, _, _ []byte) error { return nil } + +// --- mockGoocooRepo --- + +type mockGoocooRepo struct { + slots map[uint32][]byte + ensureCalled bool + clearCalled []uint32 + savedSlots map[uint32][]byte +} + +func newMockGoocooRepo() *mockGoocooRepo { + return &mockGoocooRepo{ + slots: make(map[uint32][]byte), + savedSlots: make(map[uint32][]byte), + } +} + +func (m *mockGoocooRepo) EnsureExists(_ uint32) error { + m.ensureCalled = true + return nil +} + +func (m *mockGoocooRepo) GetSlot(_ uint32, slot uint32) ([]byte, error) { + if data, ok := m.slots[slot]; ok { + return data, nil + } + return nil, nil +} + +func (m *mockGoocooRepo) ClearSlot(_ uint32, slot uint32) error { + m.clearCalled = append(m.clearCalled, slot) + delete(m.slots, slot) + return nil +} + +func (m *mockGoocooRepo) SaveSlot(_ uint32, slot uint32, data []byte) error { + m.savedSlots[slot] = data + return nil +} + +// --- mockGuildRepo (minimal, for SendMail guild path) --- + +type mockGuildRepoForMail struct { + guild *Guild + members []*GuildMember + getErr error +} + +func (m *mockGuildRepoForMail) GetByCharID(_ uint32) (*Guild, error) { + if m.getErr != nil { + return nil, m.getErr + } + return m.guild, nil +} + +func (m *mockGuildRepoForMail) GetMembers(_ uint32, _ bool) ([]*GuildMember, error) { + return m.members, nil +} + +// Stub out all other GuildRepo methods. +func (m *mockGuildRepoForMail) GetByID(_ uint32) (*Guild, error) { return nil, errNotFound } +func (m *mockGuildRepoForMail) ListAll() ([]*Guild, error) { return nil, nil } +func (m *mockGuildRepoForMail) Create(_ uint32, _ string) (int32, error) { return 0, nil } +func (m *mockGuildRepoForMail) Save(_ *Guild) error { return nil } +func (m *mockGuildRepoForMail) Disband(_ uint32) error { return nil } +func (m *mockGuildRepoForMail) RemoveCharacter(_ uint32) error { return nil } +func (m *mockGuildRepoForMail) AcceptApplication(_, _ uint32) error { return nil } +func (m *mockGuildRepoForMail) CreateApplication(_, _, _ uint32, _ GuildApplicationType, _ *sql.Tx) error { + return nil +} +func (m *mockGuildRepoForMail) CancelInvitation(_, _ uint32) error { return nil } +func (m *mockGuildRepoForMail) RejectApplication(_, _ uint32) error { return nil } +func (m *mockGuildRepoForMail) ArrangeCharacters(_ []uint32) error { return nil } +func (m *mockGuildRepoForMail) GetApplication(_, _ uint32, _ GuildApplicationType) (*GuildApplication, error) { + return nil, nil +} +func (m *mockGuildRepoForMail) HasApplication(_, _ uint32) (bool, error) { return false, nil } +func (m *mockGuildRepoForMail) GetItemBox(_ uint32) ([]byte, error) { return nil, nil } +func (m *mockGuildRepoForMail) SaveItemBox(_ uint32, _ []byte) error { return nil } +func (m *mockGuildRepoForMail) GetCharacterMembership(_ uint32) (*GuildMember, error) { return nil, nil } +func (m *mockGuildRepoForMail) SaveMember(_ *GuildMember) error { return nil } +func (m *mockGuildRepoForMail) SetRecruiting(_ uint32, _ bool) error { return nil } +func (m *mockGuildRepoForMail) SetPugiOutfits(_ uint32, _ uint32) error { return nil } +func (m *mockGuildRepoForMail) SetRecruiter(_ uint32, _ bool) error { return nil } +func (m *mockGuildRepoForMail) AddMemberDailyRP(_ uint32, _ uint16) error { return nil } +func (m *mockGuildRepoForMail) ExchangeEventRP(_ uint32, _ uint16) (uint32, error) { return 0, nil } +func (m *mockGuildRepoForMail) AddRankRP(_ uint32, _ uint16) error { return nil } +func (m *mockGuildRepoForMail) AddEventRP(_ uint32, _ uint16) error { return nil } +func (m *mockGuildRepoForMail) GetRoomRP(_ uint32) (uint16, error) { return 0, nil } +func (m *mockGuildRepoForMail) SetRoomRP(_ uint32, _ uint16) error { return nil } +func (m *mockGuildRepoForMail) AddRoomRP(_ uint32, _ uint16) error { return nil } +func (m *mockGuildRepoForMail) SetRoomExpiry(_ uint32, _ time.Time) error { return nil } +func (m *mockGuildRepoForMail) ListPosts(_ uint32, _ int) ([]*MessageBoardPost, error) { return nil, nil } +func (m *mockGuildRepoForMail) CreatePost(_, _, _ uint32, _ int, _, _ string, _ int) error { return nil } +func (m *mockGuildRepoForMail) DeletePost(_ uint32) error { return nil } +func (m *mockGuildRepoForMail) UpdatePost(_ uint32, _, _ string) error { return nil } +func (m *mockGuildRepoForMail) UpdatePostStamp(_, _ uint32) error { return nil } +func (m *mockGuildRepoForMail) GetPostLikedBy(_ uint32) (string, error) { return "", nil } +func (m *mockGuildRepoForMail) SetPostLikedBy(_ uint32, _ string) error { return nil } +func (m *mockGuildRepoForMail) CountNewPosts(_ uint32, _ time.Time) (int, error) { return 0, nil } +func (m *mockGuildRepoForMail) GetAllianceByID(_ uint32) (*GuildAlliance, error) { return nil, nil } +func (m *mockGuildRepoForMail) ListAlliances() ([]*GuildAlliance, error) { return nil, nil } +func (m *mockGuildRepoForMail) CreateAlliance(_ string, _ uint32) error { return nil } +func (m *mockGuildRepoForMail) DeleteAlliance(_ uint32) error { return nil } +func (m *mockGuildRepoForMail) RemoveGuildFromAlliance(_, _, _, _ uint32) error { return nil } +func (m *mockGuildRepoForMail) ListAdventures(_ uint32) ([]*GuildAdventure, error) { return nil, nil } +func (m *mockGuildRepoForMail) CreateAdventure(_, _ uint32, _, _ int64) error { return nil } +func (m *mockGuildRepoForMail) CreateAdventureWithCharge(_, _, _ uint32, _, _ int64) error { return nil } +func (m *mockGuildRepoForMail) CollectAdventure(_ uint32, _ uint32) error { return nil } +func (m *mockGuildRepoForMail) ChargeAdventure(_ uint32, _ uint32) error { return nil } +func (m *mockGuildRepoForMail) GetPendingHunt(_ uint32) (*TreasureHunt, error) { return nil, nil } +func (m *mockGuildRepoForMail) ListGuildHunts(_, _ uint32) ([]*TreasureHunt, error) { return nil, nil } +func (m *mockGuildRepoForMail) CreateHunt(_, _, _, _ uint32, _ []byte, _ string) error { return nil } +func (m *mockGuildRepoForMail) AcquireHunt(_ uint32) error { return nil } +func (m *mockGuildRepoForMail) RegisterHuntReport(_, _ uint32) error { return nil } +func (m *mockGuildRepoForMail) CollectHunt(_ uint32) error { return nil } +func (m *mockGuildRepoForMail) ClaimHuntReward(_, _ uint32) error { return nil } +func (m *mockGuildRepoForMail) ListMeals(_ uint32) ([]*GuildMeal, error) { return nil, nil } +func (m *mockGuildRepoForMail) CreateMeal(_, _, _ uint32, _ time.Time) (uint32, error) { return 0, nil } +func (m *mockGuildRepoForMail) UpdateMeal(_, _, _ uint32, _ time.Time) error { return nil } +func (m *mockGuildRepoForMail) ClaimHuntBox(_ uint32, _ time.Time) error { return nil } +func (m *mockGuildRepoForMail) ListGuildKills(_, _ uint32) ([]*GuildKill, error) { return nil, nil } +func (m *mockGuildRepoForMail) CountGuildKills(_, _ uint32) (int, error) { return 0, nil } +func (m *mockGuildRepoForMail) ClearTreasureHunt(_ uint32) error { return nil } +func (m *mockGuildRepoForMail) InsertKillLog(_ uint32, _ int, _ uint8, _ time.Time) error { return nil } +func (m *mockGuildRepoForMail) ListInvitedCharacters(_ uint32) ([]*ScoutedCharacter, error) { return nil, nil } +func (m *mockGuildRepoForMail) RolloverDailyRP(_ uint32, _ time.Time) error { return nil } +func (m *mockGuildRepoForMail) AddWeeklyBonusUsers(_ uint32, _ uint8) error { return nil }