package api
import (
"context"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ops"
)
// forumRepoAt folds one forum thread so a store has activity to report.
func forumRepoAt(t *testing.T, seed, date, title string) *Repo {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "i.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
if err := ix.FoldNow([]index.Parsed{{Hash: strings.Repeat(seed, 32),
Art: &artifact.Artifact{Type: artifact.Forum, ThreadTitle: title,
Date: date, User: "dan"}}}); err != nil {
t.Fatal(err)
}
return &Repo{Index: ix}
}
func dashFeed(t *testing.T, d Deps) meFeedOut {
t.Helper()
r := ops.NewRegistry()
if err := RegisterAll(r, d); err != nil {
t.Fatal(err)
}
got, err := r.Invoke(ops.WithUser(context.Background(), "dan"), "me.feed", meFeedIn{})
if err != nil {
t.Fatal(err)
}
return got.(meFeedOut)
}
// A companion is a store, not a project (RFC 0015): Fleet lists it with
// its parent, and no other list does. repo.list has filtered them since
// the binding existed — the dashboard is the OTHER list of repositories
// and had not, so a site admin's landing page showed "fossil-forum" as
// though it were a project of its own.
func TestTheDashboardDoesNotListACompanion(t *testing.T) {
out := dashFeed(t, Deps{Version: "v",
Repos: NewRepos(map[string]*Repo{
"fossil": forumRepoAt(t, "a", "2026-09-01T10:00:00", "parent thread"),
"fossil-forum": forumRepoAt(t, "b", "2026-09-02T10:00:00", "companion thread"),
}),
Companions: &fakeCompanions{bound: map[string]string{"fossil": "fossil-forum"}},
Accounts: stubAccounts{grants: map[string]map[string]string{
"dan": {"fossil": "admin", "fossil-forum": "admin"},
}}})
for _, r := range out.Repos {
if r.Name == "fossil-forum" {
t.Errorf("FAIL: the dashboard listed the companion as a project: %+v", out.Repos)
}
}
if len(out.Repos) != 1 || out.Repos[0].Name != "fossil" {
t.Errorf("FAIL: repos = %+v, want fossil alone", out.Repos)
}
}
// And its activity is the parent's: a thread in fossil-forum is
// something that happened to fossil, which is what the RFC means by
// attributing forum activity to the parent.
func TestCompanionActivityIsAttributedToTheParent(t *testing.T) {
out := dashFeed(t, Deps{Version: "v",
Repos: NewRepos(map[string]*Repo{
"fossil": forumRepoAt(t, "a", "2026-09-01T10:00:00", "parent thread"),
"fossil-forum": forumRepoAt(t, "b", "2026-09-02T10:00:00", "companion thread"),
}),
Companions: &fakeCompanions{bound: map[string]string{"fossil": "fossil-forum"}},
Accounts: stubAccounts{grants: map[string]map[string]string{
"dan": {"fossil": "admin", "fossil-forum": "admin"},
}}})
if len(out.Feed) == 0 {
t.Fatal("FAIL: the feed is empty; the companion's activity was dropped rather than attributed")
}
for _, e := range out.Feed {
if e.Repo == "fossil-forum" {
t.Errorf("FAIL: a feed row is labelled with the companion: %+v", e)
}
}
// The companion's thread is the newest, so it leads — under the
// parent's name.
if out.Feed[0].Repo != "fossil" {
t.Errorf("FAIL: the newest row is under %q, want fossil", out.Feed[0].Repo)
}
}
// An unbound forge is untouched: every granted repository is its own
// project and keeps its own name.
func TestWithoutABindingEveryRepositoryIsItsOwnProject(t *testing.T) {
out := dashFeed(t, Deps{Version: "v",
Repos: NewRepos(map[string]*Repo{
"fossil": forumRepoAt(t, "a", "2026-09-01T10:00:00", "one"),
"fossil-forum": forumRepoAt(t, "b", "2026-09-02T10:00:00", "two"),
}),
Accounts: stubAccounts{grants: map[string]map[string]string{
"dan": {"fossil": "admin", "fossil-forum": "admin"},
}}})
if len(out.Repos) != 2 {
t.Errorf("FAIL: repos = %+v, want both listed when nothing is bound", out.Repos)
}
}