package index
import (
"fmt"
"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, "", TicketFilter{State: "open"})
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, "", TicketFilter{State: "all"})
if err != nil {
t.Fatal(err)
}
if len(all) != 5 {
t.Errorf("opting in returned %d tickets, want all 5", len(all))
}
// And the finished work browsable on its own, which is the third
// tab every forge offers.
done, _, err := ix.TicketSummaries(50, "", TicketFilter{State: "closed"})
if err != nil {
t.Fatal(err)
}
if len(done) != 3 {
t.Errorf("closed-only returned %d tickets, want the 3 finished ones", len(done))
}
}
// 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, "", TicketFilter{State: "all"})
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)
}
}
// The list row names who opened a ticket, who touched it last, and how
// many remarks it carries — folded with the summary, so the list stays
// an index walk.
func TestTicketSummaryCarriesPeopleAndRemarks(t *testing.T) {
ix := openIndex(t)
id := strings.Repeat("b1", 20)
change := func(hash, date, user string, fields ...artifact.Field) Parsed {
return Parsed{Hash: hash, Art: &artifact.Artifact{
Type: artifact.Ticket, TicketID: id, Date: date, User: user, Fields: fields}}
}
if err := ix.FoldNow([]Parsed{
change(strings.Repeat("c1", 20), "2026-08-15T10:00:00", "ann",
artifact.Field{Name: "title", Value: "t"}, artifact.Field{Name: "status", Value: "Open"},
artifact.Field{Name: "comment", Value: "the description"}),
change(strings.Repeat("c2", 20), "2026-08-16T10:00:00", "bob",
artifact.Field{Name: "icomment", Value: "a remark"}),
change(strings.Repeat("c3", 20), "2026-08-17T10:00:00", "cid",
artifact.Field{Name: "priority", Value: "High"}),
change(strings.Repeat("c4", 20), "2026-08-18T10:00:00", "dee",
artifact.Field{Name: "icomment", Value: "another"}),
}); err != nil {
t.Fatal(err)
}
got, _, err := ix.TicketSummaries(10, "", TicketFilter{State: "open"})
if err != nil || len(got) != 1 {
t.Fatalf("got %d rows, err %v", len(got), err)
}
if got[0].Author != "ann" || got[0].LastUser != "dee" || got[0].Comments != 2 {
t.Errorf("author %q last %q comments %d; want ann dee 2", got[0].Author, got[0].LastUser, got[0].Comments)
}
}
// A report is the URL: each axis narrows the listing, and the header's
// counts must be counted through the SAME filter. A header reading "37
// open" above four rows is two numbers a reader cannot reconcile, and
// it was the first thing wrong with the old page.
func TestReportAxesNarrowListingAndCountsTogether(t *testing.T) {
ix := openIndex(t)
mk := func(hash, status, typ, sub, who string) Parsed {
return Parsed{Hash: hash, Art: &artifact.Artifact{
Type: artifact.Ticket, TicketID: hash, Date: "2026-08-15T10:00:00", User: who,
Fields: []artifact.Field{
{Name: "title", Value: "t-" + hash[:2]}, {Name: "status", Value: status},
{Name: "type", Value: typ}, {Name: "subsystem", Value: sub}},
}}
}
if err := ix.FoldNow([]Parsed{
mk(strings.Repeat("b1", 20), "Open", "Code_Defect", "web", "dan"),
mk(strings.Repeat("b2", 20), "Open", "Code_Defect", "api", "dan"),
mk(strings.Repeat("b3", 20), "Fixed", "Code_Defect", "web", "ana"),
mk(strings.Repeat("b4", 20), "Open", "Feature_Request", "web", "ana"),
}); err != nil {
t.Fatal(err)
}
web := TicketFilter{Subsystem: "web"}
got, _, err := ix.TicketSummaries(50, "", web)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 { // b1 and b4; b3 is closed, b2 is api
t.Fatalf("subsystem=web open listing has %d rows, want 2", len(got))
}
open, closed, err := ix.TicketCounts(web)
if err != nil {
t.Fatal(err)
}
if open != 2 || closed != 1 {
t.Errorf("counts through subsystem=web = %d/%d, want 2/1 — the header must describe the listing", open, closed)
}
// Axes compose, and match case-insensitively like every other
// status comparison in this file.
both, _, err := ix.TicketSummaries(50, "", TicketFilter{Subsystem: "WEB", Type: "feature_request"})
if err != nil {
t.Fatal(err)
}
if len(both) != 1 || both[0].Type != "Feature_Request" {
t.Errorf("two composed axes returned %+v, want only the web Feature_Request", both)
}
// The facets offer what the tracker HOLDS, not what a config
// guessed, counted by OPEN work and ordered by it: a browse list
// answers "where is the work" before "what exists". web has two
// open (b1, b4) against api's one, so web leads despite the alphabet.
subs, authors, err := ix.TicketFacets(false)
if err != nil {
t.Fatal(err)
}
var facets []string
for _, f := range subs {
facets = append(facets, fmt.Sprintf("%s=%d", f.Value, f.Open))
}
if strings.Join(facets, ",") != "web=2,api=1" {
t.Errorf("subsystem facets = %v, want web=2,api=1", facets)
}
// b3 is ana's and closed, so ana counts one open (b4) and dan one
// (b1 or b2 — both open); the tie falls back to the alphabet.
facets = nil
for _, f := range authors {
facets = append(facets, fmt.Sprintf("%s=%d", f.Value, f.Open))
}
if strings.Join(facets, ",") != "dan=2,ana=1" {
t.Errorf("author facets = %v, want dan=2,ana=1", facets)
}
}
// A column header names a KEY, and the SQL it selects lives in this
// package. A key that is not in the whitelist sorts by the default:
// a listing reached from a stale link should degrade, not 500.
func TestTicketOrderIsAWhitelist(t *testing.T) {
ix := openIndex(t)
mk := func(hash, title, 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: title}, {Name: "status", Value: status}},
}}
}
if err := ix.FoldNow([]Parsed{
mk(strings.Repeat("c1", 20), "charlie", "Open"),
mk(strings.Repeat("c2", 20), "alpha", "Open"),
mk(strings.Repeat("c3", 20), "bravo", "Open"),
}); err != nil {
t.Fatal(err)
}
titles := func(f TicketFilter) []string {
got, _, err := ix.TicketSummaries(50, "", f)
if err != nil {
t.Fatal(err)
}
var out []string
for _, s := range got {
out = append(out, s.Title)
}
return out
}
if got := strings.Join(titles(TicketFilter{Order: "title"}), ","); got != "alpha,bravo,charlie" {
t.Errorf("order=title gave %q", got)
}
// Not in the map, and shaped like an injection attempt: both must
// land on the default order rather than reaching the query.
for _, bogus := range []string{"nonsense", "title; DROP TABLE ticket", "updated DESC--"} {
if got := titles(TicketFilter{Order: bogus}); len(got) != 3 {
t.Errorf("order=%q returned %d rows, want the default listing's 3", bogus, len(got))
}
}
if _, ok := TicketOrders["title"]; !ok {
t.Error("the table's columns must each name a key of TicketOrders")
}
}
// The board groups by assignee, so assignee has to be a column on the
// folded summary and not a per-ticket join. Three things have to hold:
// it folds like every other field (last mention wins, EMPTY INCLUDED,
// so a change can unassign), unassigned is askable as a filter, and an
// absent field reads as unassigned rather than as an error.
func TestAssigneeFoldsAndUnassignedIsAskable(t *testing.T) {
ix := openIndex(t)
id := func(n string) string { return strings.Repeat(n, 20) }
mk := func(hash, ticket, date string, fields ...artifact.Field) Parsed {
return Parsed{Hash: hash, Art: &artifact.Artifact{
Type: artifact.Ticket, TicketID: ticket, Date: date, User: "dan",
Fields: fields}}
}
if err := ix.FoldNow([]Parsed{
// Assigned, then reassigned: the later mention wins.
mk(id("b1"), id("t1"), "2026-08-15T10:00:00",
artifact.Field{Name: "title", Value: "one"},
artifact.Field{Name: "status", Value: "Open"},
artifact.Field{Name: "assignee", Value: "mia"}),
mk(id("b2"), id("t1"), "2026-08-16T10:00:00",
artifact.Field{Name: "assignee", Value: "nils"}),
// Assigned, then CLEARED. An empty mention unassigns, which is
// fossil's folding rule; keeping the old value would make a
// ticket impossible to hand back.
mk(id("c1"), id("t2"), "2026-08-15T10:00:00",
artifact.Field{Name: "title", Value: "two"},
artifact.Field{Name: "status", Value: "Open"},
artifact.Field{Name: "assignee", Value: "mia"}),
mk(id("c2"), id("t2"), "2026-08-16T10:00:00",
artifact.Field{Name: "assignee", Value: ""}),
// Never mentioned at all — a tracker with no assignee column.
mk(id("d1"), id("t3"), "2026-08-15T10:00:00",
artifact.Field{Name: "title", Value: "three"},
artifact.Field{Name: "status", Value: "Open"}),
}); err != nil {
t.Fatal(err)
}
byTitle := func(f TicketFilter) map[string]string {
rows, _, err := ix.TicketSummaries(50, "", f)
if err != nil {
t.Fatal(err)
}
out := map[string]string{}
for _, r := range rows {
out[r.Title] = r.Assignee
}
return out
}
all := byTitle(TicketFilter{State: "all"})
if all["one"] != "nils" {
t.Errorf("FAIL: reassignment did not fold: one = %q, want nils", all["one"])
}
if all["two"] != "" {
t.Errorf("FAIL: an empty mention did not unassign: two = %q", all["two"])
}
if all["three"] != "" {
t.Errorf("FAIL: a ticket with no assignee field reads as %q", all["three"])
}
// Narrowing to one person.
mine := byTitle(TicketFilter{State: "all", Assignee: "nils", AssigneeSet: true})
if len(mine) != 1 || mine["one"] != "nils" {
t.Errorf("FAIL: filtering by assignee gave %v", mine)
}
// And the column a board exists for: nobody. The empty string has
// to be a REAL filter value here, which is why it needs its own
// flag — filterable()'s rule is that empty means "do not narrow",
// and under that rule the unassigned column would be unaskable.
nobody := byTitle(TicketFilter{State: "all", Assignee: "", AssigneeSet: true})
if len(nobody) != 2 {
t.Errorf("FAIL: the unassigned column holds %v, want two and three", nobody)
}
// Without the flag, empty must still mean "every ticket".
if got := byTitle(TicketFilter{State: "all"}); len(got) != 3 {
t.Errorf("FAIL: an unset assignee narrowed the listing to %v", got)
}
}