package api
import (
"context"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ops"
)
// forumWith folds one thread per title so each is findable by a word.
func forumWith(t *testing.T, titles ...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() })
var batch []index.Parsed
for i, title := range titles {
h := strings.Repeat("0", 61) + string("0123456789abcdef"[i%16]) + "ab"
batch = append(batch, index.Parsed{Hash: h, Art: &artifact.Artifact{
Type: artifact.Forum, ThreadTitle: title, User: "dan",
Date: "2026-09-01T10:00:00", Body: []byte(title + " — body text"),
}})
}
if err := ix.FoldNow(batch); err != nil {
t.Fatal(err)
}
return &Repo{Index: ix}
}
func searchForum(t *testing.T, d Deps, repo, q string) []index.SearchResult {
t.Helper()
r := ops.NewRegistry()
if err := RegisterAll(r, d); err != nil {
t.Fatal(err)
}
got, err := r.Invoke(context.Background(), "search.query",
searchIn{Repo: repo, Q: q, Kind: "forum", Limit: 50})
if err != nil {
t.Fatal(err)
}
return got.(searchOut).Results
}
// Searching a bound repository's forum must reach the store that holds
// it. On fossil this answered "nothing matches" for a word in 3,558
// threads: the parent's index has never heard of them, and the store
// that could answer was never asked (RFC 0015 §Search).
func TestSearchingAForumReachesTheCompanion(t *testing.T) {
d := Deps{Version: "v",
Repos: NewRepos(map[string]*Repo{
"fossil": forumWith(t, "a legacy thread about nothing"),
"fossil-forum": forumWith(t, "the difference between two things"),
}),
Companions: &fakeCompanions{bound: map[string]string{"fossil": "fossil-forum"}},
}
got := searchForum(t, d, "fossil", "difference")
if len(got) == 0 {
t.Fatal("FAIL: a word that exists in the companion found nothing")
}
found := false
for _, r := range got {
if strings.Contains(r.Label+r.Snippet, "difference") {
found = true
}
}
if !found {
t.Errorf("FAIL: the companion's thread is not among the results: %+v", got)
}
}
// Merged, not replaced: a repository bound after it had threads of its
// own still has them, and both are on the forum page a reader clicks
// through to.
func TestTheParentsOwnThreadsAreStillFound(t *testing.T) {
d := Deps{Version: "v",
Repos: NewRepos(map[string]*Repo{
"fossil": forumWith(t, "a legacy thread about pelicans"),
"fossil-forum": forumWith(t, "a newer thread about pelicans"),
}),
Companions: &fakeCompanions{bound: map[string]string{"fossil": "fossil-forum"}},
}
got := searchForum(t, d, "fossil", "pelicans")
if len(got) != 2 {
t.Fatalf("FAIL: found %d threads, want both the parent's and the companion's", len(got))
}
}
// An unbound repository behaves exactly as before, and a companion is
// not searchable under its own name for things it does not hold.
func TestAnUnboundRepositorySearchesOnlyItself(t *testing.T) {
d := Deps{Version: "v",
Repos: NewRepos(map[string]*Repo{
"alpha": forumWith(t, "alpha's own thread about pelicans"),
"beta": forumWith(t, "beta's thread about pelicans"),
}),
}
if got := searchForum(t, d, "alpha", "pelicans"); len(got) != 1 {
t.Errorf("FAIL: an unbound repository returned %d results, want only its own", len(got))
}
}
// A binding naming a store this node does not serve is an operator's
// problem, worded properly the moment somebody opens the forum. A
// search says what it found rather than failing on it.
func TestADanglingBindingStillSearchesTheParent(t *testing.T) {
d := Deps{Version: "v",
Repos: NewRepos(map[string]*Repo{"fossil": forumWith(t, "a thread about pelicans")}),
Companions: &fakeCompanions{bound: map[string]string{"fossil": "nowhere"}},
}
got := searchForum(t, d, "fossil", "pelicans")
if len(got) != 1 {
t.Errorf("FAIL: a dangling binding gave %d results, want the parent's own", len(got))
}
}
// The merge is deterministic: equal ranks break on ref, so two runs of
// the same query cannot reorder under a reader.
func TestTheMergeIsDeterministic(t *testing.T) {
a := []index.SearchResult{{Ref: "bbb", Rank: -1}, {Ref: "aaa", Rank: -1}}
b := []index.SearchResult{{Ref: "ccc", Rank: -1}}
first := mergeSearch(a, b, 0)
second := mergeSearch(a, b, 0)
for i := range first {
if first[i].Ref != second[i].Ref {
t.Fatalf("FAIL: the merge reordered between runs: %v vs %v", first, second)
}
}
if first[0].Ref != "aaa" {
t.Errorf("FAIL: ties did not break on ref: %v", first)
}
// A better rank still wins regardless of which side it came from.
if got := mergeSearch(
[]index.SearchResult{{Ref: "worse", Rank: -1}},
[]index.SearchResult{{Ref: "better", Rank: -9}}, 0); got[0].Ref != "better" {
t.Errorf("FAIL: the companion's stronger hit did not lead: %v", got)
}
// And the limit is honoured after the merge, not before it.
if got := mergeSearch(a, b, 2); len(got) != 2 {
t.Errorf("FAIL: limit gave %d", len(got))
}
}
// Fleet-wide, a companion's hits are the parent's: "the companion is
// not searchable on its own name" (RFC 0015 §Search), and a reader who
// clicks a forum result lands on /r/X/forum whichever store held it.
func TestFleetSearchAttributesACompanionToItsParent(t *testing.T) {
d := Deps{Version: "v",
Repos: NewRepos(map[string]*Repo{
"fossil": forumWith(t, "a legacy thread about pelicans"),
"fossil-forum": forumWith(t, "a newer thread about pelicans"),
"sqlite": forumWith(t, "sqlite on pelicans"),
}),
Companions: &fakeCompanions{bound: map[string]string{"fossil": "fossil-forum"}},
}
r := ops.NewRegistry()
if err := RegisterAll(r, d); err != nil {
t.Fatal(err)
}
got, err := r.Invoke(context.Background(), "search.all",
searchAllIn{Q: "pelicans", Kind: "forum", Limit: 50})
if err != nil {
t.Fatal(err)
}
out := got.(searchAllOut)
for _, h := range out.Results {
if h.Repo == "fossil-forum" {
t.Errorf("FAIL: a hit is attributed to the companion: %+v", h)
}
}
// One facet per repository, with the companion's hits folded in:
// two rows for fossil would say one project is two.
seen := map[string]int{}
for _, f := range out.ByRepo {
seen[f.Repo]++
}
if seen["fossil-forum"] != 0 {
t.Error("FAIL: the companion is listed as a repository of its own")
}
if seen["fossil"] != 1 {
t.Errorf("FAIL: fossil appears in %d facets, want one: %+v", seen["fossil"], out.ByRepo)
}
// Both of fossil's threads are counted under it.
for _, f := range out.ByRepo {
if f.Repo == "fossil" && f.Count != 2 {
t.Errorf("FAIL: fossil's facet counts %d, want both its threads", f.Count)
}
}
}