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) // Save to warehouse directly (simulating a save handler) _, err := db.Exec(` INSERT INTO warehouse (character_id, equip0) VALUES ($1, $2) ON CONFLICT (character_id) DO UPDATE SET equip0 = $2 `, charID, serializedEquip) 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 { return mhfitem.MHFEquipment{ ItemID: itemID, WarehouseID: warehouseID, Decorations: make([]mhfitem.MHFItem, 3), Sigils: make([]mhfitem.MHFSigil, 3), } } // 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), semaphore: make(map[string]*Semaphore), erupeConfig: _config.ErupeConfig, isShuttingDown: false, } // Create logger logger, _ := zap.NewDevelopment() server.logger = logger 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 }