Files
Erupe/server/channelserver/session_lifecycle_integration_test.go
Houmgaor b507057cc9 refactor(channelserver): extract FestaRepository and TowerRepository
Move all direct DB calls from handlers_festa.go (23 calls across 8
tables) and handlers_tower.go (16 calls across 4 tables) into
dedicated repository structs following the established pattern.

FestaRepository (14 methods): lifecycle cleanup, event management,
team souls, trial stats/rankings, user state, voting, registration,
soul submission, prize claiming/enumeration.

TowerRepository (12 methods): personal tower data (skills, progress,
gems), guild tenrouirai progress/scores/page advancement, tower RP.

Also fix pre-existing nil pointer panics in integration tests by
adding SetTestDB helper that initializes both the DB connection and
all repositories, and wire the done channel in createTestServerWithDB
to prevent Shutdown panics.
2026-02-20 23:09:51 +01:00

638 lines
19 KiB
Go

package channelserver
import (
"bytes"
"net"
"testing"
"time"
_config "erupe-ce/config"
"erupe-ce/common/mhfitem"
"erupe-ce/network/clientctx"
"erupe-ce/network/mhfpacket"
"erupe-ce/server/channelserver/compression/nullcomp"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
)
// ============================================================================
// SESSION LIFECYCLE INTEGRATION TESTS
// Full end-to-end tests that simulate the complete player session lifecycle
//
// These tests address the core issue: handler-level tests don't catch problems
// with the logout flow. Players report data loss because logout doesn't
// trigger save handlers.
//
// Test Strategy:
// 1. Create a real session (not just call handlers directly)
// 2. Modify game data through packets
// 3. Trigger actual logout event (not just call handlers)
// 4. Create new session for the same character
// 5. Verify all data persists correctly
// ============================================================================
// TestSessionLifecycle_BasicSaveLoadCycle tests the complete session lifecycle
// This is the minimal reproduction case for player-reported data loss
func TestSessionLifecycle_BasicSaveLoadCycle(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
server := createTestServerWithDB(t, db)
defer server.Shutdown()
// Create test user and character
userID := CreateTestUser(t, db, "lifecycle_test_user")
charID := CreateTestCharacter(t, db, userID, "LifecycleChar")
t.Logf("Created character ID %d for lifecycle test", charID)
// ===== SESSION 1: Login, modify data, logout =====
t.Log("--- Starting Session 1: Login and modify data ---")
session1 := createTestSessionForServerWithChar(server, charID, "LifecycleChar")
// Note: Not calling Start() since we're testing handlers directly, not packet processing
// Modify data via packet handlers
initialPoints := uint32(5000)
_, err := db.Exec("UPDATE characters SET frontier_points = $1 WHERE id = $2", initialPoints, charID)
if err != nil {
t.Fatalf("Failed to set initial road points: %v", err)
}
// Save main savedata through packet
saveData := make([]byte, 150000)
copy(saveData[88:], []byte("LifecycleChar\x00"))
// Add some identifiable data at offset 1000
saveData[1000] = 0xDE
saveData[1001] = 0xAD
saveData[1002] = 0xBE
saveData[1003] = 0xEF
compressed, err := nullcomp.Compress(saveData)
if err != nil {
t.Fatalf("Failed to compress savedata: %v", err)
}
savePkt := &mhfpacket.MsgMhfSavedata{
SaveType: 0,
AckHandle: 1001,
AllocMemSize: uint32(len(compressed)),
DataSize: uint32(len(compressed)),
RawDataPayload: compressed,
}
t.Log("Sending savedata packet")
handleMsgMhfSavedata(session1, savePkt)
// Drain ACK
time.Sleep(100 * time.Millisecond)
// Now trigger logout via the actual logout flow
t.Log("Triggering logout via logoutPlayer")
logoutPlayer(session1)
// Give logout time to complete
time.Sleep(100 * time.Millisecond)
// ===== SESSION 2: Login again and verify data =====
t.Log("--- Starting Session 2: Login and verify data persists ---")
session2 := createTestSessionForServerWithChar(server, charID, "LifecycleChar")
// Note: Not calling Start() since we're testing handlers directly
// Load character data
loadPkt := &mhfpacket.MsgMhfLoaddata{
AckHandle: 2001,
}
handleMsgMhfLoaddata(session2, loadPkt)
time.Sleep(50 * time.Millisecond)
// Verify savedata persisted
var savedCompressed []byte
err = db.QueryRow("SELECT savedata FROM characters WHERE id = $1", charID).Scan(&savedCompressed)
if err != nil {
t.Fatalf("Failed to load savedata after session: %v", err)
}
if len(savedCompressed) == 0 {
t.Error("❌ CRITICAL: Savedata not persisted across logout/login cycle")
return
}
// Decompress and verify
decompressed, err := nullcomp.Decompress(savedCompressed)
if err != nil {
t.Errorf("Failed to decompress savedata: %v", err)
return
}
// Check our marker bytes
if len(decompressed) > 1003 {
if decompressed[1000] != 0xDE || decompressed[1001] != 0xAD ||
decompressed[1002] != 0xBE || decompressed[1003] != 0xEF {
t.Error("❌ CRITICAL: Savedata contents corrupted or not saved correctly")
t.Errorf("Expected [DE AD BE EF] at offset 1000, got [%02X %02X %02X %02X]",
decompressed[1000], decompressed[1001], decompressed[1002], decompressed[1003])
} else {
t.Log("✓ Savedata persisted correctly across logout/login")
}
} else {
t.Error("❌ CRITICAL: Savedata too short after reload")
}
// Verify name persisted
if session2.Name != "LifecycleChar" {
t.Errorf("❌ Character name not loaded correctly: got %q, want %q", session2.Name, "LifecycleChar")
} else {
t.Log("✓ Character name persisted correctly")
}
// Clean up
logoutPlayer(session2)
}
// TestSessionLifecycle_WarehouseDataPersistence tests warehouse across sessions
// This addresses user report: "warehouse contents not saved"
func TestSessionLifecycle_WarehouseDataPersistence(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
server := createTestServerWithDB(t, db)
defer server.Shutdown()
userID := CreateTestUser(t, db, "warehouse_test_user")
charID := CreateTestCharacter(t, db, userID, "WarehouseChar")
t.Log("Testing warehouse persistence across logout/login")
// ===== SESSION 1: Add items to warehouse =====
session1 := createTestSessionForServerWithChar(server, charID, "WarehouseChar")
// Create test equipment for warehouse
equipment := []mhfitem.MHFEquipment{
createTestEquipmentItem(100, 1),
createTestEquipmentItem(101, 2),
createTestEquipmentItem(102, 3),
}
serializedEquip := mhfitem.SerializeWarehouseEquipment(equipment, _config.ZZ)
// Save to warehouse directly (simulating a save handler)
_, _ = db.Exec("INSERT INTO warehouse (character_id) VALUES ($1) ON CONFLICT DO NOTHING", charID)
_, err := db.Exec("UPDATE warehouse SET equip0 = $1 WHERE character_id = $2", serializedEquip, charID)
if err != nil {
t.Fatalf("Failed to save warehouse: %v", err)
}
t.Log("Saved equipment to warehouse in session 1")
// Logout
logoutPlayer(session1)
time.Sleep(100 * time.Millisecond)
// ===== SESSION 2: Verify warehouse contents =====
session2 := createTestSessionForServerWithChar(server, charID, "WarehouseChar")
// Reload warehouse
var savedEquip []byte
err = db.QueryRow("SELECT equip0 FROM warehouse WHERE character_id = $1", charID).Scan(&savedEquip)
if err != nil {
t.Errorf("❌ Failed to load warehouse after logout: %v", err)
logoutPlayer(session2)
return
}
if len(savedEquip) == 0 {
t.Error("❌ Warehouse equipment not saved")
} else if !bytes.Equal(savedEquip, serializedEquip) {
t.Error("❌ Warehouse equipment data mismatch")
} else {
t.Log("✓ Warehouse equipment persisted correctly across logout/login")
}
logoutPlayer(session2)
}
// TestSessionLifecycle_KoryoPointsPersistence tests kill counter across sessions
// This addresses user report: "monster kill counter not saved"
func TestSessionLifecycle_KoryoPointsPersistence(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
server := createTestServerWithDB(t, db)
defer server.Shutdown()
userID := CreateTestUser(t, db, "koryo_test_user")
charID := CreateTestCharacter(t, db, userID, "KoryoChar")
t.Log("Testing Koryo points persistence across logout/login")
// ===== SESSION 1: Add Koryo points =====
session1 := createTestSessionForServerWithChar(server, charID, "KoryoChar")
// Add Koryo points via packet
addPoints := uint32(250)
pkt := &mhfpacket.MsgMhfAddKouryouPoint{
AckHandle: 3001,
KouryouPoints: addPoints,
}
t.Logf("Adding %d Koryo points", addPoints)
handleMsgMhfAddKouryouPoint(session1, pkt)
time.Sleep(50 * time.Millisecond)
// Verify points were added in session 1
var points1 uint32
err := db.QueryRow("SELECT COALESCE(kouryou_point, 0) FROM characters WHERE id = $1", charID).Scan(&points1)
if err != nil {
t.Fatalf("Failed to query koryo points: %v", err)
}
t.Logf("Koryo points after add: %d", points1)
// Logout
logoutPlayer(session1)
time.Sleep(100 * time.Millisecond)
// ===== SESSION 2: Verify Koryo points persist =====
session2 := createTestSessionForServerWithChar(server, charID, "KoryoChar")
// Reload Koryo points
var points2 uint32
err = db.QueryRow("SELECT COALESCE(kouryou_point, 0) FROM characters WHERE id = $1", charID).Scan(&points2)
if err != nil {
t.Errorf("❌ Failed to load koryo points after logout: %v", err)
logoutPlayer(session2)
return
}
if points2 != addPoints {
t.Errorf("❌ Koryo points not persisted: got %d, want %d", points2, addPoints)
} else {
t.Logf("✓ Koryo points persisted correctly: %d", points2)
}
logoutPlayer(session2)
}
// TestSessionLifecycle_MultipleDataTypesPersistence tests multiple data types in one session
// This is the comprehensive test that simulates a real player session
func TestSessionLifecycle_MultipleDataTypesPersistence(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
server := createTestServerWithDB(t, db)
defer server.Shutdown()
userID := CreateTestUser(t, db, "multi_test_user")
charID := CreateTestCharacter(t, db, userID, "MultiChar")
t.Log("Testing multiple data types persistence across logout/login")
// ===== SESSION 1: Modify multiple data types =====
session1 := createTestSessionForServerWithChar(server, charID, "MultiChar")
// 1. Set Road Points
rdpPoints := uint32(7500)
_, err := db.Exec("UPDATE characters SET frontier_points = $1 WHERE id = $2", rdpPoints, charID)
if err != nil {
t.Fatalf("Failed to set RdP: %v", err)
}
// 2. Add Koryo Points
koryoPoints := uint32(500)
addKoryoPkt := &mhfpacket.MsgMhfAddKouryouPoint{
AckHandle: 4001,
KouryouPoints: koryoPoints,
}
handleMsgMhfAddKouryouPoint(session1, addKoryoPkt)
// 3. Save Hunter Navi
naviData := make([]byte, 552)
for i := range naviData {
naviData[i] = byte((i * 7) % 256)
}
naviPkt := &mhfpacket.MsgMhfSaveHunterNavi{
AckHandle: 4002,
IsDataDiff: false,
RawDataPayload: naviData,
}
handleMsgMhfSaveHunterNavi(session1, naviPkt)
// 4. Save main savedata
saveData := make([]byte, 150000)
copy(saveData[88:], []byte("MultiChar\x00"))
saveData[2000] = 0xCA
saveData[2001] = 0xFE
saveData[2002] = 0xBA
saveData[2003] = 0xBE
compressed, err := nullcomp.Compress(saveData)
if err != nil {
t.Fatalf("Failed to compress savedata: %v", err)
}
savePkt := &mhfpacket.MsgMhfSavedata{
SaveType: 0,
AckHandle: 4003,
AllocMemSize: uint32(len(compressed)),
DataSize: uint32(len(compressed)),
RawDataPayload: compressed,
}
handleMsgMhfSavedata(session1, savePkt)
// Give handlers time to process
time.Sleep(100 * time.Millisecond)
t.Log("Modified all data types in session 1")
// Logout
logoutPlayer(session1)
time.Sleep(100 * time.Millisecond)
// ===== SESSION 2: Verify all data persists =====
session2 := createTestSessionForServerWithChar(server, charID, "MultiChar")
// Load character data
loadPkt := &mhfpacket.MsgMhfLoaddata{
AckHandle: 5001,
}
handleMsgMhfLoaddata(session2, loadPkt)
time.Sleep(50 * time.Millisecond)
allPassed := true
// Verify 1: Road Points
var loadedRdP uint32
_ = db.QueryRow("SELECT frontier_points FROM characters WHERE id = $1", charID).Scan(&loadedRdP)
if loadedRdP != rdpPoints {
t.Errorf("❌ RdP not persisted: got %d, want %d", loadedRdP, rdpPoints)
allPassed = false
} else {
t.Logf("✓ RdP persisted: %d", loadedRdP)
}
// Verify 2: Koryo Points
var loadedKoryo uint32
_ = db.QueryRow("SELECT COALESCE(kouryou_point, 0) FROM characters WHERE id = $1", charID).Scan(&loadedKoryo)
if loadedKoryo != koryoPoints {
t.Errorf("❌ Koryo points not persisted: got %d, want %d", loadedKoryo, koryoPoints)
allPassed = false
} else {
t.Logf("✓ Koryo points persisted: %d", loadedKoryo)
}
// Verify 3: Hunter Navi
var loadedNavi []byte
_ = db.QueryRow("SELECT hunternavi FROM characters WHERE id = $1", charID).Scan(&loadedNavi)
if len(loadedNavi) == 0 {
t.Error("❌ Hunter Navi not saved")
allPassed = false
} else if !bytes.Equal(loadedNavi, naviData) {
t.Error("❌ Hunter Navi data mismatch")
allPassed = false
} else {
t.Logf("✓ Hunter Navi persisted: %d bytes", len(loadedNavi))
}
// Verify 4: Savedata
var savedCompressed []byte
_ = db.QueryRow("SELECT savedata FROM characters WHERE id = $1", charID).Scan(&savedCompressed)
if len(savedCompressed) == 0 {
t.Error("❌ Savedata not saved")
allPassed = false
} else {
decompressed, err := nullcomp.Decompress(savedCompressed)
if err != nil {
t.Errorf("❌ Failed to decompress savedata: %v", err)
allPassed = false
} else if len(decompressed) > 2003 {
if decompressed[2000] != 0xCA || decompressed[2001] != 0xFE ||
decompressed[2002] != 0xBA || decompressed[2003] != 0xBE {
t.Error("❌ Savedata contents corrupted")
allPassed = false
} else {
t.Log("✓ Savedata persisted correctly")
}
} else {
t.Error("❌ Savedata too short")
allPassed = false
}
}
if allPassed {
t.Log("✅ All data types persisted correctly across logout/login cycle")
} else {
t.Log("❌ CRITICAL: Some data types failed to persist - logout may not be triggering save handlers")
}
logoutPlayer(session2)
}
// TestSessionLifecycle_DisconnectWithoutLogout tests ungraceful disconnect
// This simulates network failure or client crash
func TestSessionLifecycle_DisconnectWithoutLogout(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
server := createTestServerWithDB(t, db)
defer server.Shutdown()
userID := CreateTestUser(t, db, "disconnect_test_user")
charID := CreateTestCharacter(t, db, userID, "DisconnectChar")
t.Log("Testing data persistence after ungraceful disconnect")
// ===== SESSION 1: Modify data then disconnect without explicit logout =====
session1 := createTestSessionForServerWithChar(server, charID, "DisconnectChar")
// Modify data
rdpPoints := uint32(9999)
_, err := db.Exec("UPDATE characters SET frontier_points = $1 WHERE id = $2", rdpPoints, charID)
if err != nil {
t.Fatalf("Failed to set RdP: %v", err)
}
// Save data
saveData := make([]byte, 150000)
copy(saveData[88:], []byte("DisconnectChar\x00"))
saveData[3000] = 0xAB
saveData[3001] = 0xCD
compressed, err := nullcomp.Compress(saveData)
if err != nil {
t.Fatalf("Failed to compress savedata: %v", err)
}
savePkt := &mhfpacket.MsgMhfSavedata{
SaveType: 0,
AckHandle: 6001,
AllocMemSize: uint32(len(compressed)),
DataSize: uint32(len(compressed)),
RawDataPayload: compressed,
}
handleMsgMhfSavedata(session1, savePkt)
time.Sleep(100 * time.Millisecond)
// Simulate disconnect by calling logoutPlayer (which is called by recvLoop on EOF)
// In real scenario, this is triggered by connection close
t.Log("Simulating ungraceful disconnect")
logoutPlayer(session1)
time.Sleep(100 * time.Millisecond)
// ===== SESSION 2: Verify data saved despite ungraceful disconnect =====
session2 := createTestSessionForServerWithChar(server, charID, "DisconnectChar")
// Verify savedata
var savedCompressed []byte
err = db.QueryRow("SELECT savedata FROM characters WHERE id = $1", charID).Scan(&savedCompressed)
if err != nil {
t.Fatalf("Failed to load savedata: %v", err)
}
if len(savedCompressed) == 0 {
t.Error("❌ CRITICAL: No data saved after disconnect")
logoutPlayer(session2)
return
}
decompressed, err := nullcomp.Decompress(savedCompressed)
if err != nil {
t.Errorf("Failed to decompress: %v", err)
logoutPlayer(session2)
return
}
if len(decompressed) > 3001 {
if decompressed[3000] == 0xAB && decompressed[3001] == 0xCD {
t.Log("✓ Data persisted after ungraceful disconnect")
} else {
t.Error("❌ Data corrupted after disconnect")
}
} else {
t.Error("❌ Data too short after disconnect")
}
logoutPlayer(session2)
}
// TestSessionLifecycle_RapidReconnect tests quick logout/login cycles
// This simulates a player reconnecting quickly or connection instability
func TestSessionLifecycle_RapidReconnect(t *testing.T) {
db := SetupTestDB(t)
defer TeardownTestDB(t, db)
server := createTestServerWithDB(t, db)
defer server.Shutdown()
userID := CreateTestUser(t, db, "rapid_test_user")
charID := CreateTestCharacter(t, db, userID, "RapidChar")
t.Log("Testing data persistence with rapid logout/login cycles")
for cycle := 1; cycle <= 3; cycle++ {
t.Logf("--- Cycle %d ---", cycle)
session := createTestSessionForServerWithChar(server, charID, "RapidChar")
// Modify road points each cycle
points := uint32(1000 * cycle)
_, err := db.Exec("UPDATE characters SET frontier_points = $1 WHERE id = $2", points, charID)
if err != nil {
t.Fatalf("Cycle %d: Failed to update points: %v", cycle, err)
}
// Logout quickly
logoutPlayer(session)
time.Sleep(30 * time.Millisecond)
// Verify points persisted
var loadedPoints uint32
_ = db.QueryRow("SELECT frontier_points FROM characters WHERE id = $1", charID).Scan(&loadedPoints)
if loadedPoints != points {
t.Errorf("❌ Cycle %d: Points not persisted: got %d, want %d", cycle, loadedPoints, points)
} else {
t.Logf("✓ Cycle %d: Points persisted correctly: %d", cycle, loadedPoints)
}
}
}
// Helper function to create test equipment item with proper initialization
func createTestEquipmentItem(itemID uint16, warehouseID uint32) mhfitem.MHFEquipment {
sigils := make([]mhfitem.MHFSigil, 3)
for i := range sigils {
sigils[i].Effects = make([]mhfitem.MHFSigilEffect, 3)
}
return mhfitem.MHFEquipment{
ItemID: itemID,
WarehouseID: warehouseID,
Decorations: make([]mhfitem.MHFItem, 3),
Sigils: sigils,
}
}
// MockNetConn is defined in client_connection_simulation_test.go
// Helper function to create a test server with database
func createTestServerWithDB(t *testing.T, db *sqlx.DB) *Server {
t.Helper()
// Create minimal server for testing
// Note: This may need adjustment based on actual Server initialization
server := &Server{
db: db,
sessions: make(map[net.Conn]*Session),
stages: make(map[string]*Stage),
userBinaryParts: make(map[userBinaryPartID][]byte),
minidataParts: make(map[uint32][]byte),
semaphore: make(map[string]*Semaphore),
erupeConfig: &_config.Config{
RealClientMode: _config.ZZ,
},
isShuttingDown: false,
done: make(chan struct{}),
}
// Create logger
logger, _ := zap.NewDevelopment()
server.logger = logger
// Initialize repositories
server.charRepo = NewCharacterRepository(db)
server.guildRepo = NewGuildRepository(db)
server.userRepo = NewUserRepository(db)
server.gachaRepo = NewGachaRepository(db)
server.houseRepo = NewHouseRepository(db)
server.festaRepo = NewFestaRepository(db)
server.towerRepo = NewTowerRepository(db)
return server
}
// Helper function to create a test session for a specific character
func createTestSessionForServerWithChar(server *Server, charID uint32, name string) *Session {
mock := &MockCryptConn{sentPackets: make([][]byte, 0)}
mockNetConn := NewMockNetConn() // Create a mock net.Conn for the session map key
session := &Session{
logger: server.logger,
server: server,
rawConn: mockNetConn,
cryptConn: mock,
sendPackets: make(chan packet, 20),
clientContext: &clientctx.ClientContext{},
lastPacket: time.Now(),
sessionStart: time.Now().Unix(),
charID: charID,
Name: name,
}
// Register session with server (needed for logout to work properly)
server.Lock()
server.sessions[mockNetConn] = session
server.Unlock()
return session
}