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