package index
import (
"strings"
"testing"
"craftdesign.group/quarry/internal/artifact"
)
// The contributor table is folded at write time, so it must agree with
// the aggregate it replaces — for every event kind, not just check-ins.
// A person who only files tickets is a contributor.
func TestContributorsMatchTheAggregate(t *testing.T) {
ix := openIndex(t)
parsed := []Parsed{
{Hash: strings.Repeat("a1", 32), Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2020-01-01T00:00:00", User: "drh", Comment: "first"}},
{Hash: strings.Repeat("a2", 32), Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-01-01T00:00:00", User: "drh", Comment: "later"}},
{Hash: strings.Repeat("a3", 32), Art: &artifact.Artifact{Type: artifact.Wiki,
WikiTitle: "Guide", Date: "2023-01-01T00:00:00", User: "dan", Body: []byte("x")}},
}
if err := ix.FoldNow(parsed); err != nil {
t.Fatal(err)
}
got, err := ix.Contributors(50)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d contributors, want 2: %+v", len(got), got)
}
// Busiest first.
if got[0].User != "drh" || got[0].Events != 2 {
t.Errorf("first = %+v, want drh with 2 events", got[0])
}
// A wiki-only author still counts.
if got[1].User != "dan" || got[1].Events != 1 {
t.Errorf("second = %+v, want dan with 1 event", got[1])
}
if got[0].First[:4] != "2020" || got[0].Last[:4] != "2026" {
t.Errorf("drh range = %s..%s, want 2020..2026", got[0].First, got[0].Last)
}
// And it must equal the aggregate it stands in for.
rows, err := ix.db.Query(`SELECT user, count(*) FROM event GROUP BY user`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
want := map[string]int{}
for rows.Next() {
var u string
var n int
if err := rows.Scan(&u, &n); err != nil {
t.Fatal(err)
}
want[u] = n
}
for _, c := range got {
if want[c.User] != c.Events {
t.Errorf("%s: folded %d events, aggregate says %d", c.User, c.Events, want[c.User])
}
}
}
// Reset must leave the folded tables exact, not merely consistent: the
// delete trigger deliberately does not narrow first/last, so a rebuild
// is the only thing that restores them precisely.
func TestResetRebuildsContributorsExactly(t *testing.T) {
ix := openIndex(t)
if err := ix.FoldNow([]Parsed{
{Hash: strings.Repeat("b1", 32), Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2001-01-01T00:00:00", User: "old", Comment: "ancient"}},
}); err != nil {
t.Fatal(err)
}
if err := ix.Reset(); err != nil {
t.Fatal(err)
}
got, err := ix.Contributors(50)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("after Reset: %+v, want no contributors", got)
}
if err := ix.FoldNow([]Parsed{
{Hash: strings.Repeat("b2", 32), Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-06-06T00:00:00", User: "new", Comment: "fresh"}},
}); err != nil {
t.Fatal(err)
}
got, _ = ix.Contributors(50)
if len(got) != 1 || got[0].User != "new" || got[0].First[:4] != "2026" {
t.Errorf("after rebuild: %+v, want only new starting 2026", got)
}
}
// A tracker's list is a working surface. Across this corpus most of
// every tracker is finished work — sqlite currently has 2 open tickets
// against 792 closed — so closed ones are opt-in. The vocabulary is
// per-repository, so an unrecognized status must count as OPEN: a
// wrongly-shown ticket is noise, a wrongly-hidden one is invisible.
func TestClosedTicketsAreOptIn(t *testing.T) {
ix := openIndex(t)
mk := func(hash, status string) Parsed {
return Parsed{Hash: hash, Art: &artifact.Artifact{
Type: artifact.Ticket, TicketID: hash, Date: "2026-08-15T10:00:00", User: "dan",
Fields: []artifact.Field{
{Name: "title", Value: "t-" + status}, {Name: "status", Value: status}},
}}
}
if err := ix.FoldNow([]Parsed{
mk(strings.Repeat("a1", 20), "Open"),
mk(strings.Repeat("a2", 20), "Closed"),
mk(strings.Repeat("a3", 20), "closed"), // casing varies in real data
mk(strings.Repeat("a4", 20), "Fixed"),
mk(strings.Repeat("a5", 20), "Needs Triage"), // unknown word
}); err != nil {
t.Fatal(err)
}
open, _, err := ix.TicketSummaries(50, "", false)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, s := range open {
got[s.Status] = true
}
for _, want := range []string{"Open", "Needs Triage"} {
if !got[want] {
t.Errorf("%q is missing from the open listing; an unrecognized status must not hide a ticket", want)
}
}
for _, hidden := range []string{"Closed", "closed", "Fixed"} {
if got[hidden] {
t.Errorf("%q appeared in the open listing", hidden)
}
}
all, _, err := ix.TicketSummaries(50, "", true)
if err != nil {
t.Fatal(err)
}
if len(all) != 5 {
t.Errorf("opting in returned %d tickets, want all 5", len(all))
}
}
// Fossil can mark a whole ticket private. quarry indexed the field and
// gave it no meaning, so 67 tickets across the served corpus were
// listed, searchable and readable by anonymous callers. A private
// ticket must be absent from all three, and absent means NOT FOUND —
// a refusal would confirm it exists.
func TestPrivateTicketsAreNotServed(t *testing.T) {
ix := openIndex(t)
mk := func(hash, title, private string) Parsed {
f := []artifact.Field{{Name: "title", Value: title}, {Name: "status", Value: "Open"}}
if private != "" {
f = append(f, artifact.Field{Name: "is_private", Value: private})
}
return Parsed{Hash: hash, Art: &artifact.Artifact{
Type: artifact.Ticket, TicketID: hash, Date: "2026-08-15T10:00:00",
User: "dan", Fields: f}}
}
if err := ix.FoldNow([]Parsed{
mk(strings.Repeat("c1", 20), "public pelican", ""),
mk(strings.Repeat("c2", 20), "public flamingo", "0"),
mk(strings.Repeat("c3", 20), "secret heron", "1"),
mk(strings.Repeat("c4", 20), "secret egret", "true"),
}); err != nil {
t.Fatal(err)
}
list, _, err := ix.TicketSummaries(50, "", true)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Errorf("listing returned %d tickets, want the 2 public ones: %+v", len(list), list)
}
for _, s := range list {
if strings.HasPrefix(s.Title, "secret") {
t.Errorf("a private ticket was listed: %+v", s)
}
}
// Reachable by id? It must report not-found, not forbidden.
if _, ok, err := ix.TicketByPrefix(strings.Repeat("c3", 20)); err != nil || ok {
t.Errorf("a private ticket resolved by id (ok=%v)", ok)
}
if _, ok, err := ix.TicketByPrefix(strings.Repeat("c1", 20)); err != nil || !ok {
t.Errorf("a public ticket stopped resolving (ok=%v)", ok)
}
// Searchable? A snippet leaks the content whether or not the page is.
hits, err := ix.Search("secret", "ticket", 50)
if err != nil {
t.Fatal(err)
}
if len(hits) != 0 {
t.Errorf("a private ticket is in the search corpus: %+v", hits)
}
if hits, _ := ix.Search("pelican", "ticket", 50); len(hits) != 1 {
t.Errorf("public tickets stopped being searchable (%d hits)", len(hits))
}
}
// SQLite discards a conflict clause written inside a trigger body when
// the firing statement carries its own — and check-ins arrive through
// an upsert. With "INSERT OR REPLACE INTO event" in the trigger,
// re-folding an artifact already known aborted the entire batch with a
// UNIQUE failure on event.hash, silently stopping ingest on every
// restart whose frame cursor overlapped the index.
func TestRefoldingAKnownArtifactSucceeds(t *testing.T) {
ix := openIndex(t)
batch := []Parsed{{Hash: strings.Repeat("d1", 32), Art: &artifact.Artifact{
Type: artifact.Manifest, Date: "2026-08-15T10:00:00", User: "dan", Comment: "once"}}}
if err := ix.FoldNow(batch); err != nil {
t.Fatal(err)
}
// The same artifact again, as a replayed frame cursor delivers it.
if err := ix.FoldNow(batch); err != nil {
t.Fatalf("re-folding a known artifact failed: %v", err)
}
var n int
if err := ix.db.QueryRow(`SELECT count(*) FROM event WHERE hash = ?`,
strings.Repeat("d1", 32)).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("event rows for one artifact = %d, want 1", n)
}
}