Skip to content
quarry Artifact [af157c42a9]
Login

Artifact [af157c42a9]

Artifact af157c42a9a9f926b3c6ad8d729d809df9583e84db9fce1560c54b17dda9a419:


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)
			}
		}
	}
}

func TestRobotLineNamesNumbers(t *testing.T) {
	for _, c := range []struct{ kind, key, sigil string }{
		{"landing.opened", "landing", "!"},
		{"ticket.closed", "ticket", "ticket #"},
	} {
		// The line leads with the reference token for the number; JSON
		// payloads decode numbers as float64, and both spell it alike.
		for _, n := range []any{781, float64(781)} {
			got := RobotLine(c.kind, map[string]any{c.key: "1234567890abcdef", "number": n, "actor": "a"})
			// In chat a bare #N is a message; a ticket is named "ticket #N".
			if !strings.HasPrefix(got, c.sigil+"781 ") {
				t.Errorf("RobotLine(%s, %T) = %q, want it to lead with the %s781 token", c.kind, n, got, c.sigil)
			}
		}
		got := RobotLine(c.kind, map[string]any{c.key: "1234567890abcdef"})
		if !strings.Contains(got, "1234567890ab") || strings.ContainsAny(got[:1], "!#") || strings.Contains(got, "#") {
			t.Errorf("RobotLine(%s) without a number = %q, want the hash form", c.kind, got)
		}
	}
}