package bot import ( "os" "path/filepath" "strings" "testing" ) func mustRecipe(t *testing.T, src string) *Recipe { t.Helper() path := filepath.Join(t.TempDir(), "r.recipe.json") if err := os.WriteFile(path, []byte(src), 0o600); err != nil { t.Fatal(err) } r, err := LoadRecipe(path) if err != nil { t.Fatalf("LoadRecipe: %v", err) } return r } func loadErr(t *testing.T, src string) error { t.Helper() path := filepath.Join(t.TempDir(), "r.recipe.json") if err := os.WriteFile(path, []byte(src), 0o600); err != nil { t.Fatal(err) } _, err := LoadRecipe(path) return err } // TestRecipeValidation pins fail-closed: everything outside the palette is a // load error, never a silently different behaviour. func TestRecipeValidation(t *testing.T) { bad := map[string]string{ "empty rules": `{"rules":[]}`, "unknown trigger": `{"rules":[{"on":"webhook","action":"reply","text":"x"}]}`, "unknown action": `{"rules":[{"on":"message","action":"deploy"}]}`, "unknown match": `{"rules":[{"on":"message","match":"sometimes","action":"commands"}]}`, "reply without text": `{"rules":[{"on":"message","action":"reply"}]}`, "commands with text": `{"rules":[{"on":"message","action":"commands","text":"x"}]}`, "bad regex": `{"rules":[{"on":"message","action":"reply","text":"x","regex":"("}]}`, "every on message": `{"rules":[{"on":"message","action":"commands","every":"5m"}]}`, "changelog with text": `{"rules":[{"on":"changelog","action":"announce","text":"x"}]}`, "changelog bad action": `{"rules":[{"on":"changelog","action":"reply"}]}`, "changelog bad every": `{"rules":[{"on":"changelog","action":"announce","every":"soon"}]}`, "two changelog rules": `{"rules":[{"on":"changelog","action":"announce"},{"on":"changelog","action":"announce"}]}`, } for name, src := range bad { if loadErr(t, src) == nil { t.Errorf("%s: expected a load error", name) } } // Missing file is "no recipe", not an error. if r, err := LoadRecipe(filepath.Join(t.TempDir(), "absent.json")); r != nil || err != nil { t.Fatalf("missing file: %v %v", r, err) } } func TestRecipeHandler(t *testing.T) { // The inclusivity-nudge watcher over a command floor — the docs' canonical // recipe (docs/bots.md § Capability tiers). r := mustRecipe(t, `{"rules":[ {"on":"message","match":"any","regex":"(?i)\\bguys\\b","action":"reply","text":"Hey {name} — how about \"folks\"?"}, {"on":"message","action":"commands"} ]}`) if !r.Watches() { t.Fatal("match:any recipe must report Watches") } h := r.handler("Eleven", false) // Watcher fires on an unaddressed group message, template fills the sender. rep, ok := h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "morning guys", Triggered: false}) if !ok || rep.Text != `Hey Paul — how about "folks"?` { t.Fatalf("nudge: ok=%v %+v", ok, rep) } // Unaddressed, no keyword → silent (commands rule is mention-gated). if _, ok := h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "morning all", Triggered: false}); ok { t.Fatal("unaddressed non-match must stay silent") } // Addressed command answers via the floor (mention stripped first). rep, ok = h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "@Eleven /dark", Triggered: true}) if !ok || rep.Actions[0]["appearance"] != "dark" { t.Fatalf("command via recipe: ok=%v %+v", ok, rep) } // Addressed non-command falls through the commands rule to the hint. rep, ok = h(nil, Incoming{Kind: "dm", Sender: "Paul", Text: "hello", Triggered: true}) if !ok || rep.Text != degradeText { t.Fatalf("degrade via recipe: ok=%v %+v", ok, rep) } // Rule order: an addressed message that ALSO matches the watcher gets the // first rule's action, not the floor. rep, _ = h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "@Eleven you guys ok?", Triggered: true}) if !strings.Contains(rep.Text, "folks") { t.Fatalf("first matching rule must win: %+v", rep) } // Keyword gate is case-insensitive substring. r2 := mustRecipe(t, `{"rules":[{"on":"message","match":"any","keyword":"standup","action":"reply","text":"Logged."}]}`) if _, ok := r2.handler("Bot", false)(nil, Incoming{Text: "STANDUP done", Sender: "P"}); !ok { t.Fatal("keyword must match case-insensitively") } if r2.Watches() != true { t.Fatal("watcher flag") } // The default recipe is mention-gated commands — never a watcher. d := DefaultRecipe() if err := d.validate(); err != nil { t.Fatalf("default recipe must validate: %v", err) } if d.Watches() { t.Fatal("default recipe must not watch") } if _, ok := d.handler("Bot", false)(nil, Incoming{Kind: "group", Text: "/help", Triggered: false}); ok { t.Fatal("default recipe must ignore unaddressed messages") } } // The changelog trigger stays a supported recipe primitive after the default // Announcer's retirement: an operator can still write a custom deploy watcher. // A parsed announce recipe validates, gets the default poll interval, and — // using no model — stays deterministic (badges BOT). func TestChangelogRecipeStillSupported(t *testing.T) { r := mustRecipe(t, `{"rules":[{"on":"changelog","action":"announce"}]}`) if r.usesModel() { t.Error("an announce recipe must not use the model") } var hasChangelog bool for _, rule := range r.Rules { if rule.On == "changelog" && rule.Action == "announce" { hasChangelog = true if rule.every <= 0 { t.Error("announce recipe has no poll interval") } } } if !hasChangelog { t.Error("parsed recipe missing the changelog rule") } }