diff --git a/agents/robot_test.go b/agents/robot_test.go index 0076347..9e8236d 100644 --- a/agents/robot_test.go +++ b/agents/robot_test.go @@ -14,8 +14,20 @@ import ( ) // newTestRobot creates a minimal Robot for testing without requiring -// Matrix or network. Fields are initialized directly. +// Matrix or network. Fields are initialized directly. Uses standard "!" prefix. func newTestRobot(t *testing.T) *Robot { + t.Helper() + return newTestRobotWithPrefix(t, "!") +} + +// newTestRobotNoPrefix creates a minimal Robot with command_prefix: "" (no prefix). +func newTestRobotNoPrefix(t *testing.T) *Robot { + t.Helper() + return newTestRobotWithPrefix(t, "") +} + +// newTestRobotWithPrefix creates a Robot with the given command prefix. +func newTestRobotWithPrefix(t *testing.T, prefix string) *Robot { t.Helper() cfg := &config.AgentConfig{ Agent: config.AgentMeta{ @@ -25,6 +37,11 @@ func newTestRobot(t *testing.T) *Robot { Description: "robot for tests", Version: "1.0.0", }, + Matrix: config.MatrixCfg{ + Filters: config.FiltersCfg{ + CommandPrefix: prefix, + }, + }, } r := &Robot{ cfg: cfg, @@ -288,3 +305,66 @@ func TestRobotBuiltinCommandCount(t *testing.T) { } } } + +// ── No-prefix command tests ─────────────────────────────────────────────── + +// TestRobotNoPrefixCmdHelp verifies that help shows commands without ! prefix +// when command_prefix is "". +func TestRobotNoPrefixCmdHelp(t *testing.T) { + r := newTestRobotNoPrefix(t) + + reply := r.cmdHelp(context.Background(), decision.MessageContext{}) + + if !strings.Contains(reply, "Comandos disponibles") { + t.Error("help reply missing header") + } + + // Commands should appear WITHOUT ! prefix + for _, cmd := range []string{"help", "ping", "status", "info", "version"} { + // Should contain the command name + if !strings.Contains(reply, cmd) { + t.Errorf("help reply missing command %s", cmd) + } + // Should NOT contain "!cmd" as usage (but might contain it elsewhere) + if strings.Contains(reply, "!"+cmd) { + t.Errorf("help reply should not show !%s in no-prefix mode", cmd) + } + } +} + +// TestRobotNoPrefixCmdHelpWithCustom verifies custom commands in no-prefix mode. +func TestRobotNoPrefixCmdHelpWithCustom(t *testing.T) { + r := newTestRobotNoPrefix(t) + + r.RegisterCommand( + command.Spec{Name: "deploy", Description: "Deploy to env", Usage: "deploy "}, + func(_ context.Context, _ decision.MessageContext) string { return "deployed" }, + ) + + reply := r.cmdHelp(context.Background(), decision.MessageContext{}) + + if !strings.Contains(reply, "Comandos del robot") { + t.Error("help reply missing 'Comandos del robot' section") + } + if !strings.Contains(reply, "deploy") { + t.Error("help reply missing custom command deploy") + } +} + +// TestRobotNoPrefixSameBuiltins verifies that no-prefix robots have the +// same set of built-in commands as standard robots. +func TestRobotNoPrefixSameBuiltins(t *testing.T) { + standard := newTestRobot(t) + noPrefix := newTestRobotNoPrefix(t) + + if len(standard.commands) != len(noPrefix.commands) { + t.Errorf("command count mismatch: standard=%d, noPrefix=%d", + len(standard.commands), len(noPrefix.commands)) + } + + for name := range standard.commands { + if _, ok := noPrefix.commands[name]; !ok { + t.Errorf("no-prefix robot missing command %q", name) + } + } +} diff --git a/pkg/message/parse_test.go b/pkg/message/parse_test.go new file mode 100644 index 0000000..4145f0c --- /dev/null +++ b/pkg/message/parse_test.go @@ -0,0 +1,210 @@ +package message + +import ( + "testing" +) + +func TestParse_CommandWithPrefix(t *testing.T) { + opts := ParseOptions{ + CommandPrefix: "!", + BotUserID: "@bot:server", + } + + tests := []struct { + name string + body string + wantCmd string + wantArgs []string + }{ + { + name: "standard command", + body: "!help", + wantCmd: "help", + }, + { + name: "command with args", + body: "!deploy prod", + wantCmd: "deploy", + wantArgs: []string{"prod"}, + }, + { + name: "no prefix means no command", + body: "help", + wantCmd: "", + }, + { + name: "normal message", + body: "hola mundo", + wantCmd: "", + }, + { + name: "command is lowercased", + body: "!HELP", + wantCmd: "help", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := Parse(tt.body, "@user:server", "!room:server", 0, false, opts) + if ctx.Command != tt.wantCmd { + t.Errorf("Command = %q, want %q", ctx.Command, tt.wantCmd) + } + if tt.wantArgs != nil { + if len(ctx.Args) != len(tt.wantArgs) { + t.Errorf("Args = %v, want %v", ctx.Args, tt.wantArgs) + } + for i, arg := range tt.wantArgs { + if i < len(ctx.Args) && ctx.Args[i] != arg { + t.Errorf("Args[%d] = %q, want %q", i, ctx.Args[i], arg) + } + } + } + }) + } +} + +func TestParse_CommandWithoutPrefix(t *testing.T) { + opts := ParseOptions{ + CommandPrefix: "", + BotUserID: "@bot:server", + } + + tests := []struct { + name string + body string + wantCmd string + wantArgs []string + }{ + { + name: "bare command", + body: "help", + wantCmd: "help", + }, + { + name: "bare command with args", + body: "deploy prod", + wantCmd: "deploy", + wantArgs: []string{"prod"}, + }, + { + name: "bang prefix stripped for backward compatibility", + body: "!help", + wantCmd: "help", + }, + { + name: "bang prefix stripped with args", + body: "!deploy staging", + wantCmd: "deploy", + wantArgs: []string{"staging"}, + }, + { + name: "first token is command", + body: "hola mundo", + wantCmd: "hola", + wantArgs: []string{"mundo"}, + }, + { + name: "command is lowercased", + body: "HELP", + wantCmd: "help", + }, + { + name: "bang-only message produces no command", + body: "!", + wantCmd: "", + }, + { + name: "empty message", + body: "", + wantCmd: "", + }, + { + name: "whitespace-only message", + body: " ", + wantCmd: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := Parse(tt.body, "@user:server", "!room:server", 0, false, opts) + if ctx.Command != tt.wantCmd { + t.Errorf("Command = %q, want %q", ctx.Command, tt.wantCmd) + } + if tt.wantArgs != nil { + if len(ctx.Args) != len(tt.wantArgs) { + t.Errorf("Args = %v, want %v", ctx.Args, tt.wantArgs) + } + for i, arg := range tt.wantArgs { + if i < len(ctx.Args) && ctx.Args[i] != arg { + t.Errorf("Args[%d] = %q, want %q", i, ctx.Args[i], arg) + } + } + } + }) + } +} + +func TestParse_MentionDetection(t *testing.T) { + // Ensure mention detection still works regardless of command prefix mode. + botUID := "@bot:server" + + tests := []struct { + name string + body string + mentions []string + wantMention bool + }{ + { + name: "mentioned via m.mentions", + body: "hello", + mentions: []string{botUID}, + wantMention: true, + }, + { + name: "mentioned via body text", + body: "hey @bot:server what's up", + wantMention: true, + }, + { + name: "not mentioned", + body: "hello world", + wantMention: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := ParseOptions{ + CommandPrefix: "!", + BotUserID: botUID, + MentionedUserIDs: tt.mentions, + } + ctx := Parse(tt.body, "@user:server", "!room:server", 0, false, opts) + if ctx.IsMention != tt.wantMention { + t.Errorf("IsMention = %v, want %v", ctx.IsMention, tt.wantMention) + } + }) + } +} + +func TestParse_NoPrefixBackwardCompatibility(t *testing.T) { + // Regression: when prefix is "" and user sends "!help", the result should be + // identical to when prefix is "!" and user sends "!help". + prefixed := Parse("!help", "@user:server", "!room:server", 0, false, ParseOptions{ + CommandPrefix: "!", + BotUserID: "@bot:server", + }) + noPrefix := Parse("!help", "@user:server", "!room:server", 0, false, ParseOptions{ + CommandPrefix: "", + BotUserID: "@bot:server", + }) + + if prefixed.Command != noPrefix.Command { + t.Errorf("Command mismatch: prefixed=%q, noPrefix=%q", prefixed.Command, noPrefix.Command) + } + if len(prefixed.Args) != len(noPrefix.Args) { + t.Errorf("Args length mismatch: prefixed=%v, noPrefix=%v", prefixed.Args, noPrefix.Args) + } +}