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)
}
}
if err := db.AddAccount("root", "a-long-enough-password", true); 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)
}
// Nor a site admin who is not in the team (#318): the room carries
// lines from every repository under the team, private ones included.
if _, err := r.Invoke(asUser("root"), "team.chat",
teamChatIn{Name: "core", Limit: 50}); !errors.Is(err, ops.ErrForbidden) {
t.Errorf("FAIL: a site admin outside the team 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)
}
}
}