package api
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// openIndexAt is an empty index of its own, so the two trackers on the
// board are genuinely separate stores rather than one seen twice.
func openIndexAt(t *testing.T) *index.Index {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
return ix
}
// boardRig: a team holding two repositories with tickets in both, some
// assigned and some not.
func boardRig(t *testing.T) (*ops.Registry, *oltp.DB) {
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", "nils"} {
if err := db.AddAccount(who, "a-long-enough-password", false); err != nil {
t.Fatal(err)
}
}
mk := func(ix *index.Index, id, title, assignee string) {
fields := []artifact.Field{
{Name: "title", Value: title}, {Name: "status", Value: "Open"},
}
if assignee != "" {
fields = append(fields, artifact.Field{Name: "assignee", Value: assignee})
}
if err := ix.FoldNow([]index.Parsed{{Hash: strings.Repeat(id, 20),
Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: strings.Repeat(id, 20),
Date: "2026-09-0" + id[:1] + "T10:00:00", User: "owen", Fields: fields}}}); err != nil {
t.Fatal(err)
}
}
alpha, beta := openIndexAt(t), openIndexAt(t)
mk(alpha, "a1", "alpha assigned", "mia")
mk(alpha, "a2", "alpha spare", "")
mk(beta, "b1", "beta assigned", "nils")
mk(beta, "b2", "beta spare", "")
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v",
Repos: NewRepos(map[string]*Repo{
"alpha": {Index: alpha, Code: "a"}, "beta": {Index: beta, Code: "b"}}),
Teams: db,
Accounts: acctAdapter{db},
// The read gate, wired as the forge wires it. Without this
// CanRead is nil, which means "every repository is readable" —
// so a visibility test against an unwired rig proves nothing
// about the op. The first version of this test did exactly
// that and passed a leak.
CanRead: func(ctx context.Context, repo string) bool {
return db.EffectiveCaps(ops.User(ctx), repo).HasAny("o")
},
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)
}
for _, repo := range []string{"alpha", "beta"} {
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
}
func board(t *testing.T, r *ops.Registry, ctx context.Context, in teamBoardIn) teamBoardOut {
t.Helper()
if in.Limit == 0 {
in.Limit = 50
}
out, err := r.Invoke(ctx, "team.tickets", in)
if err != nil {
t.Fatal(err)
}
return out.(teamBoardOut)
}
// One list across the team's repositories, with the assignee columns
// beside it and nobody LAST — which RFC 0018 asks for because the
// unassigned column is what a team opens the page for.
func TestTheBoardGathersBothTrackersAndPutsNobodyLast(t *testing.T) {
r, _ := boardRig(t)
got := board(t, r, asUser("owen"), teamBoardIn{Name: "core"})
if len(got.Tickets) != 4 {
t.Fatalf("FAIL: the board holds %d tickets, want 4", len(got.Tickets))
}
// Both trackers, and each row still says which one it came from.
repos := map[string]bool{}
for _, tk := range got.Tickets {
repos[tk.Repo] = true
}
if !repos["alpha"] || !repos["beta"] {
t.Errorf("FAIL: the board is not merging both trackers: %v", repos)
}
// mia, nils, then nobody.
if len(got.Columns) != 3 {
t.Fatalf("FAIL: %d columns, want mia, nils and nobody: %+v", len(got.Columns), got.Columns)
}
if got.Columns[0].Assignee != "mia" || got.Columns[1].Assignee != "nils" {
t.Errorf("FAIL: named columns are not sorted: %+v", got.Columns)
}
last := got.Columns[len(got.Columns)-1]
if !last.Nobody() {
t.Errorf("FAIL: the unassigned column is not last: %+v", got.Columns)
}
if last.Open != 2 {
t.Errorf("FAIL: the unassigned column counts %d, want 2", last.Open)
}
}
// Nobody is pickable, which is the whole point of putting it on the
// board: the empty assignee has to be a real filter value and not
// "every ticket".
func TestTheUnassignedColumnIsPickable(t *testing.T) {
r, _ := boardRig(t)
nobody := board(t, r, asUser("owen"), teamBoardIn{Name: "core", Nobody: true})
if len(nobody.Tickets) != 2 {
t.Fatalf("FAIL: picking nobody gave %d tickets, want 2", len(nobody.Tickets))
}
for _, tk := range nobody.Tickets {
if tk.Assignee != "" {
t.Errorf("FAIL: %q is assigned to %q", tk.Title, tk.Assignee)
}
}
// One person's column.
hers := board(t, r, asUser("owen"), teamBoardIn{Name: "core", Assignee: "mia"})
if len(hers.Tickets) != 1 || hers.Tickets[0].Assignee != "mia" {
t.Errorf("FAIL: mia's column holds %+v", hers.Tickets)
}
// Picking a column must not make the OTHER columns vanish from the
// strip that got you there.
if len(hers.Columns) != 3 {
t.Errorf("FAIL: picking a column collapsed the strip to %+v", hers.Columns)
}
}
// Visibility is per REPOSITORY, not membership of the team. A board has
// no team-own content, so the gate is the one each repository already
// applies — and a private repository stays absent for somebody who
// cannot read it even though the team holds it.
func TestABoardHidesARepositoryTheReaderCannotSee(t *testing.T) {
r, db := boardRig(t)
// Make beta private: both categories empty.
for _, cat := range []string{"nobody", "anonymous"} {
if err := db.SetCategoryCaps("beta", cat, ""); err != nil {
t.Fatal(err)
}
}
// mia is nobody here: not in the team, nothing granted.
got := board(t, r, asUser("mia"), teamBoardIn{Name: "core"})
for _, tk := range got.Tickets {
if tk.Repo == "beta" {
t.Errorf("FAIL: a reader who cannot see beta was shown %q", tk.Title)
}
}
for _, name := range got.Rooms {
if name == "beta" {
t.Error("FAIL: the board names a repository the reader cannot see")
}
}
// owen administers it, so he still sees it.
his := board(t, r, asUser("owen"), teamBoardIn{Name: "core"})
if len(his.Tickets) != 4 {
t.Errorf("FAIL: the admin sees %d tickets, want 4", len(his.Tickets))
}
}
// A board for a team that does not exist is not found, rather than an
// empty board that reads as "this team has no work".
func TestAnUnknownTeamHasNoBoard(t *testing.T) {
r, _ := boardRig(t)
if _, err := r.Invoke(asUser("owen"), "team.tickets",
teamBoardIn{Name: "nope", Limit: 50}); !errors.Is(err, ops.ErrNotFound) {
t.Errorf("FAIL: an unknown team gave %v", err)
}
}