Skip to content
quarry Artifact [56bdfe67ce]
Login

Artifact [56bdfe67ce]

Artifact 56bdfe67ce2f1b670ec5e2b9006bb31f9ea620bf72add97857ff3ecf4c662772:


package api

import (
	"context"
	"errors"
	"path/filepath"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/chat"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// teamChatRig is a team holding two repositories, with chat, and three
// accounts: an owner, a member, and somebody outside.
func teamChatRig(t *testing.T) (*ops.Registry, *oltp.DB, *fakeChat) {
	t.Helper()
	db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { db.Close() })
	for _, who := range []string{"owen", "mia", "outsider"} {
		if err := db.AddAccount(who, "a-long-enough-password", false); err != nil {
			t.Fatal(err)
		}
	}
	fc := &fakeChat{}
	repos := NewRepos(map[string]*Repo{
		"quarry": {Code: "aaaa000000000000000000000000000000000001"},
		"zit":    {Code: "bbbb000000000000000000000000000000000002"},
	})
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: repos, Teams: db, Chat: fc,
		Accounts: acctAdapter{db},
		CapsFor:  func(a, repo string) CapSet { return db.EffectiveCaps(a, repo) }}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("owen"), "team.create", teamCreateIn{Name: "core"}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("owen"), "team.member",
		teamMemberIn{Name: "core", Account: "mia"}); err != nil {
		t.Fatal(err)
	}
	// owen administers both, so he may put them under the team.
	for _, repo := range []string{"quarry", "zit"} {
		if err := db.Grant("owen", repo, oltp.RepoAdmin); err != nil {
			t.Fatal(err)
		}
		if _, err := r.Invoke(asUser("owen"), "team.repo.add",
			teamRepoIn{Name: "core", Repo: repo}); err != nil {
			t.Fatal(err)
		}
	}
	return r, db, fc
}

func say(t *testing.T, fc *fakeChat, code, repo, text string, sec int) {
	t.Helper()
	base := time.Date(2026, 9, 13, 12, 0, 0, 0, time.UTC)
	if _, err := fc.Post(code, chat.Message{Kind: "msg", Repo: repo, User: "dan", Text: text,
		At: base.Add(time.Duration(sec) * time.Second).Format(time.RFC3339Nano)}); err != nil {
		t.Fatal(err)
	}
}

