diff --git a/server/channelserver/handlers_house_test.go b/server/channelserver/handlers_house_test.go index 7a787335d..e4a8dbf59 100644 --- a/server/channelserver/handlers_house_test.go +++ b/server/channelserver/handlers_house_test.go @@ -1,12 +1,74 @@ package channelserver import ( + "erupe-ce/common/byteframe" _config "erupe-ce/config" "erupe-ce/common/mhfitem" "erupe-ce/common/token" + "erupe-ce/network/mhfpacket" "testing" + + "github.com/jmoiron/sqlx" ) +// ackResponse holds parsed fields from a queued MsgSysAck packet. +type ackResponse struct { + AckHandle uint32 + IsBufferResponse bool + ErrorCode uint8 + PayloadSize uint + Payload []byte +} + +// readAck drains one packet from the session's sendPackets channel and +// parses the MsgSysAck wire format that QueueSendMHF produces. +func readAck(t *testing.T, session *Session) ackResponse { + t.Helper() + select { + case p := <-session.sendPackets: + bf := byteframe.NewByteFrameFromBytes(p.data) + _ = bf.ReadUint16() // opcode + ack := ackResponse{} + ack.AckHandle = bf.ReadUint32() + ack.IsBufferResponse = bf.ReadBool() + ack.ErrorCode = bf.ReadUint8() + size := uint(bf.ReadUint16()) + if size == 0xFFFF { + size = uint(bf.ReadUint32()) + } + ack.PayloadSize = size + if ack.IsBufferResponse { + ack.Payload = bf.ReadBytes(size) + } else { + ack.Payload = bf.ReadBytes(4) + } + return ack + default: + t.Fatal("No response packet queued") + return ackResponse{} + } +} + +// setupHouseTest creates DB, server, session, and a character with user_binary row. +func setupHouseTest(t *testing.T) (*sqlx.DB, *Server, *Session, uint32) { + t.Helper() + db := SetupTestDB(t) + server := createMockServer() + server.erupeConfig.RealClientMode = _config.ZZ + SetTestDB(server, db) + + userID := CreateTestUser(t, db, "house_test_user") + charID := CreateTestCharacter(t, db, userID, "HousePlayer") + + _, err := db.Exec(`INSERT INTO user_binary (id) VALUES ($1) ON CONFLICT DO NOTHING`, charID) + if err != nil { + t.Fatalf("Failed to create user_binary row: %v", err) + } + + session := createMockSession(charID, server) + return db, server, session, charID +} + // createTestEquipment creates properly initialized test equipment func createTestEquipment(itemIDs []uint16, warehouseIDs []uint32) []mhfitem.MHFEquipment { var equip []mhfitem.MHFEquipment @@ -26,6 +88,610 @@ func createTestEquipment(itemIDs []uint16, warehouseIDs []uint32) []mhfitem.MHFE return equip } +// ============================================================================= +// Unit Tests — guard paths, no database +// ============================================================================= + +func TestUpdateInterior_PayloadTooLarge(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfUpdateInterior{ + AckHandle: 1, + InteriorData: make([]byte, 65), // > 64 triggers guard + } + handleMsgMhfUpdateInterior(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Errorf("expected success ACK (guard returns succeed), got error code %d", ack.ErrorCode) + } +} + +func TestUpdateMyhouseInfo_PayloadTooLarge(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfUpdateMyhouseInfo{ + AckHandle: 2, + Data: make([]byte, 513), // > 512 triggers guard + } + handleMsgMhfUpdateMyhouseInfo(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Errorf("expected success ACK on oversized payload, got error code %d", ack.ErrorCode) + } +} + +func TestSaveDecoMyset_PayloadTooShort(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfSaveDecoMyset{ + AckHandle: 3, + RawDataPayload: []byte{0x00, 0x01}, // < 3 bytes + } + handleMsgMhfSaveDecoMyset(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Errorf("expected success ACK on short payload, got error code %d", ack.ErrorCode) + } +} + +func TestUpdateWarehouse_BoxIndexTooHigh(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfUpdateWarehouse{ + AckHandle: 4, + BoxIndex: 11, // > 10 triggers fail + } + handleMsgMhfUpdateWarehouse(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 1 { + t.Errorf("expected fail ACK for out-of-bounds box index, got error code %d", ack.ErrorCode) + } +} + +func TestEnumerateHouse_Method5_EmptyResult(t *testing.T) { + server := createMockServer() + server.erupeConfig.RealClientMode = _config.ZZ + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfEnumerateHouse{ + AckHandle: 5, + Method: 5, // Recent visitors — always returns empty + } + handleMsgMhfEnumerateHouse(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success, got error code %d", ack.ErrorCode) + } + if !ack.IsBufferResponse { + t.Fatal("expected buffer response") + } + // First 2 bytes = count, should be 0 + bf := byteframe.NewByteFrameFromBytes(ack.Payload) + count := bf.ReadUint16() + if count != 0 { + t.Errorf("expected 0 houses for method 5, got %d", count) + } +} + +func TestResetTitle_NoOp(t *testing.T) { + // handleMsgMhfResetTitle is an empty function — just verify no panic + defer func() { + if r := recover(); r != nil { + t.Errorf("handleMsgMhfResetTitle panicked: %v", r) + } + }() + handleMsgMhfResetTitle(nil, nil) +} + +func TestOperateWarehouse_RenameBoxIndexTooHigh(t *testing.T) { + // Operation 2 = Rename. BoxIndex > 9 should skip the rename. + // This needs a DB for initializeWarehouse, so the full test is the + // integration test TestOperateWarehouse_Op2_RenameBoxIndexTooHigh below. +} + +// ============================================================================= +// Integration Tests — real PostgreSQL via SetupTestDB +// ============================================================================= + +func TestUpdateInterior_SavesData(t *testing.T) { + _, _, session, charID := setupHouseTest(t) + + interiorData := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A} + pkt := &mhfpacket.MsgMhfUpdateInterior{ + AckHandle: 10, + InteriorData: interiorData, + } + handleMsgMhfUpdateInterior(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success, got error code %d", ack.ErrorCode) + } + + // Verify data was persisted + _, _, furniture, _, _, _, _, err := session.server.houseRepo.GetHouseContents(charID) + if err != nil { + t.Fatalf("GetHouseContents failed: %v", err) + } + if len(furniture) < len(interiorData) { + t.Fatalf("furniture data too short: got %d bytes", len(furniture)) + } + for i, b := range interiorData { + if furniture[i] != b { + t.Errorf("furniture[%d] = %#x, want %#x", i, furniture[i], b) + } + } +} + +func TestUpdateHouse_SetsStateAndPassword(t *testing.T) { + _, _, session, charID := setupHouseTest(t) + + pkt := &mhfpacket.MsgMhfUpdateHouse{ + AckHandle: 11, + State: 3, + Password: "secret", + } + handleMsgMhfUpdateHouse(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success, got error code %d", ack.ErrorCode) + } + + state, password, err := session.server.houseRepo.GetHouseAccess(charID) + if err != nil { + t.Fatalf("GetHouseAccess failed: %v", err) + } + if state != 3 { + t.Errorf("state = %d, want 3", state) + } + if password != "secret" { + t.Errorf("password = %q, want %q", password, "secret") + } +} + +func TestEnumerateHouse_Method4_ByCharID(t *testing.T) { + _, _, session, charID := setupHouseTest(t) + + pkt := &mhfpacket.MsgMhfEnumerateHouse{ + AckHandle: 12, + Method: 4, + CharID: charID, + } + handleMsgMhfEnumerateHouse(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success, got error code %d", ack.ErrorCode) + } + bf := byteframe.NewByteFrameFromBytes(ack.Payload) + count := bf.ReadUint16() + if count != 1 { + t.Errorf("expected 1 house for charID lookup, got %d", count) + } +} + +func TestEnumerateHouse_Method3_ByName(t *testing.T) { + _, _, session, _ := setupHouseTest(t) + + pkt := &mhfpacket.MsgMhfEnumerateHouse{ + AckHandle: 13, + Method: 3, + Name: "HousePlayer", + } + handleMsgMhfEnumerateHouse(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success, got error code %d", ack.ErrorCode) + } + bf := byteframe.NewByteFrameFromBytes(ack.Payload) + count := bf.ReadUint16() + if count < 1 { + t.Errorf("expected at least 1 house for name search, got %d", count) + } +} + +func TestLoadHouse_OwnHouse_Destination9(t *testing.T) { + _, _, session, charID := setupHouseTest(t) + + // Set some interior data first + interior := make([]byte, 20) + interior[0] = 0xAB + _ = session.server.houseRepo.UpdateInterior(charID, interior) + + pkt := &mhfpacket.MsgMhfLoadHouse{ + AckHandle: 14, + CharID: charID, + Destination: 9, // Own house — bypasses access control + } + handleMsgMhfLoadHouse(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success loading own house, got error code %d", ack.ErrorCode) + } + if !ack.IsBufferResponse { + t.Fatal("expected buffer response") + } + if len(ack.Payload) == 0 { + t.Error("expected non-empty house data") + } +} + +func TestLoadHouse_WrongPassword_Fails(t *testing.T) { + _, _, session, charID := setupHouseTest(t) + + // Set a password on the house + _ = session.server.houseRepo.UpdateHouseState(charID, 2, "correct") + + pkt := &mhfpacket.MsgMhfLoadHouse{ + AckHandle: 15, + CharID: charID, + Destination: 3, // Others house + CheckPass: true, + Password: "wrong", + } + handleMsgMhfLoadHouse(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 1 { + t.Errorf("expected fail ACK for wrong password, got error code %d", ack.ErrorCode) + } +} + +func TestLoadHouse_CorrectPassword_Succeeds(t *testing.T) { + _, _, session, charID := setupHouseTest(t) + + _ = session.server.houseRepo.UpdateHouseState(charID, 2, "correct") + + pkt := &mhfpacket.MsgMhfLoadHouse{ + AckHandle: 16, + CharID: charID, + Destination: 3, + CheckPass: true, + Password: "correct", + } + handleMsgMhfLoadHouse(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Errorf("expected success for correct password, got error code %d", ack.ErrorCode) + } + if !ack.IsBufferResponse { + t.Fatal("expected buffer response for house data") + } +} + +func TestGetMyhouseInfo_NoData(t *testing.T) { + _, _, session, _ := setupHouseTest(t) + + pkt := &mhfpacket.MsgMhfGetMyhouseInfo{AckHandle: 17} + handleMsgMhfGetMyhouseInfo(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success, got error code %d", ack.ErrorCode) + } + // When no mission data exists, handler returns 9-byte default + if len(ack.Payload) != 9 { + t.Errorf("expected 9-byte default payload, got %d bytes", len(ack.Payload)) + } +} + +func TestGetMyhouseInfo_WithData(t *testing.T) { + _, _, session, charID := setupHouseTest(t) + + missionData := make([]byte, 50) + missionData[0] = 0xDE + missionData[1] = 0xAD + _ = session.server.houseRepo.UpdateMission(charID, missionData) + + pkt := &mhfpacket.MsgMhfGetMyhouseInfo{AckHandle: 18} + handleMsgMhfGetMyhouseInfo(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success, got error code %d", ack.ErrorCode) + } + if len(ack.Payload) != 50 { + t.Fatalf("expected 50-byte payload, got %d bytes", len(ack.Payload)) + } + if ack.Payload[0] != 0xDE || ack.Payload[1] != 0xAD { + t.Errorf("payload mismatch: got %#x %#x, want 0xDE 0xAD", ack.Payload[0], ack.Payload[1]) + } +} + +func TestUpdateMyhouseInfo_SavesData(t *testing.T) { + _, _, session, charID := setupHouseTest(t) + + missionData := make([]byte, 100) + missionData[0] = 0xCA + missionData[1] = 0xFE + + pkt := &mhfpacket.MsgMhfUpdateMyhouseInfo{ + AckHandle: 19, + Data: missionData, + } + handleMsgMhfUpdateMyhouseInfo(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success, got error code %d", ack.ErrorCode) + } + + // Verify via repository + data, err := session.server.houseRepo.GetMission(charID) + if err != nil { + t.Fatalf("GetMission failed: %v", err) + } + if len(data) != 100 { + t.Fatalf("mission data length = %d, want 100", len(data)) + } + if data[0] != 0xCA || data[1] != 0xFE { + t.Errorf("mission data mismatch: got %#x %#x, want 0xCA 0xFE", data[0], data[1]) + } +} + +func TestEnumerateTitle_Empty(t *testing.T) { + _, _, session, _ := setupHouseTest(t) + + pkt := &mhfpacket.MsgMhfEnumerateTitle{AckHandle: 20} + handleMsgMhfEnumerateTitle(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success, got error code %d", ack.ErrorCode) + } + bf := byteframe.NewByteFrameFromBytes(ack.Payload) + count := bf.ReadUint16() + if count != 0 { + t.Errorf("expected 0 titles, got %d", count) + } +} + +func TestAcquireTitle_AndEnumerate(t *testing.T) { + _, _, session, _ := setupHouseTest(t) + + // Acquire two titles + acquirePkt := &mhfpacket.MsgMhfAcquireTitle{ + AckHandle: 21, + TitleIDs: []uint16{100, 200}, + } + handleMsgMhfAcquireTitle(session, acquirePkt) + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("acquire failed: error code %d", ack.ErrorCode) + } + + // Enumerate + enumPkt := &mhfpacket.MsgMhfEnumerateTitle{AckHandle: 22} + handleMsgMhfEnumerateTitle(session, enumPkt) + ack = readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("enumerate failed: error code %d", ack.ErrorCode) + } + + bf := byteframe.NewByteFrameFromBytes(ack.Payload) + count := bf.ReadUint16() + if count != 2 { + t.Errorf("expected 2 titles, got %d", count) + } + + // Read title IDs + _ = bf.ReadUint16() // unk + ids := make(map[uint16]bool) + for i := 0; i < int(count); i++ { + id := bf.ReadUint16() + ids[id] = true + _ = bf.ReadUint16() // unk + _ = bf.ReadUint32() // acquired timestamp + _ = bf.ReadUint32() // updated timestamp + } + if !ids[100] || !ids[200] { + t.Errorf("expected title IDs 100 and 200, got %v", ids) + } +} + +func TestAcquireTitle_Duplicate(t *testing.T) { + _, _, session, charID := setupHouseTest(t) + + // Acquire title 300 + pkt1 := &mhfpacket.MsgMhfAcquireTitle{AckHandle: 23, TitleIDs: []uint16{300}} + handleMsgMhfAcquireTitle(session, pkt1) + _ = readAck(t, session) + + // Acquire same title again + pkt2 := &mhfpacket.MsgMhfAcquireTitle{AckHandle: 24, TitleIDs: []uint16{300}} + handleMsgMhfAcquireTitle(session, pkt2) + _ = readAck(t, session) + + // Should still have exactly 1 title (upsert) + titles, err := session.server.houseRepo.GetTitles(charID) + if err != nil { + t.Fatalf("GetTitles failed: %v", err) + } + if len(titles) != 1 { + t.Errorf("expected 1 title after duplicate acquire, got %d", len(titles)) + } +} + +func TestOperateWarehouse_Op0_GetBoxNames(t *testing.T) { + _, _, session, charID := setupHouseTest(t) + + // Initialize warehouse and rename a box + session.server.houseRepo.InitializeWarehouse(charID) + _ = session.server.houseRepo.RenameWarehouseBox(charID, 0, 0, "MyItems") + + pkt := &mhfpacket.MsgMhfOperateWarehouse{ + AckHandle: 25, + Operation: 0, + } + handleMsgMhfOperateWarehouse(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success, got error code %d", ack.ErrorCode) + } + if !ack.IsBufferResponse { + t.Fatal("expected buffer response") + } + // Response format: op(1) + renewal(4) + usages(2) + count(1) + entries + if len(ack.Payload) < 8 { + t.Fatalf("payload too short: %d bytes", len(ack.Payload)) + } + bf := byteframe.NewByteFrameFromBytes(ack.Payload) + op := bf.ReadUint8() + if op != 0 { + t.Errorf("op = %d, want 0", op) + } +} + +func TestOperateWarehouse_Op3_GetUsageLimit(t *testing.T) { + _, _, session, _ := setupHouseTest(t) + + pkt := &mhfpacket.MsgMhfOperateWarehouse{ + AckHandle: 26, + Operation: 3, + } + handleMsgMhfOperateWarehouse(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success, got error code %d", ack.ErrorCode) + } + // Response: op(1) + renewal_time(4) + usages(2) = 7 bytes + bf := byteframe.NewByteFrameFromBytes(ack.Payload) + op := bf.ReadUint8() + if op != 3 { + t.Errorf("op = %d, want 3", op) + } + renewalTime := bf.ReadUint32() + usages := bf.ReadUint16() + if renewalTime != 0 { + t.Errorf("renewal time = %d, want 0", renewalTime) + } + if usages != 10000 { + t.Errorf("usages = %d, want 10000", usages) + } +} + +func TestOperateWarehouse_Op2_RenameBoxIndexTooHigh(t *testing.T) { + _, _, session, _ := setupHouseTest(t) + + pkt := &mhfpacket.MsgMhfOperateWarehouse{ + AckHandle: 27, + Operation: 2, + BoxIndex: 10, // > 9, rename should be skipped + Name: "ShouldNotRename", + } + handleMsgMhfOperateWarehouse(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success ACK even with skipped rename, got error code %d", ack.ErrorCode) + } +} + +func TestEnumerateWarehouse_EmptyBox(t *testing.T) { + _, _, session, _ := setupHouseTest(t) + + pkt := &mhfpacket.MsgMhfEnumerateWarehouse{ + AckHandle: 28, + BoxType: 0, // Items + BoxIndex: 0, + } + handleMsgMhfEnumerateWarehouse(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success, got error code %d", ack.ErrorCode) + } + if !ack.IsBufferResponse { + t.Fatal("expected buffer response") + } + // Empty box returns serialized empty list: count(2) + unk(2) = 4 bytes minimum + if len(ack.Payload) < 4 { + t.Errorf("expected at least 4-byte payload for empty box, got %d", len(ack.Payload)) + } +} + +func TestUpdateWarehouse_Items(t *testing.T) { + _, _, session, charID := setupHouseTest(t) + + items := []mhfitem.MHFItemStack{ + {Item: mhfitem.MHFItem{ItemID: 42}, Quantity: 10, WarehouseID: token.RNG.Uint32()}, + {Item: mhfitem.MHFItem{ItemID: 99}, Quantity: 5, WarehouseID: token.RNG.Uint32()}, + } + pkt := &mhfpacket.MsgMhfUpdateWarehouse{ + AckHandle: 29, + BoxType: 0, + BoxIndex: 0, + UpdatedItems: items, + } + handleMsgMhfUpdateWarehouse(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success, got error code %d", ack.ErrorCode) + } + + // Read back via enumerate + session2 := createMockSession(charID, session.server) + enumPkt := &mhfpacket.MsgMhfEnumerateWarehouse{ + AckHandle: 30, + BoxType: 0, + BoxIndex: 0, + } + handleMsgMhfEnumerateWarehouse(session2, enumPkt) + + ack2 := readAck(t, session2) + if ack2.ErrorCode != 0 { + t.Fatalf("enumerate failed: error code %d", ack2.ErrorCode) + } + // Parse the serialized items + bf := byteframe.NewByteFrameFromBytes(ack2.Payload) + count := bf.ReadUint16() + if count != 2 { + t.Errorf("expected 2 items in warehouse, got %d", count) + } +} + +func TestLoadDecoMyset_Default(t *testing.T) { + _, _, session, _ := setupHouseTest(t) + + pkt := &mhfpacket.MsgMhfLoadDecoMyset{AckHandle: 31} + handleMsgMhfLoadDecoMyset(session, pkt) + + ack := readAck(t, session) + if ack.ErrorCode != 0 { + t.Fatalf("expected success, got error code %d", ack.ErrorCode) + } + if !ack.IsBufferResponse { + t.Fatal("expected buffer response") + } + // G10+ mode returns {0x01, 0x00} + if len(ack.Payload) < 2 { + t.Fatalf("expected at least 2-byte payload, got %d", len(ack.Payload)) + } + if ack.Payload[0] != 0x01 || ack.Payload[1] != 0x00 { + t.Errorf("expected default {0x01, 0x00}, got {%#x, %#x}", ack.Payload[0], ack.Payload[1]) + } +} + +// ============================================================================= +// Existing pure-logic tests and benchmarks (unchanged) +// ============================================================================= + // TestWarehouseItemSerialization verifies warehouse item serialization func TestWarehouseItemSerialization(t *testing.T) { tests := []struct {