Files
Erupe/server/channelserver/handlers_character_test.go
Houmgaor 74798fc8b3 fix(channelserver): return error from Save() to prevent misleading success logs
CharacterSaveData.Save() silently returned on failure (nil decompressed
data, compression error, DB error) while the caller unconditionally
logged "Saved character data successfully". This made diagnosing save
failures difficult (ref #163).

Save() now returns an error, and all six call sites check it. The
success log in saveAllCharacterData only fires when the save actually
persisted.
2026-02-27 11:21:37 +01:00

750 lines
17 KiB
Go

package channelserver
import (
"bytes"
"database/sql"
"encoding/binary"
"errors"
"testing"
cfg "erupe-ce/config"
"erupe-ce/network/mhfpacket"
"erupe-ce/server/channelserver/compression/nullcomp"
)
// TestGetPointers tests the pointer map generation for different game versions
func TestGetPointers(t *testing.T) {
tests := []struct {
name string
clientMode cfg.Mode
wantGender int
wantHR int
}{
{
name: "ZZ_version",
clientMode: cfg.ZZ,
wantGender: 81,
wantHR: 130550,
},
{
name: "Z2_version",
clientMode: cfg.Z2,
wantGender: 81,
wantHR: 94550,
},
{
name: "G10_version",
clientMode: cfg.G10,
wantGender: 81,
wantHR: 94550,
},
{
name: "F5_version",
clientMode: cfg.F5,
wantGender: 81,
wantHR: 62550,
},
{
name: "S6_version",
clientMode: cfg.S6,
wantGender: 81,
wantHR: 14550,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pointers := getPointers(tt.clientMode)
if pointers[pGender] != tt.wantGender {
t.Errorf("pGender = %d, want %d", pointers[pGender], tt.wantGender)
}
if pointers[pHR] != tt.wantHR {
t.Errorf("pHR = %d, want %d", pointers[pHR], tt.wantHR)
}
// Verify all required pointers exist
requiredPointers := []SavePointer{pGender, pRP, pHouseTier, pHouseData, pBookshelfData,
pGalleryData, pToreData, pGardenData, pPlaytime, pWeaponType, pWeaponID, pHR, lBookshelfData}
for _, ptr := range requiredPointers {
if _, exists := pointers[ptr]; !exists {
t.Errorf("pointer %v not found in map", ptr)
}
}
})
}
}
// TestCharacterSaveData_Compress tests savedata compression
func TestCharacterSaveData_Compress(t *testing.T) {
tests := []struct {
name string
data []byte
wantErr bool
}{
{
name: "valid_small_data",
data: []byte{0x01, 0x02, 0x03, 0x04},
wantErr: false,
},
{
name: "valid_large_data",
data: bytes.Repeat([]byte{0xAA}, 10000),
wantErr: false,
},
{
name: "empty_data",
data: []byte{},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
save := &CharacterSaveData{
decompSave: tt.data,
}
err := save.Compress()
if (err != nil) != tt.wantErr {
t.Errorf("Compress() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && len(save.compSave) == 0 {
t.Error("compressed save is empty")
}
})
}
}
// TestCharacterSaveData_Decompress tests savedata decompression
func TestCharacterSaveData_Decompress(t *testing.T) {
tests := []struct {
name string
setup func() []byte
wantErr bool
}{
{
name: "valid_compressed_data",
setup: func() []byte {
data := []byte{0x01, 0x02, 0x03, 0x04}
compressed, _ := nullcomp.Compress(data)
return compressed
},
wantErr: false,
},
{
name: "valid_large_compressed_data",
setup: func() []byte {
data := bytes.Repeat([]byte{0xBB}, 5000)
compressed, _ := nullcomp.Compress(data)
return compressed
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
save := &CharacterSaveData{
compSave: tt.setup(),
}
err := save.Decompress()
if (err != nil) != tt.wantErr {
t.Errorf("Decompress() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && len(save.decompSave) == 0 {
t.Error("decompressed save is empty")
}
})
}
}
// TestCharacterSaveData_RoundTrip tests compression and decompression
func TestCharacterSaveData_RoundTrip(t *testing.T) {
tests := []struct {
name string
data []byte
}{
{
name: "small_data",
data: []byte{0x01, 0x02, 0x03, 0x04, 0x05},
},
{
name: "repeating_pattern",
data: bytes.Repeat([]byte{0xCC}, 1000),
},
{
name: "mixed_data",
data: []byte{0x00, 0xFF, 0x01, 0xFE, 0x02, 0xFD, 0x03, 0xFC},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
save := &CharacterSaveData{
decompSave: tt.data,
}
// Compress
if err := save.Compress(); err != nil {
t.Fatalf("Compress() failed: %v", err)
}
// Clear decompressed data
save.decompSave = nil
// Decompress
if err := save.Decompress(); err != nil {
t.Fatalf("Decompress() failed: %v", err)
}
// Verify round trip
if !bytes.Equal(save.decompSave, tt.data) {
t.Errorf("round trip failed: got %v, want %v", save.decompSave, tt.data)
}
})
}
}
// TestCharacterSaveData_updateStructWithSaveData tests parsing save data
func TestCharacterSaveData_updateStructWithSaveData(t *testing.T) {
tests := []struct {
name string
isNewCharacter bool
setupSaveData func() []byte
wantName string
wantGender bool
}{
{
name: "male_character",
isNewCharacter: false,
setupSaveData: func() []byte {
data := make([]byte, 150000)
copy(data[88:], []byte("TestChar\x00"))
data[81] = 0 // Male
return data
},
wantName: "TestChar",
wantGender: false,
},
{
name: "female_character",
isNewCharacter: false,
setupSaveData: func() []byte {
data := make([]byte, 150000)
copy(data[88:], []byte("FemaleChar\x00"))
data[81] = 1 // Female
return data
},
wantName: "FemaleChar",
wantGender: true,
},
{
name: "new_character_skips_parsing",
isNewCharacter: true,
setupSaveData: func() []byte {
data := make([]byte, 150000)
copy(data[88:], []byte("NewChar\x00"))
return data
},
wantName: "NewChar",
wantGender: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
save := &CharacterSaveData{
Mode: cfg.Z2,
Pointers: getPointers(cfg.Z2),
decompSave: tt.setupSaveData(),
IsNewCharacter: tt.isNewCharacter,
}
save.updateStructWithSaveData()
if save.Name != tt.wantName {
t.Errorf("Name = %q, want %q", save.Name, tt.wantName)
}
if save.Gender != tt.wantGender {
t.Errorf("Gender = %v, want %v", save.Gender, tt.wantGender)
}
})
}
}
// TestCharacterSaveData_updateSaveDataWithStruct tests writing struct to save data
func TestCharacterSaveData_updateSaveDataWithStruct(t *testing.T) {
tests := []struct {
name string
rp uint16
kqf []byte
wantRP uint16
}{
{
name: "update_rp_value",
rp: 1234,
kqf: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08},
wantRP: 1234,
},
{
name: "zero_rp_value",
rp: 0,
kqf: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
wantRP: 0,
},
{
name: "max_rp_value",
rp: 65535,
kqf: []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF},
wantRP: 65535,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
save := &CharacterSaveData{
Mode: cfg.G10,
Pointers: getPointers(cfg.G10),
decompSave: make([]byte, 150000),
RP: tt.rp,
KQF: tt.kqf,
}
save.updateSaveDataWithStruct()
// Verify RP was written correctly
rpOffset := save.Pointers[pRP]
gotRP := binary.LittleEndian.Uint16(save.decompSave[rpOffset : rpOffset+2])
if gotRP != tt.wantRP {
t.Errorf("RP in save data = %d, want %d", gotRP, tt.wantRP)
}
// Verify KQF was written correctly
kqfOffset := save.Pointers[pKQF]
gotKQF := save.decompSave[kqfOffset : kqfOffset+8]
if !bytes.Equal(gotKQF, tt.kqf) {
t.Errorf("KQF in save data = %v, want %v", gotKQF, tt.kqf)
}
})
}
}
// TestHandleMsgMhfSexChanger tests the sex changer handler
func TestHandleMsgMhfSexChanger(t *testing.T) {
tests := []struct {
name string
ackHandle uint32
}{
{
name: "basic_sex_change",
ackHandle: 1234,
},
{
name: "different_ack_handle",
ackHandle: 9999,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock)
pkt := &mhfpacket.MsgMhfSexChanger{
AckHandle: tt.ackHandle,
}
handleMsgMhfSexChanger(s, pkt)
// Verify ACK was sent
if len(s.sendPackets) == 0 {
t.Fatal("no ACK packet was sent")
}
// Drain the channel
<-s.sendPackets
})
}
}
// TestGetCharacterSaveData_Integration tests retrieving character save data from database
func TestGetCharacterSaveData_Integration(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
tests := []struct {
name string
charName string
isNewCharacter bool
wantError bool
}{
{
name: "existing_character",
charName: "TestChar",
isNewCharacter: false,
wantError: false,
},
{
name: "new_character",
charName: "NewChar",
isNewCharacter: true,
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test user and character
userID := CreateTestUser(t, db, "testuser_"+tt.name)
charID := CreateTestCharacter(t, db, userID, tt.charName)
// Update is_new_character flag
_, err := db.Exec("UPDATE characters SET is_new_character = $1 WHERE id = $2", tt.isNewCharacter, charID)
if err != nil {
t.Fatalf("Failed to update character: %v", err)
}
// Create test session
mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock)
s.charID = charID
SetTestDB(s.server, db)
s.server.erupeConfig.RealClientMode = cfg.Z2
// Get character save data
saveData, err := GetCharacterSaveData(s, charID)
if (err != nil) != tt.wantError {
t.Errorf("GetCharacterSaveData() error = %v, wantErr %v", err, tt.wantError)
return
}
if !tt.wantError {
if saveData == nil {
t.Fatal("saveData is nil")
}
if saveData.CharID != charID {
t.Errorf("CharID = %d, want %d", saveData.CharID, charID)
}
if saveData.Name != tt.charName {
t.Errorf("Name = %q, want %q", saveData.Name, tt.charName)
}
if saveData.IsNewCharacter != tt.isNewCharacter {
t.Errorf("IsNewCharacter = %v, want %v", saveData.IsNewCharacter, tt.isNewCharacter)
}
}
})
}
}
// TestCharacterSaveData_Save_Integration tests saving character data to database
func TestCharacterSaveData_Save_Integration(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
// Create test user and character
userID := CreateTestUser(t, db, "savetest")
charID := CreateTestCharacter(t, db, userID, "SaveChar")
// Create test session
mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
s := createTestSession(mock)
s.charID = charID
SetTestDB(s.server, db)
s.server.erupeConfig.RealClientMode = cfg.Z2
// Load character save data
saveData, err := GetCharacterSaveData(s, charID)
if err != nil {
t.Fatalf("Failed to get save data: %v", err)
}
// Modify save data
saveData.HR = 999
saveData.GR = 100
saveData.Gender = true
saveData.WeaponType = 5
saveData.WeaponID = 1234
// Save it
if err := saveData.Save(s); err != nil {
t.Fatalf("Save failed: %v", err)
}
// Reload and verify
var hr, gr uint16
var gender bool
var weaponType uint8
var weaponID uint16
err = db.QueryRow("SELECT hr, gr, is_female, weapon_type, weapon_id FROM characters WHERE id = $1",
charID).Scan(&hr, &gr, &gender, &weaponType, &weaponID)
if err != nil {
t.Fatalf("Failed to query updated character: %v", err)
}
if hr != 999 {
t.Errorf("HR = %d, want 999", hr)
}
if gr != 100 {
t.Errorf("GR = %d, want 100", gr)
}
if !gender {
t.Error("Gender should be true (female)")
}
if weaponType != 5 {
t.Errorf("WeaponType = %d, want 5", weaponType)
}
if weaponID != 1234 {
t.Errorf("WeaponID = %d, want 1234", weaponID)
}
}
// TestGRPtoGR tests the GRP to GR conversion function
func TestGRPtoGR(t *testing.T) {
tests := []struct {
name string
grp int
wantGR uint16
}{
{
name: "zero_grp",
grp: 0,
wantGR: 1, // Function returns 1 for 0 GRP
},
{
name: "low_grp",
grp: 10000,
wantGR: 10, // Function returns 10 for 10000 GRP
},
{
name: "mid_grp",
grp: 500000,
wantGR: 88, // Function returns 88 for 500000 GRP
},
{
name: "high_grp",
grp: 2000000,
wantGR: 265, // Function returns 265 for 2000000 GRP
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotGR := grpToGR(tt.grp)
if gotGR != tt.wantGR {
t.Errorf("grpToGR(%d) = %d, want %d", tt.grp, gotGR, tt.wantGR)
}
})
}
}
// BenchmarkCompress benchmarks savedata compression
func BenchmarkCompress(b *testing.B) {
data := bytes.Repeat([]byte{0xAA, 0xBB, 0xCC, 0xDD}, 25000) // 100KB
save := &CharacterSaveData{
decompSave: data,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = save.Compress()
}
}
// BenchmarkDecompress benchmarks savedata decompression
func BenchmarkDecompress(b *testing.B) {
data := bytes.Repeat([]byte{0xAA, 0xBB, 0xCC, 0xDD}, 25000)
compressed, _ := nullcomp.Compress(data)
save := &CharacterSaveData{
compSave: compressed,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = save.Decompress()
}
}
// --- Mock-based GetCharacterSaveData tests ---
func TestGetCharacterSaveData_NilSavedata(t *testing.T) {
server := createMockServer()
mock := newMockCharacterRepo()
mock.loadSaveDataID = 42
mock.loadSaveDataName = "Hunter"
mock.loadSaveDataNew = true
server.charRepo = mock
session := createMockSession(42, server)
result, err := GetCharacterSaveData(session, 42)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.CharID != 42 {
t.Errorf("CharID = %d, want 42", result.CharID)
}
if result.Name != "Hunter" {
t.Errorf("Name = %q, want %q", result.Name, "Hunter")
}
if !result.IsNewCharacter {
t.Error("IsNewCharacter should be true")
}
}
func TestGetCharacterSaveData_NotFound(t *testing.T) {
server := createMockServer()
mock := newMockCharacterRepo()
mock.loadSaveDataErr = sql.ErrNoRows
server.charRepo = mock
session := createMockSession(1, server)
result, err := GetCharacterSaveData(session, 999)
if err == nil {
t.Fatal("expected error for missing character")
}
if result != nil {
t.Error("expected nil result for missing character")
}
}
func TestGetCharacterSaveData_DBError(t *testing.T) {
server := createMockServer()
mock := newMockCharacterRepo()
mock.loadSaveDataErr = errors.New("connection refused")
server.charRepo = mock
session := createMockSession(1, server)
result, err := GetCharacterSaveData(session, 1)
if err == nil {
t.Fatal("expected error on DB failure")
}
if result != nil {
t.Error("expected nil result on DB failure")
}
}
func TestGetCharacterSaveData_WithCompressedData(t *testing.T) {
server := createMockServer()
mock := newMockCharacterRepo()
// Create minimal valid savedata and compress it
saveData := make([]byte, 150000)
copy(saveData[88:], append([]byte("TestHunter"), 0x00))
compressed, err := nullcomp.Compress(saveData)
if err != nil {
t.Fatalf("failed to compress test savedata: %v", err)
}
mock.loadSaveDataID = 10
mock.loadSaveDataData = compressed
mock.loadSaveDataName = "TestHunter"
mock.loadSaveDataNew = false
server.charRepo = mock
session := createMockSession(10, server)
result, err := GetCharacterSaveData(session, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.CharID != 10 {
t.Errorf("CharID = %d, want 10", result.CharID)
}
if result.IsNewCharacter {
t.Error("IsNewCharacter should be false")
}
if result.Name != "TestHunter" {
t.Errorf("Name = %q, want %q", result.Name, "TestHunter")
}
}
func TestGetCharacterSaveData_NewCharacterSkipsDecompress(t *testing.T) {
// When savedata is nil AND IsNewCharacter=true, GetCharacterSaveData
// returns a valid result without decompressing.
server := createMockServer()
mock := newMockCharacterRepo()
mock.loadSaveDataID = 5
mock.loadSaveDataName = "NewPlayer"
mock.loadSaveDataNew = true
// loadSaveDataData is nil
server.charRepo = mock
session := createMockSession(5, server)
result, err := GetCharacterSaveData(session, 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if !result.IsNewCharacter {
t.Error("IsNewCharacter should be true")
}
if result.CharID != 5 {
t.Errorf("CharID = %d, want 5", result.CharID)
}
}
func TestGetCharacterSaveData_ConfigMode(t *testing.T) {
server := createMockServer()
mock := newMockCharacterRepo()
saveData := make([]byte, 150000)
copy(saveData[88:], append([]byte("ModeTest"), 0x00))
compressed, err := nullcomp.Compress(saveData)
if err != nil {
t.Fatalf("failed to compress: %v", err)
}
mock.loadSaveDataID = 1
mock.loadSaveDataData = compressed
mock.loadSaveDataName = "ModeTest"
server.charRepo = mock
modes := []struct {
mode cfg.Mode
name string
}{
{cfg.S6, "S6"},
{cfg.F5, "F5"},
{cfg.G10, "G10"},
{cfg.Z2, "Z2"},
{cfg.ZZ, "ZZ"},
}
for _, tc := range modes {
mode := tc.mode
t.Run(tc.name, func(t *testing.T) {
server.erupeConfig.RealClientMode = mode
session := createMockSession(1, server)
result, err := GetCharacterSaveData(session, 1)
if err != nil {
t.Fatalf("unexpected error for mode %v: %v", mode, err)
}
if result.Mode != mode {
t.Errorf("Mode = %v, want %v", result.Mode, mode)
}
expectedPointers := getPointers(mode)
if len(result.Pointers) != len(expectedPointers) {
t.Errorf("Pointers count = %d, want %d", len(result.Pointers), len(expectedPointers))
}
})
}
}