// The room is the repositories' rooms and the team's own, in one time
// order. Nothing is copied to build it, so every line still names where
// it was said.
func TestTheTeamRoomGathersItsRepositories(t *testing.T) {
	r, _, fc := teamChatRig(t)
	say(t, fc, "aaaa000000000000000000000000000000000001", "quarry", "in quarry", 0)
	say(t, fc, chat.TeamCode("core"), "", "to the team", 1)
	say(t, fc, "bbbb000000000000000000000000000000000002", "zit", "in zit", 2)

	out, err := r.Invoke(asUser("mia"), "team.chat", teamChatIn{Name: "core", Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	got := out.(teamChatOut)
	if len(got.Messages) != 3 {
		t.Fatalf("FAIL: the room holds %d lines, want 3", len(got.Messages))
	}
	// Newest first.
	for i, want := range []string{"in zit", "to the team", "in quarry"} {
		if got.Messages[i].Text != want {
			t.Errorf("FAIL: position %d is %q, want %q", i, got.Messages[i].Text, want)
		}
	}
	// Each line still says where it came from; the team's own has no
	// repository, which is how a reader tells them apart.
	if got.Messages[0].Repo != "zit" || got.Messages[1].Repo != "" {
		t.Errorf("FAIL: lines lost their origin: %+v", got.Messages)
	}
	if len(got.Rooms) != 2 {
		t.Errorf("FAIL: the room names %v, want both repositories", got.Rooms)
	}
	// Everything read, so no cursor: the reader can stop.
	if got.Cursor != "" {
		t.Error("FAIL: a fully-read room still offers a cursor")
	}
}

// The room is for members. A non-member is not shown the team's own
// lines by being shown a merged view of rooms they could otherwise read
// one at a time.
func TestTheTeamRoomIsForMembers(t *testing.T) {
	r, _, fc := teamChatRig(t)
	say(t, fc, chat.TeamCode("core"), "", "internal", 0)

	if _, err := r.Invoke(asUser("outsider"), "team.chat",
		teamChatIn{Name: "core", Limit: 50}); !errors.Is(err, ops.ErrForbidden) {
		t.Errorf("FAIL: a non-member read the room: %v", err)
	}
	if _, err := r.Invoke(context.Background(), "team.chat",
		teamChatIn{Name: "core", Limit: 50}); !errors.Is(err, ops.ErrForbidden) {
		t.Errorf("FAIL: an anonymous reader read the room: %v", err)
	}
	// A member and an owner both may.
	for _, who := range []string{"mia", "owen"} {
		if _, err := r.Invoke(asUser(who), "team.chat",
			teamChatIn{Name: "core", Limit: 50}); err != nil {
			t.Errorf("FAIL: %s cannot read their own team's room: %v", who, err)
		}
	}
}

// A reply goes back where the line came from, and the repository's own
// capability answers for it — team membership is not a way into a room
// the team does not hold, and it is not a way past a repository's own
// chat gate either.
func TestAReplyGoesBackToTheRepository(t *testing.T) {
	r, db, fc := teamChatRig(t)

	// Plain post: the team's own channel, and it carries no repository.
	if _, err := r.Invoke(asUser("mia"), "team.chat.post",
		teamChatPostIn{Name: "core", Text: "to the team"}); err != nil {
		t.Fatal(err)
	}
	if got := fc.of(chat.TeamCode("core")); len(got) != 1 || got[0].Repo != "" {
		t.Fatalf("FAIL: the team's channel holds %+v", got)
	}

	// Aimed at a repository: mia holds the membership floor, which
	// carries chat, so this lands in quarry's own room.
	if _, err := r.Invoke(asUser("mia"), "team.chat.post",
		teamChatPostIn{Name: "core", Text: "answering", Repo: "quarry", Ref: 1}); err != nil {
		t.Fatalf("FAIL: a member could not answer in a team repository's room: %v", err)
	}
	inQuarry := fc.of("aaaa000000000000000000000000000000000001")
	if len(inQuarry) != 1 || inQuarry[0].Text != "answering" || inQuarry[0].Repo != "quarry" {
		t.Errorf("FAIL: quarry's room holds %+v", inQuarry)
	}
	if len(fc.of(chat.TeamCode("core"))) != 1 {
		t.Error("FAIL: the repository-aimed line was also put in the team's channel")
	}

	// A repository the team does NOT hold is refused, even to an owner
	// who administers it — otherwise the team room is a way to post
	// anywhere its owner can reach, which is a different feature.
	if err := db.Grant("owen", "elsewhere", oltp.RepoAdmin); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(asUser("owen"), "team.chat.post",
		teamChatPostIn{Name: "core", Text: "stray", Repo: "elsewhere"}); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: posted to a repository outside the team: %v", err)
	}
}

// Paging a merged room visits every line once. The cursor is the thing
// most likely to be wrong, and wrong either drops a line or repeats one
// — both of which look like a working room until somebody counts.
func TestPagingTheTeamRoomVisitsEveryLineOnce(t *testing.T) {
	r, _, fc := teamChatRig(t)
	codes := []string{
		"aaaa000000000000000000000000000000000001",
		chat.TeamCode("core"),
		"bbbb000000000000000000000000000000000002",
	}
	repos := []string{"quarry", "", "zit"}
	for i := 0; i < 12; i++ {
		say(t, fc, codes[i%3], repos[i%3], string(rune('a'+i)), i)
	}
	seen := map[string]int{}
	cursor := ""
	for pages := 0; ; pages++ {
		if pages > 20 {
			t.Fatal("FAIL: paging does not terminate")
		}
		out, err := r.Invoke(asUser("mia"), "team.chat",
			teamChatIn{Name: "core", Limit: 2, Cursor: cursor})
		if err != nil {
			t.Fatal(err)
		}
		got := out.(teamChatOut)
		for _, m := range got.Messages {
			seen[m.Text]++
		}
		if got.Cursor == "" {
			break
		}
		cursor = got.Cursor
	}
	if len(seen) != 12 {
		t.Errorf("FAIL: paging saw %d distinct lines, want 12", len(seen))
	}
	for text, n := range seen {
		if n != 1 {
			t.Errorf("FAIL: %q appeared %d times", text, n)
		}
	}
}