diff --git a/server/channelserver/handlers_commands_test.go b/server/channelserver/handlers_commands_test.go new file mode 100644 index 000000000..607458d44 --- /dev/null +++ b/server/channelserver/handlers_commands_test.go @@ -0,0 +1,605 @@ +package channelserver + +import ( + "errors" + "testing" + "time" + + cfg "erupe-ce/config" +) + +// --- mockUserRepoCommands --- + +type mockUserRepoCommands struct { + mockUserRepoGacha + + opResult bool + + // Ban + bannedUID uint32 + banExpiry *time.Time + banErr error + foundUID uint32 + foundName string + findErr error + + // Timer + timerState bool + timerSetCalled bool + timerNewState bool + + // PSN + psnCount int + psnSetID string + + // Discord + discordToken string + discordGetErr error + discordSetTok string + + // Rights + rightsVal uint32 + setRightsVal uint32 +} + +func (m *mockUserRepoCommands) IsOp(_ uint32) (bool, error) { return m.opResult, nil } +func (m *mockUserRepoCommands) GetByIDAndUsername(_ uint32) (uint32, string, error) { + return m.foundUID, m.foundName, m.findErr +} +func (m *mockUserRepoCommands) BanUser(uid uint32, exp *time.Time) error { + m.bannedUID = uid + m.banExpiry = exp + return m.banErr +} +func (m *mockUserRepoCommands) GetTimer(_ uint32) (bool, error) { return m.timerState, nil } +func (m *mockUserRepoCommands) SetTimer(_ uint32, v bool) error { + m.timerSetCalled = true + m.timerNewState = v + return nil +} +func (m *mockUserRepoCommands) CountByPSNID(_ string) (int, error) { return m.psnCount, nil } +func (m *mockUserRepoCommands) SetPSNID(_ uint32, id string) error { + m.psnSetID = id + return nil +} +func (m *mockUserRepoCommands) GetDiscordToken(_ uint32) (string, error) { + return m.discordToken, m.discordGetErr +} +func (m *mockUserRepoCommands) SetDiscordToken(_ uint32, tok string) error { + m.discordSetTok = tok + return nil +} +func (m *mockUserRepoCommands) GetRights(_ uint32) (uint32, error) { return m.rightsVal, nil } +func (m *mockUserRepoCommands) SetRights(_ uint32, v uint32) error { + m.setRightsVal = v + return nil +} + +// --- helpers --- + +func setupCommandsMap(allEnabled bool) { + commands = map[string]cfg.Command{ + "Ban": {Name: "Ban", Prefix: "ban", Enabled: allEnabled}, + "Timer": {Name: "Timer", Prefix: "timer", Enabled: allEnabled}, + "PSN": {Name: "PSN", Prefix: "psn", Enabled: allEnabled}, + "Reload": {Name: "Reload", Prefix: "reload", Enabled: allEnabled}, + "KeyQuest": {Name: "KeyQuest", Prefix: "kqf", Enabled: allEnabled}, + "Rights": {Name: "Rights", Prefix: "rights", Enabled: allEnabled}, + "Course": {Name: "Course", Prefix: "course", Enabled: allEnabled}, + "Raviente": {Name: "Raviente", Prefix: "ravi", Enabled: allEnabled}, + "Teleport": {Name: "Teleport", Prefix: "tp", Enabled: allEnabled}, + "Discord": {Name: "Discord", Prefix: "discord", Enabled: allEnabled}, + "Playtime": {Name: "Playtime", Prefix: "playtime", Enabled: allEnabled}, + "Help": {Name: "Help", Prefix: "help", Enabled: allEnabled}, + } +} + +func createCommandSession(repo *mockUserRepoCommands) *Session { + server := createMockServer() + server.erupeConfig.CommandPrefix = "!" + server.userRepo = repo + server.charRepo = newMockCharacterRepo() + session := createMockSession(1, server) + session.userID = 1 + return session +} + +func drainChatResponses(s *Session) int { + count := 0 + for { + select { + case <-s.sendPackets: + count++ + default: + return count + } + } +} + +// --- Timer --- + +func TestParseChatCommand_Timer_TogglesOn(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{timerState: false} + s := createCommandSession(repo) + + parseChatCommand(s, "!timer") + + if !repo.timerSetCalled { + t.Fatal("SetTimer should be called") + } + if !repo.timerNewState { + t.Error("timer should toggle from false to true") + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +func TestParseChatCommand_Timer_TogglesOff(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{timerState: true} + s := createCommandSession(repo) + + parseChatCommand(s, "!timer") + + if !repo.timerSetCalled { + t.Fatal("SetTimer should be called") + } + if repo.timerNewState { + t.Error("timer should toggle from true to false") + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +func TestParseChatCommand_Timer_DisabledNonOp(t *testing.T) { + setupCommandsMap(false) + repo := &mockUserRepoCommands{opResult: false} + s := createCommandSession(repo) + + parseChatCommand(s, "!timer") + + if repo.timerSetCalled { + t.Error("SetTimer should not be called when disabled for non-op") + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1 (disabled message)", n) + } +} + +func TestParseChatCommand_DisabledCommand_OpCanStillUse(t *testing.T) { + setupCommandsMap(false) + repo := &mockUserRepoCommands{opResult: true, timerState: false} + s := createCommandSession(repo) + + parseChatCommand(s, "!timer") + + if !repo.timerSetCalled { + t.Error("op should be able to use disabled commands") + } +} + +// --- PSN --- + +func TestParseChatCommand_PSN_Success(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{psnCount: 0} + s := createCommandSession(repo) + + parseChatCommand(s, "!psn MyPSNID") + + if repo.psnSetID != "MyPSNID" { + t.Errorf("PSN ID = %q, want %q", repo.psnSetID, "MyPSNID") + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +func TestParseChatCommand_PSN_AlreadyExists(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{psnCount: 1} + s := createCommandSession(repo) + + parseChatCommand(s, "!psn TakenID") + + if repo.psnSetID != "" { + t.Error("PSN should not be set when ID already exists") + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +func TestParseChatCommand_PSN_MissingArgs(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{} + s := createCommandSession(repo) + + parseChatCommand(s, "!psn") + + if repo.psnSetID != "" { + t.Error("PSN should not be set with missing args") + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +// --- Rights --- + +func TestParseChatCommand_Rights_Success(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{} + s := createCommandSession(repo) + + parseChatCommand(s, "!rights 30") + + if repo.setRightsVal != 30 { + t.Errorf("rights = %d, want 30", repo.setRightsVal) + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +func TestParseChatCommand_Rights_MissingArgs(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{} + s := createCommandSession(repo) + + parseChatCommand(s, "!rights") + + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +// --- Discord --- + +func TestParseChatCommand_Discord_ExistingToken(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{discordToken: "abc-123"} + s := createCommandSession(repo) + + parseChatCommand(s, "!discord") + + if repo.discordSetTok != "" { + t.Error("should not generate new token when existing one found") + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +func TestParseChatCommand_Discord_NewToken(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{discordGetErr: errors.New("not found")} + s := createCommandSession(repo) + + parseChatCommand(s, "!discord") + + if repo.discordSetTok == "" { + t.Error("should generate and set a new token") + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +// --- Playtime --- + +func TestParseChatCommand_Playtime(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{} + s := createCommandSession(repo) + s.playtime = 3661 // 1h 1m 1s + s.playtimeTime = time.Now() + + parseChatCommand(s, "!playtime") + + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +// --- Help --- + +func TestParseChatCommand_Help_ListsCommands(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{} + s := createCommandSession(repo) + + parseChatCommand(s, "!help") + + count := drainChatResponses(s) + if count != len(commands) { + t.Errorf("help messages = %d, want %d (one per enabled command)", count, len(commands)) + } +} + +// --- Ban --- + +func TestParseChatCommand_Ban_Success(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{ + opResult: true, + foundUID: 42, + foundName: "TestUser", + } + s := createCommandSession(repo) + + // "211111" converts to CID 1 via ConvertCID (char '2' = value 1) + parseChatCommand(s, "!ban 211111") + + if repo.bannedUID != 42 { + t.Errorf("banned UID = %d, want 42", repo.bannedUID) + } + if repo.banExpiry != nil { + t.Error("expiry should be nil for permanent ban") + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +func TestParseChatCommand_Ban_WithDuration(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{ + opResult: true, + foundUID: 42, + foundName: "TestUser", + } + s := createCommandSession(repo) + + parseChatCommand(s, "!ban 211111 30d") + + if repo.bannedUID != 42 { + t.Errorf("banned UID = %d, want 42", repo.bannedUID) + } + if repo.banExpiry == nil { + t.Fatal("expiry should not be nil for timed ban") + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +func TestParseChatCommand_Ban_NonOp(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{opResult: false} + s := createCommandSession(repo) + + parseChatCommand(s, "!ban 211111") + + if repo.bannedUID != 0 { + t.Error("non-op should not be able to ban") + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1 (noOp message)", n) + } +} + +func TestParseChatCommand_Ban_InvalidCID(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{opResult: true} + s := createCommandSession(repo) + + // "abc" is not 6 chars, ConvertCID returns 0 + parseChatCommand(s, "!ban abc") + + if repo.bannedUID != 0 { + t.Error("invalid CID should not result in a ban") + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +func TestParseChatCommand_Ban_UserNotFound(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{ + opResult: true, + findErr: errors.New("not found"), + } + s := createCommandSession(repo) + + parseChatCommand(s, "!ban 211111") + + if repo.bannedUID != 0 { + t.Error("should not ban when user not found") + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1 (noUser message)", n) + } +} + +func TestParseChatCommand_Ban_MissingArgs(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{opResult: true} + s := createCommandSession(repo) + + parseChatCommand(s, "!ban") + + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +func TestParseChatCommand_Ban_DurationUnits(t *testing.T) { + tests := []struct { + name string + duration string + }{ + {"seconds", "10s"}, + {"minutes", "5m"}, + {"hours", "2h"}, + {"days", "30d"}, + {"months", "6mo"}, + {"years", "1y"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{ + opResult: true, + foundUID: 1, + foundName: "User", + } + s := createCommandSession(repo) + + parseChatCommand(s, "!ban 211111 "+tt.duration) + + if repo.banExpiry == nil { + t.Errorf("expiry should not be nil for duration %s", tt.duration) + } + }) + } +} + +// --- Teleport --- + +func TestParseChatCommand_Teleport_Success(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{} + s := createCommandSession(repo) + + parseChatCommand(s, "!tp 100 200") + + // Teleport sends a CastedBinary + a chat message = 2 packets + if n := drainChatResponses(s); n != 2 { + t.Errorf("packets = %d, want 2 (teleport + message)", n) + } +} + +func TestParseChatCommand_Teleport_MissingArgs(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{} + s := createCommandSession(repo) + + parseChatCommand(s, "!tp 100") + + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +// --- KeyQuest --- + +func TestParseChatCommand_KeyQuest_VersionCheck(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{} + s := createCommandSession(repo) + s.server.erupeConfig.RealClientMode = cfg.S6 // below G10 + + parseChatCommand(s, "!kqf get") + + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1 (version error)", n) + } +} + +func TestParseChatCommand_KeyQuest_Get(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{} + s := createCommandSession(repo) + s.server.erupeConfig.RealClientMode = cfg.ZZ + s.kqf = []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} + + parseChatCommand(s, "!kqf get") + + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +func TestParseChatCommand_KeyQuest_Set(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{} + s := createCommandSession(repo) + s.server.erupeConfig.RealClientMode = cfg.ZZ + + parseChatCommand(s, "!kqf set 0102030405060708") + + if !s.kqfOverride { + t.Error("kqfOverride should be true after set") + } + if len(s.kqf) != 8 { + t.Errorf("kqf length = %d, want 8", len(s.kqf)) + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} + +func TestParseChatCommand_KeyQuest_SetInvalid(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{} + s := createCommandSession(repo) + s.server.erupeConfig.RealClientMode = cfg.ZZ + + parseChatCommand(s, "!kqf set ABC") // not 16 hex chars + + if s.kqfOverride { + t.Error("kqfOverride should not be set with invalid hex") + } + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1 (error message)", n) + } +} + +// --- Raviente --- + +func TestParseChatCommand_Raviente_NoSemaphore(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{} + s := createCommandSession(repo) + + parseChatCommand(s, "!ravi start") + + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1 (noPlayers message)", n) + } +} + +func TestParseChatCommand_Raviente_MissingArgs(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{} + s := createCommandSession(repo) + + parseChatCommand(s, "!ravi") + + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1 (error message)", n) + } +} + +// --- Course --- + +func TestParseChatCommand_Course_MissingArgs(t *testing.T) { + setupCommandsMap(true) + repo := &mockUserRepoCommands{} + s := createCommandSession(repo) + + parseChatCommand(s, "!course") + + if n := drainChatResponses(s); n != 1 { + t.Errorf("chat responses = %d, want 1 (error message)", n) + } +} + +// --- sendServerChatMessage --- + +func TestSendServerChatMessage_CommandsContext(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + sendServerChatMessage(session, "Hello, World!") + + if n := drainChatResponses(session); n != 1 { + t.Errorf("chat responses = %d, want 1", n) + } +} +