Skip to content
quarry Artifact [60f7256040]
Login

Artifact [60f7256040]

Artifact 60f72560405583884011c115b73e78ce0223f35d1c2b356d2c0ad6d5e6c217d4:


package api

import (
	"context"
	"errors"
	"sync"
	"testing"

	"craftdesign.group/quarry/internal/evt"
	"craftdesign.group/quarry/internal/ops"
)

// fakeAttention is the attention plane as the ops see it: a sequence
// per account, and a tail nobody in these tests reads.
type fakeAttention struct {
	mu   sync.Mutex
	seq  uint64
	sent map[string][]AttentionEvent
}

func newFakeAttention() *fakeAttention {
	return &fakeAttention{sent: map[string][]AttentionEvent{}}
}

func (f *fakeAttention) Attend(account string, e AttentionEvent) (uint64, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.seq++
	e.Seq = f.seq
	f.sent[account] = append(f.sent[account], e)
	return f.seq, nil
}

func (f *fakeAttention) TailAttention(string, uint64) (<-chan AttentionEvent, func()) {
	ch := make(chan AttentionEvent)
	return ch, func() { close(ch) }
}

func (f *fakeAttention) AttentionPending(account string, since uint64) (int, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	n := 0
	for _, e := range f.sent[account] {
		if e.Seq > since {
			n++
		}
	}
	return n, nil
}

func (f *fakeAttention) AttentionTip(account string) (uint64, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	evs := f.sent[account]
	if len(evs) == 0 {
		return 0, nil
	}
	return evs[len(evs)-1].Seq, nil
}

func (f *fakeAttention) to(account string) []AttentionEvent {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]AttentionEvent(nil), f.sent[account]...)
}

type memAttnRead struct {
	mu   sync.Mutex
	seqs map[string]uint64
}

func newMemAttnRead() *memAttnRead { return &memAttnRead{seqs: map[string]uint64{}} }

func (m *memAttnRead) MarkAttentionRead(account string, seq uint64) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if seq > m.seqs[account] {
		m.seqs[account] = seq
	}
	return nil
}

func (m *memAttnRead) AttentionReadSeq(account string) uint64 {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.seqs[account]
}

// The stream is written whether or not notify exists — a forge with no
// notify configured still tells people what was addressed to them.
func TestFireNotifyAttendsWithoutNotify(t *testing.T) {
	fa := newFakeAttention()
	d := Deps{Attention: fa}
	d.fireNotify(NotifyEvent{
		Kind: evt.ForumPosted, Workflow: "quarry-mention", To: "alice",
		Payload: map[string]any{"repo": "alpha", "from": "bob",
			"text": "hey @alice look", "link": "/r/alpha/forum/abc"},
		TxnID: "mention:forum:abc:alice",
	})
	got := fa.to("alice")
	if len(got) != 1 {
		t.Fatalf("alice got %d attention events, want 1", len(got))
	}
	if got[0].Kind != string(evt.ForumPosted) {
		t.Errorf("kind is %q, want %q", got[0].Kind, evt.ForumPosted)
	}
	if got[0].Repo != "alpha" || got[0].From != "bob" ||
		got[0].Link != "/r/alpha/forum/abc" || got[0].Summary == "" {
		t.Errorf("event carries %+v", got[0])
	}
}

// And it is written when notify IS configured, independently: the two
// paths are not alternatives.
func TestFireNotifyAttendsAlongsideNotify(t *testing.T) {
	fa := newFakeAttention()
	d := Deps{Attention: fa, Notify: &fakeNotify{}}
	d.fireNotify(NotifyEvent{
		Workflow: "quarry-forum-reply", To: "alice",
		Payload: map[string]any{"repo": "alpha", "from": "bob",
			"title": "a thread", "link": "/r/alpha/forum/abc"},
	})
	got := fa.to("alice")
	if len(got) != 1 || got[0].Kind != "reply" || got[0].Summary != "a thread" {
		t.Fatalf("alice got %+v", got)
	}
}

// Topic traffic is the repository talking, not attention: it must not
// land on anybody's personal subject.
func TestFireNotifyDoesNotAttendTopics(t *testing.T) {
	fa := newFakeAttention()
	d := Deps{Attention: fa}
	for _, e := range []NotifyEvent{
		{Kind: evt.TicketOpened, Workflow: "quarry-activity", To: "repo:alpha",
			Payload: map[string]any{"repo": "alpha"}},
		{Workflow: "quarry-error", To: "admins", Payload: map[string]any{}},
		{Workflow: "quarry-mention", To: "repo:alpha", Payload: map[string]any{}},
	} {
		d.fireNotify(e)
	}
	fa.mu.Lock()
	defer fa.mu.Unlock()
	if len(fa.sent) != 0 {
		t.Fatalf("topic events reached %v", fa.sent)
	}
}

