Skip to content
quarry Artifact [4f983a7945]
Login

Artifact [4f983a7945]

Artifact 4f983a7945fff735240ae4372b4607007b3f8ecb953235b65412033a68318a00:


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, "", 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)
	}
}

func TestDefaultBranchPrefersNewestAlias(t *testing.T) {
	ix := openIndex(t)
	fold := func(hash, branch, date string) {
		if _, err := ix.db.Exec(`INSERT INTO checkin (hash, date, user, comment, branch)
			VALUES (?, ?, 'u', 'c', ?)`, hash, date, branch); err != nil {
			t.Fatal(err)
		}
	}
	if got := ix.DefaultBranch(); got != "trunk" {
		t.Fatalf("empty repo default = %q, want trunk", got)
	}
	fold("a1", "trunk", "2011-03-05T00:00:00")
	if got := ix.DefaultBranch(); got != "trunk" {
		t.Fatalf("trunk-only = %q", got)
	}
	// The rename: work continues on main; the literal trunk stays.
	fold("b2", "main", "2026-08-01T00:00:00")
	if got := ix.DefaultBranch(); got != "main" {
		t.Fatalf("renamed repo = %q, want main", got)
	}
	// A feature branch newer than both changes nothing.
	fold("c3", "wild-idea", "2026-08-15T00:00:00")
	if got := ix.DefaultBranch(); got != "main" {
		t.Fatalf("feature branch stole the default: %q", got)
	}
}

// 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.
	subs, authors, err := ix.TicketFacets()
	if err != nil {
		t.Fatal(err)
	}
	if strings.Join(subs, ",") != "api,web" {
		t.Errorf("subsystem facets = %v, want api,web sorted", subs)
	}
	if strings.Join(authors, ",") != "ana,dan" {
		t.Errorf("author facets = %v, want ana,dan sorted", authors)
	}
}

// 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")
	}
}