From db3e0bccc72e2d0f2e83598b82e7ac5d83e60795 Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Sun, 1 Feb 2026 23:28:19 +0100 Subject: [PATCH] test: improve test coverage for mhfpacket, channelserver, and server packages Add comprehensive tests for: - Pure time functions in channelserver (sys_time_test.go) - Stage-related packet parsing (msg_sys_stage_test.go) - Acquire packet family parsing (msg_mhf_acquire_test.go) - Extended mhfpacket tests for login, logout, and stage packets - Entrance server makeHeader structure and checksum tests - SignV2 server request/response JSON structure tests --- network/mhfpacket/mhfpacket_test.go | 380 ++++++++++++++++++++++ network/mhfpacket/msg_mhf_acquire_test.go | 263 +++++++++++++++ network/mhfpacket/msg_sys_stage_test.go | 338 +++++++++++++++++++ server/channelserver/sys_time_test.go | 167 ++++++++++ server/entranceserver/make_resp_test.go | 196 +++++++++++ server/signv2server/endpoints_test.go | 371 +++++++++++++++++++++ 6 files changed, 1715 insertions(+) create mode 100644 network/mhfpacket/msg_mhf_acquire_test.go create mode 100644 network/mhfpacket/msg_sys_stage_test.go create mode 100644 server/channelserver/sys_time_test.go diff --git a/network/mhfpacket/mhfpacket_test.go b/network/mhfpacket/mhfpacket_test.go index 4e748f859..ae3bedaf8 100644 --- a/network/mhfpacket/mhfpacket_test.go +++ b/network/mhfpacket/mhfpacket_test.go @@ -461,3 +461,383 @@ func TestMHFSaveLoad(t *testing.T) { }) } } + +func TestMsgSysCreateStageParse(t *testing.T) { + tests := []struct { + name string + data []byte + wantHandle uint32 + wantUnk0 uint8 + wantPlayers uint8 + wantStageID string + }{ + { + name: "simple stage", + data: append([]byte{0x00, 0x00, 0x00, 0x01, 0x02, 0x04, 0x05}, []byte("test")...), + wantHandle: 1, + wantUnk0: 2, + wantPlayers: 4, + wantStageID: "test", + }, + { + name: "empty stage ID", + data: []byte{0x12, 0x34, 0x56, 0x78, 0x01, 0x02, 0x00}, + wantHandle: 0x12345678, + wantUnk0: 1, + wantPlayers: 2, + wantStageID: "", + }, + { + name: "with null terminator", + data: append([]byte{0x00, 0x00, 0x00, 0x0A, 0x01, 0x01, 0x08}, append([]byte("stage01"), 0x00)...), + wantHandle: 10, + wantUnk0: 1, + wantPlayers: 1, + wantStageID: "stage01", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteBytes(tt.data) + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysCreateStage{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.wantHandle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.wantHandle) + } + if pkt.Unk0 != tt.wantUnk0 { + t.Errorf("Unk0 = %d, want %d", pkt.Unk0, tt.wantUnk0) + } + if pkt.PlayerCount != tt.wantPlayers { + t.Errorf("PlayerCount = %d, want %d", pkt.PlayerCount, tt.wantPlayers) + } + if pkt.StageID != tt.wantStageID { + t.Errorf("StageID = %q, want %q", pkt.StageID, tt.wantStageID) + } + }) + } +} + +func TestMsgSysEnterStageParse(t *testing.T) { + tests := []struct { + name string + data []byte + wantHandle uint32 + wantUnkBool uint8 + wantStageID string + }{ + { + name: "enter mezeporta", + data: append([]byte{0x00, 0x00, 0x00, 0x01, 0x00, 0x0F}, []byte("sl1Ns200p0a0u0")...), + wantHandle: 1, + wantUnkBool: 0, + wantStageID: "sl1Ns200p0a0u0", + }, + { + name: "with unk bool set", + data: append([]byte{0xAB, 0xCD, 0xEF, 0x12, 0x01, 0x05}, []byte("room1")...), + wantHandle: 0xABCDEF12, + wantUnkBool: 1, + wantStageID: "room1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteBytes(tt.data) + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysEnterStage{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.wantHandle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.wantHandle) + } + if pkt.UnkBool != tt.wantUnkBool { + t.Errorf("UnkBool = %d, want %d", pkt.UnkBool, tt.wantUnkBool) + } + if pkt.StageID != tt.wantStageID { + t.Errorf("StageID = %q, want %q", pkt.StageID, tt.wantStageID) + } + }) + } +} + +func TestMsgSysMoveStageParse(t *testing.T) { + tests := []struct { + name string + data []byte + wantHandle uint32 + wantUnkBool uint8 + wantStageID string + }{ + { + name: "move to quest stage", + data: append([]byte{0x00, 0x00, 0x12, 0x34, 0x00, 0x06}, []byte("quest1")...), + wantHandle: 0x1234, + wantUnkBool: 0, + wantStageID: "quest1", + }, + { + name: "with null in string", + data: append([]byte{0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0x08}, append([]byte("stage"), []byte{0x00, 0x00, 0x00}...)...), + wantHandle: 0xFFFFFFFF, + wantUnkBool: 1, + wantStageID: "stage", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteBytes(tt.data) + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysMoveStage{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.wantHandle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.wantHandle) + } + if pkt.UnkBool != tt.wantUnkBool { + t.Errorf("UnkBool = %d, want %d", pkt.UnkBool, tt.wantUnkBool) + } + if pkt.StageID != tt.wantStageID { + t.Errorf("StageID = %q, want %q", pkt.StageID, tt.wantStageID) + } + }) + } +} + +func TestMsgSysLockStageParse(t *testing.T) { + tests := []struct { + name string + data []byte + wantHandle uint32 + wantUnk0 uint8 + wantUnk1 uint8 + wantStageID string + }{ + { + name: "lock stage", + data: append([]byte{0x00, 0x00, 0x00, 0x05, 0x01, 0x01, 0x06}, []byte("room01")...), + wantHandle: 5, + wantUnk0: 1, + wantUnk1: 1, + wantStageID: "room01", + }, + { + name: "different unk values", + data: append([]byte{0x12, 0x34, 0x56, 0x78, 0x02, 0x03, 0x04}, []byte("test")...), + wantHandle: 0x12345678, + wantUnk0: 2, + wantUnk1: 3, + wantStageID: "test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteBytes(tt.data) + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysLockStage{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.wantHandle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.wantHandle) + } + if pkt.Unk0 != tt.wantUnk0 { + t.Errorf("Unk0 = %d, want %d", pkt.Unk0, tt.wantUnk0) + } + if pkt.Unk1 != tt.wantUnk1 { + t.Errorf("Unk1 = %d, want %d", pkt.Unk1, tt.wantUnk1) + } + if pkt.StageID != tt.wantStageID { + t.Errorf("StageID = %q, want %q", pkt.StageID, tt.wantStageID) + } + }) + } +} + +func TestMsgSysUnlockStageRoundTrip(t *testing.T) { + tests := []struct { + name string + unk0 uint16 + }{ + {"zero value", 0}, + {"typical value", 1}, + {"max value", 0xFFFF}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + original := &MsgSysUnlockStage{Unk0: tt.unk0} + ctx := &clientctx.ClientContext{} + + // Build + bf := byteframe.NewByteFrame() + err := original.Build(bf, ctx) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + + // Parse + bf.Seek(0, io.SeekStart) + parsed := &MsgSysUnlockStage{} + err = parsed.Parse(bf, ctx) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Compare + if parsed.Unk0 != original.Unk0 { + t.Errorf("Unk0 = %d, want %d", parsed.Unk0, original.Unk0) + } + }) + } +} + +func TestMsgSysBackStageParse(t *testing.T) { + tests := []struct { + name string + data []byte + wantHandle uint32 + }{ + {"simple handle", []byte{0x00, 0x00, 0x00, 0x01}, 1}, + {"large handle", []byte{0xDE, 0xAD, 0xBE, 0xEF}, 0xDEADBEEF}, + {"zero handle", []byte{0x00, 0x00, 0x00, 0x00}, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteBytes(tt.data) + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysBackStage{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.wantHandle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.wantHandle) + } + }) + } +} + +func TestMsgSysLogoutParse(t *testing.T) { + tests := []struct { + name string + data []byte + wantUnk0 uint8 + }{ + {"typical logout", []byte{0x01}, 1}, + {"zero value", []byte{0x00}, 0}, + {"max value", []byte{0xFF}, 255}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteBytes(tt.data) + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysLogout{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.Unk0 != tt.wantUnk0 { + t.Errorf("Unk0 = %d, want %d", pkt.Unk0, tt.wantUnk0) + } + }) + } +} + +func TestMsgSysLoginParse(t *testing.T) { + tests := []struct { + name string + ackHandle uint32 + charID0 uint32 + loginTokenNumber uint32 + hardcodedZero0 uint16 + requestVersion uint16 + charID1 uint32 + hardcodedZero1 uint16 + tokenStrLen uint16 + tokenString string + }{ + { + name: "typical login", + ackHandle: 1, + charID0: 12345, + loginTokenNumber: 67890, + hardcodedZero0: 0, + requestVersion: 1, + charID1: 12345, + hardcodedZero1: 0, + tokenStrLen: 0x11, + tokenString: "abc123token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteUint32(tt.ackHandle) + bf.WriteUint32(tt.charID0) + bf.WriteUint32(tt.loginTokenNumber) + bf.WriteUint16(tt.hardcodedZero0) + bf.WriteUint16(tt.requestVersion) + bf.WriteUint32(tt.charID1) + bf.WriteUint16(tt.hardcodedZero1) + bf.WriteUint16(tt.tokenStrLen) + bf.WriteBytes(append([]byte(tt.tokenString), 0x00)) // null terminated + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysLogin{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.ackHandle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.ackHandle) + } + if pkt.CharID0 != tt.charID0 { + t.Errorf("CharID0 = %d, want %d", pkt.CharID0, tt.charID0) + } + if pkt.LoginTokenNumber != tt.loginTokenNumber { + t.Errorf("LoginTokenNumber = %d, want %d", pkt.LoginTokenNumber, tt.loginTokenNumber) + } + if pkt.RequestVersion != tt.requestVersion { + t.Errorf("RequestVersion = %d, want %d", pkt.RequestVersion, tt.requestVersion) + } + if pkt.LoginTokenString != tt.tokenString { + t.Errorf("LoginTokenString = %q, want %q", pkt.LoginTokenString, tt.tokenString) + } + }) + } +} diff --git a/network/mhfpacket/msg_mhf_acquire_test.go b/network/mhfpacket/msg_mhf_acquire_test.go new file mode 100644 index 000000000..cddefb559 --- /dev/null +++ b/network/mhfpacket/msg_mhf_acquire_test.go @@ -0,0 +1,263 @@ +package mhfpacket + +import ( + "io" + "testing" + + "erupe-ce/common/byteframe" + "erupe-ce/network" + "erupe-ce/network/clientctx" +) + +func TestAcquirePacketOpcodes(t *testing.T) { + tests := []struct { + name string + pkt MHFPacket + expect network.PacketID + }{ + {"MsgMhfAcquireGuildTresure", &MsgMhfAcquireGuildTresure{}, network.MSG_MHF_ACQUIRE_GUILD_TRESURE}, + {"MsgMhfAcquireTitle", &MsgMhfAcquireTitle{}, network.MSG_MHF_ACQUIRE_TITLE}, + {"MsgMhfAcquireDistItem", &MsgMhfAcquireDistItem{}, network.MSG_MHF_ACQUIRE_DIST_ITEM}, + {"MsgMhfAcquireMonthlyItem", &MsgMhfAcquireMonthlyItem{}, network.MSG_MHF_ACQUIRE_MONTHLY_ITEM}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.pkt.Opcode(); got != tt.expect { + t.Errorf("Opcode() = %v, want %v", got, tt.expect) + } + }) + } +} + +func TestMsgMhfAcquireGuildTresureParse(t *testing.T) { + tests := []struct { + name string + ackHandle uint32 + huntID uint32 + unk uint8 + }{ + {"basic acquisition", 1, 12345, 0}, + {"large hunt ID", 0xABCDEF12, 0xFFFFFFFF, 1}, + {"zero values", 0, 0, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteUint32(tt.ackHandle) + bf.WriteUint32(tt.huntID) + bf.WriteUint8(tt.unk) + bf.Seek(0, io.SeekStart) + + pkt := &MsgMhfAcquireGuildTresure{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.ackHandle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.ackHandle) + } + if pkt.HuntID != tt.huntID { + t.Errorf("HuntID = %d, want %d", pkt.HuntID, tt.huntID) + } + if pkt.Unk != tt.unk { + t.Errorf("Unk = %d, want %d", pkt.Unk, tt.unk) + } + }) + } +} + +func TestMsgMhfAcquireTitleParse(t *testing.T) { + tests := []struct { + name string + ackHandle uint32 + unk0 uint16 + unk1 uint16 + titleID uint16 + }{ + {"acquire title 1", 1, 0, 0, 1}, + {"acquire title 100", 0x12345678, 10, 20, 100}, + {"max title ID", 0xFFFFFFFF, 0xFFFF, 0xFFFF, 0xFFFF}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteUint32(tt.ackHandle) + bf.WriteUint16(tt.unk0) + bf.WriteUint16(tt.unk1) + bf.WriteUint16(tt.titleID) + bf.Seek(0, io.SeekStart) + + pkt := &MsgMhfAcquireTitle{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.ackHandle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.ackHandle) + } + if pkt.Unk0 != tt.unk0 { + t.Errorf("Unk0 = %d, want %d", pkt.Unk0, tt.unk0) + } + if pkt.Unk1 != tt.unk1 { + t.Errorf("Unk1 = %d, want %d", pkt.Unk1, tt.unk1) + } + if pkt.TitleID != tt.titleID { + t.Errorf("TitleID = %d, want %d", pkt.TitleID, tt.titleID) + } + }) + } +} + +func TestMsgMhfAcquireDistItemParse(t *testing.T) { + tests := []struct { + name string + ackHandle uint32 + distributionType uint8 + distributionID uint32 + }{ + {"type 0", 1, 0, 12345}, + {"type 1", 0xABCD, 1, 67890}, + {"max values", 0xFFFFFFFF, 0xFF, 0xFFFFFFFF}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteUint32(tt.ackHandle) + bf.WriteUint8(tt.distributionType) + bf.WriteUint32(tt.distributionID) + bf.Seek(0, io.SeekStart) + + pkt := &MsgMhfAcquireDistItem{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.ackHandle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.ackHandle) + } + if pkt.DistributionType != tt.distributionType { + t.Errorf("DistributionType = %d, want %d", pkt.DistributionType, tt.distributionType) + } + if pkt.DistributionID != tt.distributionID { + t.Errorf("DistributionID = %d, want %d", pkt.DistributionID, tt.distributionID) + } + }) + } +} + +func TestMsgMhfAcquireMonthlyItemParse(t *testing.T) { + tests := []struct { + name string + ackHandle uint32 + unk0 uint16 + unk1 uint16 + unk2 uint32 + unk3 uint32 + }{ + {"basic", 1, 0, 0, 0, 0}, + {"with values", 100, 10, 20, 30, 40}, + {"max values", 0xFFFFFFFF, 0xFFFF, 0xFFFF, 0xFFFFFFFF, 0xFFFFFFFF}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteUint32(tt.ackHandle) + bf.WriteUint16(tt.unk0) + bf.WriteUint16(tt.unk1) + bf.WriteUint32(tt.unk2) + bf.WriteUint32(tt.unk3) + bf.Seek(0, io.SeekStart) + + pkt := &MsgMhfAcquireMonthlyItem{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.ackHandle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.ackHandle) + } + if pkt.Unk0 != tt.unk0 { + t.Errorf("Unk0 = %d, want %d", pkt.Unk0, tt.unk0) + } + if pkt.Unk1 != tt.unk1 { + t.Errorf("Unk1 = %d, want %d", pkt.Unk1, tt.unk1) + } + if pkt.Unk2 != tt.unk2 { + t.Errorf("Unk2 = %d, want %d", pkt.Unk2, tt.unk2) + } + if pkt.Unk3 != tt.unk3 { + t.Errorf("Unk3 = %d, want %d", pkt.Unk3, tt.unk3) + } + }) + } +} + +func TestAcquirePacketsFromOpcode(t *testing.T) { + acquireOpcodes := []network.PacketID{ + network.MSG_MHF_ACQUIRE_GUILD_TRESURE, + network.MSG_MHF_ACQUIRE_TITLE, + network.MSG_MHF_ACQUIRE_DIST_ITEM, + network.MSG_MHF_ACQUIRE_MONTHLY_ITEM, + } + + for _, opcode := range acquireOpcodes { + t.Run(opcode.String(), func(t *testing.T) { + pkt := FromOpcode(opcode) + if pkt == nil { + t.Fatalf("FromOpcode(%s) returned nil", opcode) + } + if pkt.Opcode() != opcode { + t.Errorf("Opcode() = %s, want %s", pkt.Opcode(), opcode) + } + }) + } +} + +func TestAcquirePacketEdgeCases(t *testing.T) { + t.Run("guild tresure with max hunt ID", func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteUint32(1) + bf.WriteUint32(0xFFFFFFFF) + bf.WriteUint8(255) + bf.Seek(0, io.SeekStart) + + pkt := &MsgMhfAcquireGuildTresure{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.HuntID != 0xFFFFFFFF { + t.Errorf("HuntID = %d, want %d", pkt.HuntID, 0xFFFFFFFF) + } + }) + + t.Run("dist item with all types", func(t *testing.T) { + for i := uint8(0); i < 5; i++ { + bf := byteframe.NewByteFrame() + bf.WriteUint32(1) + bf.WriteUint8(i) + bf.WriteUint32(12345) + bf.Seek(0, io.SeekStart) + + pkt := &MsgMhfAcquireDistItem{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v for type %d", err, i) + } + + if pkt.DistributionType != i { + t.Errorf("DistributionType = %d, want %d", pkt.DistributionType, i) + } + } + }) +} diff --git a/network/mhfpacket/msg_sys_stage_test.go b/network/mhfpacket/msg_sys_stage_test.go new file mode 100644 index 000000000..257b82be7 --- /dev/null +++ b/network/mhfpacket/msg_sys_stage_test.go @@ -0,0 +1,338 @@ +package mhfpacket + +import ( + "io" + "testing" + + "erupe-ce/common/byteframe" + "erupe-ce/network" + "erupe-ce/network/clientctx" +) + +func TestStagePacketOpcodes(t *testing.T) { + tests := []struct { + name string + pkt MHFPacket + expect network.PacketID + }{ + {"MsgSysCreateStage", &MsgSysCreateStage{}, network.MSG_SYS_CREATE_STAGE}, + {"MsgSysEnterStage", &MsgSysEnterStage{}, network.MSG_SYS_ENTER_STAGE}, + {"MsgSysMoveStage", &MsgSysMoveStage{}, network.MSG_SYS_MOVE_STAGE}, + {"MsgSysBackStage", &MsgSysBackStage{}, network.MSG_SYS_BACK_STAGE}, + {"MsgSysLockStage", &MsgSysLockStage{}, network.MSG_SYS_LOCK_STAGE}, + {"MsgSysUnlockStage", &MsgSysUnlockStage{}, network.MSG_SYS_UNLOCK_STAGE}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.pkt.Opcode(); got != tt.expect { + t.Errorf("Opcode() = %v, want %v", got, tt.expect) + } + }) + } +} + +func TestMsgSysCreateStageFields(t *testing.T) { + tests := []struct { + name string + ackHandle uint32 + unk0 uint8 + playerCount uint8 + stageID string + }{ + {"empty stage", 1, 1, 4, ""}, + {"mezeporta", 0x12345678, 2, 8, "sl1Ns200p0a0u0"}, + {"quest room", 100, 1, 4, "q1234"}, + {"max players", 0xFFFFFFFF, 2, 16, "max_stage"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteUint32(tt.ackHandle) + bf.WriteUint8(tt.unk0) + bf.WriteUint8(tt.playerCount) + stageIDBytes := []byte(tt.stageID) + bf.WriteUint8(uint8(len(stageIDBytes))) + bf.WriteBytes(stageIDBytes) + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysCreateStage{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.ackHandle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.ackHandle) + } + if pkt.Unk0 != tt.unk0 { + t.Errorf("Unk0 = %d, want %d", pkt.Unk0, tt.unk0) + } + if pkt.PlayerCount != tt.playerCount { + t.Errorf("PlayerCount = %d, want %d", pkt.PlayerCount, tt.playerCount) + } + if pkt.StageID != tt.stageID { + t.Errorf("StageID = %q, want %q", pkt.StageID, tt.stageID) + } + }) + } +} + +func TestMsgSysEnterStageFields(t *testing.T) { + tests := []struct { + name string + handle uint32 + unkBool uint8 + stageID string + }{ + {"enter town", 1, 0, "town01"}, + {"force enter", 2, 1, "quest_stage"}, + {"rasta bar", 999, 0, "sl1Ns211p0a0u0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteUint32(tt.handle) + bf.WriteUint8(tt.unkBool) + stageIDBytes := []byte(tt.stageID) + bf.WriteUint8(uint8(len(stageIDBytes))) + bf.WriteBytes(stageIDBytes) + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysEnterStage{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.handle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.handle) + } + if pkt.UnkBool != tt.unkBool { + t.Errorf("UnkBool = %d, want %d", pkt.UnkBool, tt.unkBool) + } + if pkt.StageID != tt.stageID { + t.Errorf("StageID = %q, want %q", pkt.StageID, tt.stageID) + } + }) + } +} + +func TestMsgSysMoveStageFields(t *testing.T) { + tests := []struct { + name string + handle uint32 + unkBool uint8 + stageID string + }{ + {"move to area", 1, 0, "area01"}, + {"move to quest", 0xABCD, 1, "quest12345"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteUint32(tt.handle) + bf.WriteUint8(tt.unkBool) + stageIDBytes := []byte(tt.stageID) + bf.WriteUint8(uint8(len(stageIDBytes))) + bf.WriteBytes(stageIDBytes) + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysMoveStage{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.handle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.handle) + } + if pkt.UnkBool != tt.unkBool { + t.Errorf("UnkBool = %d, want %d", pkt.UnkBool, tt.unkBool) + } + if pkt.StageID != tt.stageID { + t.Errorf("StageID = %q, want %q", pkt.StageID, tt.stageID) + } + }) + } +} + +func TestMsgSysLockStageFields(t *testing.T) { + tests := []struct { + name string + handle uint32 + unk0 uint8 + unk1 uint8 + stageID string + }{ + {"lock room", 1, 1, 1, "room01"}, + {"private party", 0x1234, 1, 1, "party_stage"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteUint32(tt.handle) + bf.WriteUint8(tt.unk0) + bf.WriteUint8(tt.unk1) + stageIDBytes := []byte(tt.stageID) + bf.WriteUint8(uint8(len(stageIDBytes))) + bf.WriteBytes(stageIDBytes) + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysLockStage{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.handle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.handle) + } + if pkt.Unk0 != tt.unk0 { + t.Errorf("Unk0 = %d, want %d", pkt.Unk0, tt.unk0) + } + if pkt.Unk1 != tt.unk1 { + t.Errorf("Unk1 = %d, want %d", pkt.Unk1, tt.unk1) + } + if pkt.StageID != tt.stageID { + t.Errorf("StageID = %q, want %q", pkt.StageID, tt.stageID) + } + }) + } +} + +func TestMsgSysUnlockStageFields(t *testing.T) { + tests := []struct { + name string + unk0 uint16 + }{ + {"zero", 0}, + {"typical", 1}, + {"max", 0xFFFF}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteUint16(tt.unk0) + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysUnlockStage{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.Unk0 != tt.unk0 { + t.Errorf("Unk0 = %d, want %d", pkt.Unk0, tt.unk0) + } + }) + } +} + +func TestMsgSysBackStageFields(t *testing.T) { + tests := []struct { + name string + handle uint32 + }{ + {"small handle", 1}, + {"large handle", 0xDEADBEEF}, + {"zero", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bf := byteframe.NewByteFrame() + bf.WriteUint32(tt.handle) + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysBackStage{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.AckHandle != tt.handle { + t.Errorf("AckHandle = %d, want %d", pkt.AckHandle, tt.handle) + } + }) + } +} + +func TestStageIDEdgeCases(t *testing.T) { + t.Run("long stage ID", func(t *testing.T) { + // Stage ID with max length (255 bytes) + longID := make([]byte, 200) + for i := range longID { + longID[i] = 'a' + byte(i%26) + } + + bf := byteframe.NewByteFrame() + bf.WriteUint32(1) + bf.WriteUint8(1) + bf.WriteUint8(4) + bf.WriteUint8(uint8(len(longID))) + bf.WriteBytes(longID) + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysCreateStage{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if pkt.StageID != string(longID) { + t.Errorf("StageID length = %d, want %d", len(pkt.StageID), len(longID)) + } + }) + + t.Run("stage ID with null terminator", func(t *testing.T) { + // String terminated with null byte + stageID := "test\x00extra" + + bf := byteframe.NewByteFrame() + bf.WriteUint32(1) + bf.WriteUint8(0) + bf.WriteUint8(uint8(len(stageID))) + bf.WriteBytes([]byte(stageID)) + bf.Seek(0, io.SeekStart) + + pkt := &MsgSysEnterStage{} + err := pkt.Parse(bf, &clientctx.ClientContext{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Should truncate at null + if pkt.StageID != "test" { + t.Errorf("StageID = %q, want %q (should truncate at null)", pkt.StageID, "test") + } + }) +} + +func TestStagePacketFromOpcode(t *testing.T) { + stageOpcodes := []network.PacketID{ + network.MSG_SYS_CREATE_STAGE, + network.MSG_SYS_ENTER_STAGE, + network.MSG_SYS_BACK_STAGE, + network.MSG_SYS_MOVE_STAGE, + network.MSG_SYS_LOCK_STAGE, + network.MSG_SYS_UNLOCK_STAGE, + } + + for _, opcode := range stageOpcodes { + t.Run(opcode.String(), func(t *testing.T) { + pkt := FromOpcode(opcode) + if pkt == nil { + t.Fatalf("FromOpcode(%s) returned nil", opcode) + } + if pkt.Opcode() != opcode { + t.Errorf("Opcode() = %s, want %s", pkt.Opcode(), opcode) + } + }) + } +} diff --git a/server/channelserver/sys_time_test.go b/server/channelserver/sys_time_test.go new file mode 100644 index 000000000..6fbb5c645 --- /dev/null +++ b/server/channelserver/sys_time_test.go @@ -0,0 +1,167 @@ +package channelserver + +import ( + "testing" + "time" +) + +func TestTimeAdjusted(t *testing.T) { + result := TimeAdjusted() + + // Should return a time in UTC+9 timezone + _, offset := result.Zone() + expectedOffset := 9 * 60 * 60 // 9 hours in seconds + if offset != expectedOffset { + t.Errorf("TimeAdjusted() zone offset = %d, want %d (UTC+9)", offset, expectedOffset) + } + + // The time should be close to current time (within a few seconds) + now := time.Now() + diff := result.Sub(now.In(time.FixedZone("UTC+9", 9*60*60))) + if diff < -time.Second || diff > time.Second { + t.Errorf("TimeAdjusted() time differs from expected by %v", diff) + } +} + +func TestTimeMidnight(t *testing.T) { + midnight := TimeMidnight() + + // Should be at midnight (hour=0, minute=0, second=0, nanosecond=0) + if midnight.Hour() != 0 { + t.Errorf("TimeMidnight() hour = %d, want 0", midnight.Hour()) + } + if midnight.Minute() != 0 { + t.Errorf("TimeMidnight() minute = %d, want 0", midnight.Minute()) + } + if midnight.Second() != 0 { + t.Errorf("TimeMidnight() second = %d, want 0", midnight.Second()) + } + if midnight.Nanosecond() != 0 { + t.Errorf("TimeMidnight() nanosecond = %d, want 0", midnight.Nanosecond()) + } + + // Should be in UTC+9 timezone + _, offset := midnight.Zone() + expectedOffset := 9 * 60 * 60 + if offset != expectedOffset { + t.Errorf("TimeMidnight() zone offset = %d, want %d (UTC+9)", offset, expectedOffset) + } +} + +func TestTimeWeekStart(t *testing.T) { + weekStart := TimeWeekStart() + + // Should be on Monday (weekday = 1) + if weekStart.Weekday() != time.Monday { + t.Errorf("TimeWeekStart() weekday = %v, want Monday", weekStart.Weekday()) + } + + // Should be at midnight + if weekStart.Hour() != 0 || weekStart.Minute() != 0 || weekStart.Second() != 0 { + t.Errorf("TimeWeekStart() should be at midnight, got %02d:%02d:%02d", + weekStart.Hour(), weekStart.Minute(), weekStart.Second()) + } + + // Should be in UTC+9 timezone + _, offset := weekStart.Zone() + expectedOffset := 9 * 60 * 60 + if offset != expectedOffset { + t.Errorf("TimeWeekStart() zone offset = %d, want %d (UTC+9)", offset, expectedOffset) + } + + // Week start should be before or equal to current midnight + midnight := TimeMidnight() + if weekStart.After(midnight) { + t.Errorf("TimeWeekStart() %v should be <= current midnight %v", weekStart, midnight) + } +} + +func TestTimeWeekNext(t *testing.T) { + weekStart := TimeWeekStart() + weekNext := TimeWeekNext() + + // TimeWeekNext should be exactly 7 days after TimeWeekStart + expectedNext := weekStart.Add(time.Hour * 24 * 7) + if !weekNext.Equal(expectedNext) { + t.Errorf("TimeWeekNext() = %v, want %v (7 days after WeekStart)", weekNext, expectedNext) + } + + // Should also be on Monday + if weekNext.Weekday() != time.Monday { + t.Errorf("TimeWeekNext() weekday = %v, want Monday", weekNext.Weekday()) + } + + // Should be at midnight + if weekNext.Hour() != 0 || weekNext.Minute() != 0 || weekNext.Second() != 0 { + t.Errorf("TimeWeekNext() should be at midnight, got %02d:%02d:%02d", + weekNext.Hour(), weekNext.Minute(), weekNext.Second()) + } + + // Should be in the future relative to week start + if !weekNext.After(weekStart) { + t.Errorf("TimeWeekNext() %v should be after TimeWeekStart() %v", weekNext, weekStart) + } +} + +func TestTimeWeekStartSundayEdge(t *testing.T) { + // When today is Sunday, the calculation should go back to last Monday + // This is tested indirectly by verifying the weekday is always Monday + weekStart := TimeWeekStart() + + // Regardless of what day it is now, week start should be Monday + if weekStart.Weekday() != time.Monday { + t.Errorf("TimeWeekStart() on any day should return Monday, got %v", weekStart.Weekday()) + } +} + +func TestTimeMidnightSameDay(t *testing.T) { + adjusted := TimeAdjusted() + midnight := TimeMidnight() + + // Midnight should be on the same day (year, month, day) + if midnight.Year() != adjusted.Year() || + midnight.Month() != adjusted.Month() || + midnight.Day() != adjusted.Day() { + t.Errorf("TimeMidnight() date = %v, want same day as TimeAdjusted() %v", + midnight.Format("2006-01-02"), adjusted.Format("2006-01-02")) + } +} + +func TestTimeWeekDuration(t *testing.T) { + weekStart := TimeWeekStart() + weekNext := TimeWeekNext() + + // Duration between week boundaries should be exactly 7 days + duration := weekNext.Sub(weekStart) + expectedDuration := time.Hour * 24 * 7 + + if duration != expectedDuration { + t.Errorf("Duration between WeekStart and WeekNext = %v, want %v", duration, expectedDuration) + } +} + +func TestTimeZoneConsistency(t *testing.T) { + adjusted := TimeAdjusted() + midnight := TimeMidnight() + weekStart := TimeWeekStart() + weekNext := TimeWeekNext() + + // All times should be in the same timezone (UTC+9) + times := []struct { + name string + time time.Time + }{ + {"TimeAdjusted", adjusted}, + {"TimeMidnight", midnight}, + {"TimeWeekStart", weekStart}, + {"TimeWeekNext", weekNext}, + } + + expectedOffset := 9 * 60 * 60 + for _, tt := range times { + _, offset := tt.time.Zone() + if offset != expectedOffset { + t.Errorf("%s() zone offset = %d, want %d (UTC+9)", tt.name, offset, expectedOffset) + } + } +} diff --git a/server/entranceserver/make_resp_test.go b/server/entranceserver/make_resp_test.go index 2bf2de2ef..0ef4670f3 100644 --- a/server/entranceserver/make_resp_test.go +++ b/server/entranceserver/make_resp_test.go @@ -203,3 +203,199 @@ func TestMakeHeaderDataIntegrity(t *testing.T) { }) } } + +// TestMakeHeaderStructure verifies the internal structure of makeHeader output +func TestMakeHeaderStructure(t *testing.T) { + tests := []struct { + name string + data []byte + respType string + entryCount uint16 + key byte + }{ + {"SV2 response", []byte{0x01, 0x02, 0x03, 0x04}, "SV2", 5, 0x00}, + {"SVR response", []byte{0xAA, 0xBB}, "SVR", 10, 0x10}, + {"USR response", []byte{0x00}, "USR", 1, 0xFF}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := makeHeader(tt.data, tt.respType, tt.entryCount, tt.key) + + // Result should not be empty + if len(result) == 0 { + t.Fatal("makeHeader returned empty result") + } + + // First byte should be the key + if result[0] != tt.key { + t.Errorf("first byte = 0x%X, want 0x%X", result[0], tt.key) + } + + // Decrypt the rest + encrypted := result[1:] + decrypted := DecryptBin8(encrypted, tt.key) + + // First 3 bytes should be respType + if len(decrypted) < 3 { + t.Fatal("decrypted data too short for respType") + } + if string(decrypted[:3]) != tt.respType { + t.Errorf("respType = %s, want %s", string(decrypted[:3]), tt.respType) + } + + // Next 2 bytes should be entry count (big endian) + if len(decrypted) < 5 { + t.Fatal("decrypted data too short for entry count") + } + gotCount := uint16(decrypted[3])<<8 | uint16(decrypted[4]) + if gotCount != tt.entryCount { + t.Errorf("entryCount = %d, want %d", gotCount, tt.entryCount) + } + + // Next 2 bytes should be data length (big endian) + if len(decrypted) < 7 { + t.Fatal("decrypted data too short for data length") + } + gotLen := uint16(decrypted[5])<<8 | uint16(decrypted[6]) + if gotLen != uint16(len(tt.data)) { + t.Errorf("dataLen = %d, want %d", gotLen, len(tt.data)) + } + }) + } +} + +// TestMakeHeaderChecksum verifies that checksum is correctly calculated +func TestMakeHeaderChecksum(t *testing.T) { + data := []byte{0x01, 0x02, 0x03, 0x04, 0x05} + key := byte(0x00) + + result := makeHeader(data, "SV2", 1, key) + + // Decrypt + decrypted := DecryptBin8(result[1:], key) + + // After respType(3) + entryCount(2) + dataLen(2) = 7 bytes + // Next 4 bytes should be checksum + if len(decrypted) < 11 { + t.Fatal("decrypted data too short for checksum") + } + + expectedChecksum := CalcSum32(data) + gotChecksum := uint32(decrypted[7])<<24 | uint32(decrypted[8])<<16 | uint32(decrypted[9])<<8 | uint32(decrypted[10]) + + if gotChecksum != expectedChecksum { + t.Errorf("checksum = 0x%X, want 0x%X", gotChecksum, expectedChecksum) + } +} + +// TestMakeHeaderDataPreservation verifies original data is preserved in output +func TestMakeHeaderDataPreservation(t *testing.T) { + originalData := []byte{0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE} + key := byte(0x00) + + result := makeHeader(originalData, "SV2", 1, key) + + // Decrypt + decrypted := DecryptBin8(result[1:], key) + + // Header: respType(3) + entryCount(2) + dataLen(2) + checksum(4) = 11 bytes + // Data starts at offset 11 + if len(decrypted) < 11+len(originalData) { + t.Fatalf("decrypted data too short: got %d, want at least %d", len(decrypted), 11+len(originalData)) + } + + recoveredData := decrypted[11 : 11+len(originalData)] + if !bytes.Equal(recoveredData, originalData) { + t.Errorf("recovered data = %X, want %X", recoveredData, originalData) + } +} + +// TestMakeHeaderEmptyDataNoChecksum verifies empty data doesn't include checksum +func TestMakeHeaderEmptyDataNoChecksum(t *testing.T) { + result := makeHeader([]byte{}, "SV2", 0, 0x00) + + // Decrypt + decrypted := DecryptBin8(result[1:], 0x00) + + // Header without data: respType(3) + entryCount(2) + dataLen(2) = 7 bytes + // No checksum for empty data + if len(decrypted) != 7 { + t.Errorf("decrypted length = %d, want 7 (no checksum for empty data)", len(decrypted)) + } + + // Verify data length is 0 + gotLen := uint16(decrypted[5])<<8 | uint16(decrypted[6]) + if gotLen != 0 { + t.Errorf("dataLen = %d, want 0", gotLen) + } +} + +// TestMakeHeaderKeyVariation verifies different keys produce different output +func TestMakeHeaderKeyVariation(t *testing.T) { + data := []byte{0x01, 0x02, 0x03} + + result1 := makeHeader(data, "SV2", 1, 0x00) + result2 := makeHeader(data, "SV2", 1, 0x55) + result3 := makeHeader(data, "SV2", 1, 0xAA) + + // All results should have different first bytes (the key) + if result1[0] == result2[0] || result2[0] == result3[0] { + t.Error("different keys should produce different first bytes") + } + + // Encrypted portions should also differ + if bytes.Equal(result1[1:], result2[1:]) { + t.Error("different keys should produce different encrypted data") + } + if bytes.Equal(result2[1:], result3[1:]) { + t.Error("different keys should produce different encrypted data") + } +} + +// TestCalcSum32EdgeCases tests edge cases for the checksum function +func TestCalcSum32EdgeCases(t *testing.T) { + tests := []struct { + name string + data []byte + }{ + {"single byte", []byte{0x00}}, + {"all zeros", make([]byte, 10)}, + {"all ones", bytes.Repeat([]byte{0xFF}, 10)}, + {"alternating", []byte{0xAA, 0x55, 0xAA, 0x55}}, + {"sequential", []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Should not panic + result := CalcSum32(tt.data) + + // Result should be deterministic + result2 := CalcSum32(tt.data) + if result != result2 { + t.Errorf("CalcSum32 not deterministic: got %X and %X", result, result2) + } + }) + } +} + +// TestCalcSum32Uniqueness verifies different inputs produce different checksums +func TestCalcSum32Uniqueness(t *testing.T) { + inputs := [][]byte{ + {0x01}, + {0x02}, + {0x01, 0x02}, + {0x02, 0x01}, + {0x01, 0x02, 0x03}, + } + + checksums := make(map[uint32]int) + for i, input := range inputs { + sum := CalcSum32(input) + if prevIdx, exists := checksums[sum]; exists { + t.Errorf("collision: input %d and %d both produce checksum 0x%X", prevIdx, i, sum) + } + checksums[sum] = i + } +} diff --git a/server/signv2server/endpoints_test.go b/server/signv2server/endpoints_test.go index 4b19d211e..75eefdd0b 100644 --- a/server/signv2server/endpoints_test.go +++ b/server/signv2server/endpoints_test.go @@ -347,3 +347,374 @@ func TestServerConfig(t *testing.T) { t.Error("Config.Logger should be nil when not set") } } + +// Note: Tests that require database operations are skipped when no DB is available. +// The following tests validate the structure and JSON handling of endpoints. + +// TestLoginRequestStructure tests that login request JSON structure is correct +func TestLoginRequestStructure(t *testing.T) { + // Test JSON marshaling/unmarshaling of request structure + reqData := struct { + Username string `json:"username"` + Password string `json:"password"` + }{ + Username: "testuser", + Password: "testpass", + } + + data, err := json.Marshal(reqData) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var decoded struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded.Username != reqData.Username { + t.Errorf("Username = %s, want %s", decoded.Username, reqData.Username) + } + if decoded.Password != reqData.Password { + t.Errorf("Password = %s, want %s", decoded.Password, reqData.Password) + } +} + +// TestRegisterRequestStructure tests that register request JSON structure is correct +func TestRegisterRequestStructure(t *testing.T) { + reqData := struct { + Username string `json:"username"` + Password string `json:"password"` + }{ + Username: "newuser", + Password: "newpass", + } + + data, err := json.Marshal(reqData) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var decoded struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded.Username != reqData.Username { + t.Errorf("Username = %s, want %s", decoded.Username, reqData.Username) + } + if decoded.Password != reqData.Password { + t.Errorf("Password = %s, want %s", decoded.Password, reqData.Password) + } +} + +// TestCreateCharacterRequestStructure tests that create character request JSON structure is correct +func TestCreateCharacterRequestStructure(t *testing.T) { + reqData := struct { + Token string `json:"token"` + }{ + Token: "test-token-12345", + } + + data, err := json.Marshal(reqData) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var decoded struct { + Token string `json:"token"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded.Token != reqData.Token { + t.Errorf("Token = %s, want %s", decoded.Token, reqData.Token) + } +} + +// TestDeleteCharacterRequestStructure tests that delete character request JSON structure is correct +func TestDeleteCharacterRequestStructure(t *testing.T) { + reqData := struct { + Token string `json:"token"` + CharID int `json:"id"` + }{ + Token: "test-token", + CharID: 12345, + } + + data, err := json.Marshal(reqData) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var decoded struct { + Token string `json:"token"` + CharID int `json:"id"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded.Token != reqData.Token { + t.Errorf("Token = %s, want %s", decoded.Token, reqData.Token) + } + if decoded.CharID != reqData.CharID { + t.Errorf("CharID = %d, want %d", decoded.CharID, reqData.CharID) + } +} + +// TestLoginResponseStructure tests the login response JSON structure +func TestLoginResponseStructure(t *testing.T) { + respData := struct { + Token string `json:"token"` + Characters []Character `json:"characters"` + }{ + Token: "login-token-abc123", + Characters: []Character{ + {ID: 1, Name: "Hunter1", IsFemale: false, Weapon: 3, HR: 100, GR: 10}, + {ID: 2, Name: "Hunter2", IsFemale: true, Weapon: 7, HR: 200, GR: 20}, + }, + } + + data, err := json.Marshal(respData) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var decoded struct { + Token string `json:"token"` + Characters []Character `json:"characters"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded.Token != respData.Token { + t.Errorf("Token = %s, want %s", decoded.Token, respData.Token) + } + if len(decoded.Characters) != len(respData.Characters) { + t.Errorf("Characters count = %d, want %d", len(decoded.Characters), len(respData.Characters)) + } +} + +// TestRegisterResponseStructure tests the register response JSON structure +func TestRegisterResponseStructure(t *testing.T) { + respData := struct { + Token string `json:"token"` + }{ + Token: "register-token-xyz789", + } + + data, err := json.Marshal(respData) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var decoded struct { + Token string `json:"token"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded.Token != respData.Token { + t.Errorf("Token = %s, want %s", decoded.Token, respData.Token) + } +} + +// TestCreateCharacterResponseStructure tests the create character response JSON structure +func TestCreateCharacterResponseStructure(t *testing.T) { + respData := struct { + CharID int `json:"id"` + }{ + CharID: 42, + } + + data, err := json.Marshal(respData) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var decoded struct { + CharID int `json:"id"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded.CharID != respData.CharID { + t.Errorf("CharID = %d, want %d", decoded.CharID, respData.CharID) + } +} + +// TestLauncherContentType tests that Launcher sets correct content type +func TestLauncherContentType(t *testing.T) { + s := mockServer() + + req := httptest.NewRequest("GET", "/launcher", nil) + w := httptest.NewRecorder() + + s.Launcher(w, req) + + // Note: The handler sets header after WriteHeader, so we check response body is JSON + resp := w.Result() + var data map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + t.Errorf("Launcher() response is not valid JSON: %v", err) + } +} + +// TestLauncherMessageDates tests that launcher message dates are valid timestamps +func TestLauncherMessageDates(t *testing.T) { + s := mockServer() + + req := httptest.NewRequest("GET", "/launcher", nil) + w := httptest.NewRecorder() + + s.Launcher(w, req) + + var data struct { + Important []LauncherMessage `json:"important"` + Normal []LauncherMessage `json:"normal"` + } + json.NewDecoder(w.Result().Body).Decode(&data) + + // All dates should be positive unix timestamps + for _, msg := range data.Important { + if msg.Date <= 0 { + t.Errorf("Important message date should be positive, got %d", msg.Date) + } + } + for _, msg := range data.Normal { + if msg.Date <= 0 { + t.Errorf("Normal message date should be positive, got %d", msg.Date) + } + } +} + +// TestLauncherMessageLinks tests that launcher message links are valid URLs +func TestLauncherMessageLinks(t *testing.T) { + s := mockServer() + + req := httptest.NewRequest("GET", "/launcher", nil) + w := httptest.NewRecorder() + + s.Launcher(w, req) + + var data struct { + Important []LauncherMessage `json:"important"` + Normal []LauncherMessage `json:"normal"` + } + json.NewDecoder(w.Result().Body).Decode(&data) + + // All links should start with http:// or https:// + for _, msg := range data.Important { + if len(msg.Link) < 7 || (msg.Link[:7] != "http://" && msg.Link[:8] != "https://") { + t.Errorf("Important message link should be a URL, got %q", msg.Link) + } + } + for _, msg := range data.Normal { + if len(msg.Link) < 7 || (msg.Link[:7] != "http://" && msg.Link[:8] != "https://") { + t.Errorf("Normal message link should be a URL, got %q", msg.Link) + } + } +} + +// TestCharacterStructJSONMarshal tests Character struct marshals correctly +func TestCharacterStructJSONMarshal(t *testing.T) { + char := Character{ + ID: 42, + Name: "TestHunter", + IsFemale: true, + Weapon: 7, + HR: 999, + GR: 100, + LastLogin: 1609459200, + } + + data, err := json.Marshal(char) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var decoded Character + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded.ID != char.ID { + t.Errorf("ID = %d, want %d", decoded.ID, char.ID) + } + if decoded.Name != char.Name { + t.Errorf("Name = %s, want %s", decoded.Name, char.Name) + } + if decoded.IsFemale != char.IsFemale { + t.Errorf("IsFemale = %v, want %v", decoded.IsFemale, char.IsFemale) + } + if decoded.Weapon != char.Weapon { + t.Errorf("Weapon = %d, want %d", decoded.Weapon, char.Weapon) + } + if decoded.HR != char.HR { + t.Errorf("HR = %d, want %d", decoded.HR, char.HR) + } + if decoded.GR != char.GR { + t.Errorf("GR = %d, want %d", decoded.GR, char.GR) + } + if decoded.LastLogin != char.LastLogin { + t.Errorf("LastLogin = %d, want %d", decoded.LastLogin, char.LastLogin) + } +} + +// TestLauncherMessageJSONMarshal tests LauncherMessage struct marshals correctly +func TestLauncherMessageJSONMarshal(t *testing.T) { + msg := LauncherMessage{ + Message: "Test Announcement", + Date: 1609459200, + Link: "https://example.com/news", + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var decoded LauncherMessage + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded.Message != msg.Message { + t.Errorf("Message = %s, want %s", decoded.Message, msg.Message) + } + if decoded.Date != msg.Date { + t.Errorf("Date = %d, want %d", decoded.Date, msg.Date) + } + if decoded.Link != msg.Link { + t.Errorf("Link = %s, want %s", decoded.Link, msg.Link) + } +} + +// TestEndpointHTTPMethods tests that endpoints respond to correct HTTP methods +func TestEndpointHTTPMethods(t *testing.T) { + s := mockServer() + + // Launcher should respond to GET + t.Run("Launcher GET", func(t *testing.T) { + req := httptest.NewRequest("GET", "/launcher", nil) + w := httptest.NewRecorder() + s.Launcher(w, req) + if w.Result().StatusCode != http.StatusOK { + t.Errorf("Launcher() GET status = %d, want %d", w.Result().StatusCode, http.StatusOK) + } + }) + + // Note: Login, Register, CreateCharacter, DeleteCharacter require database + // and cannot be tested without mocking the database connection +}