func attentionRegistry(t *testing.T, d Deps) *ops.Registry {
	t.Helper()
	r := ops.NewRegistry()
	if err := registerAttention(r, d); err != nil {
		t.Fatal(err)
	}
	return r
}

func TestAttentionReadIsForwardOnly(t *testing.T) {
	fa, store := newFakeAttention(), newMemAttnRead()
	d := Deps{Attention: fa, AttnRead: store}
	r := attentionRegistry(t, d)
	ctx := ops.WithUser(context.Background(), "alice")

	for i := 0; i < 3; i++ {
		if _, err := fa.Attend("alice", AttentionEvent{Kind: "mention"}); err != nil {
			t.Fatal(err)
		}
	}
	out, err := r.Invoke(ctx, "attention.read", attentionReadIn{Seq: 3})
	if err != nil {
		t.Fatal(err)
	}
	if got := out.(attentionReadOut).Seq; got != 3 {
		t.Fatalf("pointer is %d, want 3", got)
	}
	// A stale client replays an older cursor: the pointer must not move
	// backwards and resurrect mail already dismissed.
	out, err = r.Invoke(ctx, "attention.read", attentionReadIn{Seq: 1})
	if err != nil {
		t.Fatal(err)
	}
	if got := out.(attentionReadOut).Seq; got != 3 {
		t.Fatalf("pointer went to %d on a backwards mark, want 3", got)
	}
}

func TestInboxCountFallsBackToTheLocalTail(t *testing.T) {
	fa, store := newFakeAttention(), newMemAttnRead()
	r := attentionRegistry(t, Deps{Attention: fa, AttnRead: store})
	ctx := ops.WithUser(context.Background(), "alice")

	for i := 0; i < 4; i++ {
		if _, err := fa.Attend("alice", AttentionEvent{Kind: "mention"}); err != nil {
			t.Fatal(err)
		}
	}
	if _, err := fa.Attend("bob", AttentionEvent{Kind: "mention"}); err != nil {
		t.Fatal(err)
	}
	out, err := r.Invoke(ctx, "inbox.count", inboxCountIn{})
	if err != nil {
		t.Fatal(err)
	}
	if got := out.(inboxCountOut).Unread; got != 4 {
		t.Fatalf("unread is %d, want 4 (bob's is not alice's)", got)
	}
	// Marking everything read with no seq clears it.
	if _, err := r.Invoke(ctx, "attention.read", attentionReadIn{}); err != nil {
		t.Fatal(err)
	}
	out, err = r.Invoke(ctx, "inbox.count", inboxCountIn{})
	if err != nil {
		t.Fatal(err)
	}
	if got := out.(inboxCountOut).Unread; got != 0 {
		t.Fatalf("unread is %d after marking read, want 0", got)
	}
}

type memRobots struct{ events map[string]string }

func (m *memRobots) SetChatRobot(repo, events string) error {
	if events == "" {
		delete(m.events, repo)
		return nil
	}
	m.events[repo] = events
	return nil
}

func (m *memRobots) ChatRobot(repo string) string { return m.events[repo] }

func TestChatRobotValidatesPatterns(t *testing.T) {
	store := &memRobots{events: map[string]string{}}
	d := Deps{ChatRobots: store, Repos: NewRepos(map[string]*Repo{"alpha": {Code: "alpha"}})}
	r := ops.NewRegistry()
	if err := registerChatRobot(r, d); err != nil {
		t.Fatal(err)
	}
	ctx := ops.WithUser(context.Background(), "alice")

	if _, err := r.Invoke(ctx, "chat.robot",
		chatRobotIn{Repo: "alpha", Events: "checkin landing.accepted"}); err != nil {
		t.Fatal(err)
	}
	if got := store.ChatRobot("alpha"); got != "checkin landing.accepted" {
		t.Fatalf("stored %q", got)
	}
	_, err := r.Invoke(ctx, "chat.robot", chatRobotIn{Repo: "alpha", Events: "nonsense.event"})
	if err == nil {
		t.Fatal("chat.robot accepted a pattern no event can match")
	}
	if !errors.Is(err, ops.ErrInvalid) {
		t.Fatalf("refusal is %v, want an invalid-request error", err)
	}
	// The refused write left the stored patterns alone.
	if got := store.ChatRobot("alpha"); got != "checkin landing.accepted" {
		t.Fatalf("a refused write changed the stored patterns to %q", got)
	}
	// Empty is off.
	if _, err := r.Invoke(ctx, "chat.robot", chatRobotIn{Repo: "alpha"}); err != nil {
		t.Fatal(err)
	}
	out, err := r.Invoke(ctx, "chat.robot.get", chatRobotGetIn{Repo: "alpha"})
	if err != nil {
		t.Fatal(err)
	}
	if got := out.(chatRobotOut); got.On || got.Events != "" {
		t.Fatalf("robot reads back %+v after being turned off", got)
	}
}