package chat
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/events"
)
// robotFixture stands up the vocabulary stream beside the chat stream,
// wires a robot over both, and hands back a publisher for raised events.
func robotFixture(t *testing.T, patterns map[string]string) (*Room, func(kind, repo string, payload map[string]any)) {
t.Helper()
ev, err := events.Start(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(ev.Close)
room := room(t, ev)
// As the composition root wires it: every line the robot posts is
// remembered, which is what keeps it out of everybody's badge.
room.Robots = &memRobots{}
js := ev.JS()
robot := NewRobot(room, js,
func(repo string) string { return patterns[repo] },
func(repo string) (string, bool) { return repo, true })
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
if err := robot.Start(ctx); err != nil {
t.Fatal(err)
}
// The consumer must exist before anything is published: DeliverNew
// means an event raised ahead of it is not a backlog to announce.
time.Sleep(200 * time.Millisecond)
return room, func(kind, repo string, payload map[string]any) {
body, err := json.Marshal(map[string]any{
"kind": kind, "repo": repo, "payload": payload,
})
if err != nil {
t.Fatal(err)
}
if _, err := js.Publish("quarry.evt."+repo+"."+kind, body); err != nil {
t.Fatal(err)
}
}
}
// A line the robot announced is recorded as the robot's, so the badge
// it must not raise has something to subtract. Asserted through the
// whole path — raise, announce, post — because the recording hangs off
// Post and a robot that posted some other way would be invisible to it.
func TestAnnouncedLinesAreRecordedAsTheRobots(t *testing.T) {
room, raise := robotFixture(t, map[string]string{"alpha": "checkin"})
raise("checkin.created", "alpha", map[string]any{"hash": "abcdef", "actor": "drh"})
waitForRobot(t, room, "alpha")
n, err := room.Robots.RobotLines("alpha", 0)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("the robot store holds %d lines, want 1", n)
}
if u, err := room.Unread("alpha", 0); err != nil || u != 0 {
t.Fatalf("Unread = %d,%v after one robot line, want 0,nil", u, err)
}
}
func waitForRobot(t *testing.T, room *Room, code string) []Message {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
msgs, _, err := room.History(code, 0, 50)
if err != nil {
t.Fatal(err)
}
if len(msgs) > 0 {
return msgs
}
time.Sleep(100 * time.Millisecond)
}
return nil
}
func TestRobotAnnouncesAMatchingEvent(t *testing.T) {
room, raise := robotFixture(t, map[string]string{"alpha": "checkin"})
raise("checkin.created", "alpha", map[string]any{
"hash": "a91f3c2edeadbeefcafe", "actor": "drh",
"branch": "trunk", "summary": "fix the parser",
})
msgs := waitForRobot(t, room, "alpha")
if len(msgs) != 1 {
t.Fatalf("channel holds %d messages, want 1", len(msgs))
}
m := msgs[0]
if m.Kind != KindRobot || m.Origin != "robot" {
t.Fatalf("line is kind=%q origin=%q, want robot/robot", m.Kind, m.Origin)
}
for _, want := range []string{"a91f3c2edead", "trunk", "drh", "fix the parser"} {
if !strings.Contains(m.Text, want) {
t.Errorf("line %q does not name %q", m.Text, want)
}
}
}
func TestRobotStaysQuietForUnmatchedEvents(t *testing.T) {
room, raise := robotFixture(t, map[string]string{"alpha": "landing"})
raise("checkin.created", "alpha", map[string]any{"hash": "abc", "actor": "drh"})
// A repository that never opted in hears nothing either.
raise("checkin.created", "beta", map[string]any{"hash": "def", "actor": "drh"})
time.Sleep(time.Second)
for _, code := range []string{"alpha", "beta"} {
msgs, _, err := room.History(code, 0, 50)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 0 {
t.Fatalf("%s heard %d robot lines for an unmatched event", code, len(msgs))
}
}
}
// Robot lines are the channel talking about the repository, so a badge
// must not light up for them.
func TestRobotLinesAreNeverUnread(t *testing.T) {
ev, err := events.Start(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer ev.Close()
room := room(t, ev)
// The composition root attaches the robot-line store; without one
// there is no robot either, so this is the only shape that exists.
room.Robots = &memRobots{}
if _, err := room.Post("alpha", Message{
Kind: KindRobot, Origin: "robot", User: "robot", Text: "check-in abc on trunk",
}); err != nil {
t.Fatal(err)
}
n, err := room.Unread("alpha", 0)
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("a robot line counted %d unread, want 0", n)
}
if _, err := room.Post("alpha", Message{User: "alice", Text: "hello"}); err != nil {
t.Fatal(err)
}
n, err = room.Unread("alpha", 0)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("unread is %d, want 1 — the person's line and not the robot's", n)
}
}
func TestRobotLineShapes(t *testing.T) {
cases := []struct {
kind string
payload map[string]any
want []string
}{
{"landing.accepted", map[string]any{"landing": "1234567890abcdef", "actor": "alice"},
[]string{"landing", "1234567890ab"[:12], "accepted", "alice"}},
{"ci.completed", map[string]any{"hash": "abcdef012345678", "state": "passed"},
[]string{"CI", "passed", "abcdef012345"}},
{"account.added", map[string]any{"actor": "root"}, nil}, // nothing useful to say
{"uv.changed", map[string]any{"name": "releases/0.2.0/SHA256SUMS", "deleted": false, "actor": "ci"},
[]string{"releases/0.2.0/SHA256SUMS", "ci"}},
{"uv.changed", map[string]any{"name": "old.tgz", "deleted": true, "actor": "dan"},
[]string{"old.tgz", "removed", "dan"}},
}
for _, c := range cases {
got := RobotLine(c.kind, c.payload)
if len(c.want) == 0 {
if got != "" {
t.Errorf("RobotLine(%s) = %q, want silence", c.kind, got)
}
continue
}
for _, w := range c.want {
if !strings.Contains(got, w) {
t.Errorf("RobotLine(%s) = %q, missing %q", c.kind, got, w)
}
}
}
}