Skip to content
quarry Artifact [2994bfb2e3]
Login

Artifact [2994bfb2e3]

Artifact 2994bfb2e32ebbd1137a927bea0b7394d0844957fd811da8b8d09392b129c985:


package api

import (
	"context"
	"errors"
	"fmt"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/artifact"
	"craftdesign.group/quarry/internal/chat"
	"craftdesign.group/quarry/internal/index"

	"craftdesign.group/quarry/internal/ops"
)

func TestRegisterAllAndVersionOp(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "1.2.3-test"}); err != nil {
		t.Fatal(err)
	}
	got, err := r.Invoke(context.Background(), "meta.version", versionIn{})
	if err != nil {
		t.Fatal(err)
	}
	if got.(versionOut).Version != "1.2.3-test" {
		t.Fatalf("got %+v", got)
	}
}

func TestRegisteredOpsAppearInSpec(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v"}); err != nil {
		t.Fatal(err)
	}
	spec, err := ops.Spec(r, "quarry", "v")
	if err != nil {
		t.Fatal(err)
	}
	if spec.Paths["/api/v1/version"].Get == nil {
		t.Fatal("meta.version missing from generated spec")
	}
}

func wikiIndex(t *testing.T) *index.Index {
	t.Helper()
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	batch := []index.Parsed{
		{Hash: "aaa1", Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "Design",
			Date: "2026-08-15T10:00:00", User: "dan", Mimetype: "text/x-markdown",
			Body: []byte("# Design\n\ndetails\n")}},
		{Hash: "aaa2", Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "Home",
			Date: "2026-08-15T10:00:01", User: "dan", Mimetype: "text/x-markdown",
			Body: []byte("See [[Design]] and [[Missing]].\n")}},
		{Hash: "aaa3", Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "Home",
			Date: "2026-08-15T11:00:00", User: "eve", Mimetype: "text/x-markdown",
			Body: []byte("Revised. See [[Design]].\n")}},
	}
	if err := ix.FoldNow(batch); err != nil {
		t.Fatal(err)
	}
	return ix
}

func TestWikiOps(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil {
		t.Fatal(err)
	}

	got, err := r.Invoke(context.Background(), "wiki.list", wikiListIn{Repo: "test"})
	if err != nil {
		t.Fatal(err)
	}
	list := got.(wikiListOut)
	if list.Count != 2 || list.Pages[0].Title != "Design" || list.Pages[1].Title != "Home" {
		t.Fatalf("list = %+v", list)
	}
	if list.Pages[1].User != "eve" {
		t.Fatalf("latest edit not surfaced: %+v", list.Pages[1])
	}

	got, err = r.Invoke(context.Background(), "wiki.get", wikiGetIn{Repo: "test", Name: "Home"})
	if err != nil {
		t.Fatal(err)
	}
	page := got.(wikiGetOut)
	if !strings.Contains(page.HTML, `<a href="/r/test/wiki/Design">Design</a>`) {
		t.Fatalf("existing page not resolved:\n%s", page.HTML)
	}
	if !strings.Contains(page.Source, "Revised") {
		t.Fatalf("stale edit served: %+v", page)
	}

	if _, err := r.Invoke(context.Background(), "wiki.get", wikiGetIn{Repo: "test", Name: "Missing"}); err == nil {
		t.Fatal("want error for missing page")
	}
}

func TestWikiHistoryAndRevision(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil {
		t.Fatal(err)
	}

	got, err := r.Invoke(context.Background(), "wiki.history", wikiHistoryIn{Repo: "test", Name: "Home"})
	if err != nil {
		t.Fatal(err)
	}
	h := got.(wikiHistoryOut)
	if h.Count != 2 || h.Edits[0].Hash != "aaa3" || h.Edits[1].Hash != "aaa2" {
		t.Fatalf("history = %+v", h)
	}
	if h.Edits[0].User != "eve" || h.Edits[1].User != "dan" {
		t.Fatalf("edit users = %+v", h.Edits)
	}

	// An old revision serves its own content, not the fold.
	got, err = r.Invoke(context.Background(), "wiki.get", wikiGetIn{Repo: "test", Name: "Home", Rev: "aaa2"})
	if err != nil {
		t.Fatal(err)
	}
	page := got.(wikiGetOut)
	if page.Hash != "aaa2" || !strings.Contains(page.Source, "Missing") || strings.Contains(page.Source, "Revised") {
		t.Fatalf("revision fetch = %+v", page)
	}

	if _, err := r.Invoke(context.Background(), "wiki.get", wikiGetIn{Repo: "test", Name: "Home", Rev: "ffff"}); err == nil {
		t.Fatal("want error for unknown revision")
	}
	// "aaa" prefixes both edits of Home — ambiguous resolves to nothing.
	if _, err := r.Invoke(context.Background(), "wiki.get", wikiGetIn{Repo: "test", Name: "Home", Rev: "aaa"}); err == nil {
		t.Fatal("want error for ambiguous revision prefix")
	}
	if _, err := r.Invoke(context.Background(), "wiki.history", wikiHistoryIn{Repo: "test", Name: "Missing"}); err == nil {
		t.Fatal("want error for missing page history")
	}
}

func TestWikiGetUnresolvedRefStaysText(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil {
		t.Fatal(err)
	}
	// The older Home revision links [[Missing]]; current revision does
	// not — fetch Design's referrer instead via a fresh page write.
	got, err := r.Invoke(context.Background(), "wiki.get", wikiGetIn{Repo: "test", Name: "Design"})
	if err != nil {
		t.Fatal(err)
	}
	if strings.Contains(got.(wikiGetOut).HTML, "/r/test/wiki/Missing") {
		t.Fatal("nonexistent page linked")
	}
}

func TestCheckinGetAndHashResolution(t *testing.T) {
	ix := wikiIndex(t)
	h1 := strings.Repeat("ab", 32)
	h2 := "abcd" + strings.Repeat("ef", 30)
	if err := ix.FoldNow([]index.Parsed{
		{Hash: h1, Art: &artifact.Artifact{Type: artifact.Manifest,
			Date: "2026-08-15T09:00:00", User: "dan", Comment: "first"}},
		{Hash: h2, Art: &artifact.Artifact{Type: artifact.Manifest,
			Date: "2026-08-15T09:30:00", User: "dan", Comment: "second", Parents: []string{h1}}},
		{Hash: "bbb1", Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "Refs",
			Date: "2026-08-15T12:00:00", User: "dan", Mimetype: "text/x-markdown",
			Body: []byte("Fixed in " + h2[:12] + ". Ambiguous: ab.\n")}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil {
		t.Fatal(err)
	}

	// Prefix lookup: unique resolves, ambiguous does not.
	got, err := r.Invoke(context.Background(), "checkin.get", checkinGetIn{Repo: "test", Hash: h2[:12]})
	if err != nil {
		t.Fatal(err)
	}
	ci := got.(checkinGetOut)
	if ci.Hash != h2 || len(ci.Parents) != 1 || ci.Parents[0] != h1 {
		t.Fatalf("checkin = %+v", ci)
	}
	if _, err := r.Invoke(context.Background(), "checkin.get", checkinGetIn{Repo: "test", Hash: "abcdzz"}); err == nil {
		t.Fatal("want error for unknown hash")
	}

	// Wiki body hash references resolve through the same lookup.
	got, err = r.Invoke(context.Background(), "wiki.get", wikiGetIn{Repo: "test", Name: "Refs"})
	if err != nil {
		t.Fatal(err)
	}
	html := got.(wikiGetOut).HTML
	if !strings.Contains(html, `<a href="/r/test/checkin/`+h2+`">`+h2[:12]+`</a>`) {
		t.Fatalf("hash ref not resolved:\n%s", html)
	}
}

func TestTicketOps(t *testing.T) {
	ix := wikiIndex(t)
	tid := strings.Repeat("cd", 20)
	if err := ix.FoldNow([]index.Parsed{
		{Hash: "ccc1", Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid,
			Date: "2026-08-15T09:00:00", User: "dan",
			Fields: []artifact.Field{{Name: "status", Value: "Open"}, {Name: "title", Value: "crash on empty input"}}}},
		{Hash: "ccc2", Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid,
			Date: "2026-08-15T10:00:00", User: "eve",
			Fields: []artifact.Field{{Name: "resolution", Value: "Fixed"}, {Name: "status", Value: "Closed"}}}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil {
		t.Fatal(err)
	}

	got, err := r.Invoke(context.Background(), "ticket.list", ticketListIn{Repo: "test"})
	if err != nil {
		t.Fatal(err)
	}
	list := got.(ticketListOut)
	if list.Count != 1 || list.Tickets[0].Status != "Closed" || list.Tickets[0].Title != "crash on empty input" {
		t.Fatalf("list = %+v", list)
	}

	got, err = r.Invoke(context.Background(), "ticket.get", ticketGetIn{Repo: "test", ID: tid[:8]})
	if err != nil {
		t.Fatal(err)
	}
	tk := got.(ticketGetOut).Ticket
	if tk.ID != tid || len(tk.Changes) != 2 {
		t.Fatalf("ticket = %+v", tk)
	}
	if fieldValue(tk.Fields, "status") != "Closed" || fieldValue(tk.Fields, "resolution") != "Fixed" {
		t.Fatalf("fold wrong: %+v", tk.Fields)
	}
	if tk.Created != "2026-08-15T09:00:00" || tk.Updated != "2026-08-15T10:00:00" {
		t.Fatalf("created/updated: %+v", tk)
	}
}

func TestSearchOp(t *testing.T) {
	ix := wikiIndex(t)
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil {
		t.Fatal(err)
	}
	got, err := r.Invoke(context.Background(), "search.query", searchIn{Repo: "test", Q: "Design", Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	res := got.(searchOut)
	if res.Count == 0 {
		t.Fatalf("no hits: %+v", res)
	}
}

func TestEntityNotFoundWrapsSentinel(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil {
		t.Fatal(err)
	}
	for name, in := range map[string]any{
		"wiki.get":     wikiGetIn{Repo: "test", Name: "NoSuchPage"},
		"ticket.get":   ticketGetIn{Repo: "test", ID: "deadbeef"},
		"checkin.get":  checkinGetIn{Repo: "test", Hash: "deadbeefdeadbeef"},
		"checkin.diff": diffGetIn{Repo: "test", Hash: "deadbeefdeadbeef"},
	} {
		_, err := r.Invoke(context.Background(), name, in)
		if !errors.Is(err, ops.ErrNotFound) {
			t.Errorf("%s: err %v does not wrap ErrNotFound", name, err)
		}
	}
}

func TestSearchTicketDocIsFoldedState(t *testing.T) {
	// tickets 050 + 130: search over tickets sees CURRENT state with a
	// human label; stale early-change values don't match at all.
	ix := wikiIndex(t)
	tid := strings.Repeat("ef", 20)
	if err := ix.FoldNow([]index.Parsed{
		{Hash: "ddd1", Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid,
			Date: "2026-08-15T09:00:00", User: "dan",
			Fields: []artifact.Field{{Name: "status", Value: "Openish"}, {Name: "title", Value: "typo in docs"}}}},
		{Hash: "ddd2", Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid,
			Date: "2026-08-15T10:00:00", User: "eve",
			Fields: []artifact.Field{{Name: "status", Value: "Closedish"}}}},
	}); err != nil {
		t.Fatal(err)
	}
	hits, err := ix.Search("typo", "", 10)
	if err != nil {
		t.Fatal(err)
	}
	if len(hits) != 1 || hits[0].Label != "typo in docs" {
		t.Fatalf("hits = %+v", hits)
	}
	if !strings.Contains(hits[0].Snippet, "⟦typo⟧") {
		t.Fatalf("marker missing: %q", hits[0].Snippet)
	}
	// The superseded status value is gone from the corpus entirely.
	if stale, _ := ix.Search("Openish", "", 10); len(stale) != 0 {
		t.Fatalf("stale field value still searchable: %+v", stale)
	}
	if cur, _ := ix.Search("Closedish", "", 10); len(cur) != 1 {
		t.Fatalf("current field value not searchable: %+v", cur)
	}
}

func TestSearchLimitZeroIsLiteral(t *testing.T) {
	// ticket 080: an explicit limit=0 caps to zero results.
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil {
		t.Fatal(err)
	}
	got, err := r.Invoke(context.Background(), "search.query", searchIn{Repo: "test", Q: "Design", Limit: 0})
	if err != nil {
		t.Fatal(err)
	}
	if got.(searchOut).Count != 0 {
		t.Fatalf("limit=0 returned results: %+v", got)
	}
}

// mustRepo fetches a registered repo in a test, failing loudly rather
// than nil-panicking three lines later.
func mustRepo(t *testing.T, rs *Repos, name string) *Repo {
	t.Helper()
	r, ok := rs.Get(name)
	if !ok {
		t.Fatalf("no repo %q registered", name)
	}
	return r
}

func testRepos(ix *index.Index) *Repos {
	return NewRepos(map[string]*Repo{"test": {Index: ix}})
}

func TestUnknownRepoIsNotFound(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil {
		t.Fatal(err)
	}
	_, err := r.Invoke(context.Background(), "wiki.list", wikiListIn{Repo: "nope"})
	if !errors.Is(err, ops.ErrNotFound) {
		t.Fatalf("unknown repo: err %v does not wrap ErrNotFound", err)
	}
}

func TestRepoList(t *testing.T) {
	ix := wikiIndex(t)
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: NewRepos(map[string]*Repo{
		"beta": {Index: ix}, "alpha": {Index: ix},
	})}); err != nil {
		t.Fatal(err)
	}
	got, err := r.Invoke(context.Background(), "repo.list", repoListIn{})
	if err != nil {
		t.Fatal(err)
	}
	fl := got.(repoListOut)
	if fl.Count != 2 || fl.Repos[0].Name != "alpha" || fl.Repos[1].Name != "beta" {
		t.Fatalf("fleet = %+v", fl)
	}
}

func TestTimelinePagination(t *testing.T) {
	ix := wikiIndex(t)
	var hashes []string
	var batch []index.Parsed
	for i := 0; i < 5; i++ {
		h := strings.Repeat(fmt.Sprintf("%02d", i), 32)
		hashes = append(hashes, h)
		batch = append(batch, index.Parsed{Hash: h, Art: &artifact.Artifact{
			Type: artifact.Manifest, User: "dan",
			Date:    fmt.Sprintf("2026-08-15T0%d:00:00", i),
			Comment: fmt.Sprintf("commit %d", i),
		}})
	}
	if err := ix.FoldNow(batch); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil {
		t.Fatal(err)
	}

	got, err := r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 2, Y: "ci"})
	if err != nil {
		t.Fatal(err)
	}
	p1 := got.(timelineOut)
	if p1.Count != 2 || p1.Entries[0].Comment != "commit 4" || p1.Next == "" {
		t.Fatalf("page 1 = %+v", p1)
	}
	got, err = r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 2, Before: p1.Next, Y: "ci"})
	if err != nil {
		t.Fatal(err)
	}
	p2 := got.(timelineOut)
	if p2.Count != 2 || p2.Entries[0].Comment != "commit 2" || p2.Next == "" {
		t.Fatalf("page 2 = %+v", p2)
	}
	got, err = r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 2, Before: p2.Next, Y: "ci"})
	if err != nil {
		t.Fatal(err)
	}
	p3 := got.(timelineOut)
	if p3.Count != 1 || p3.Entries[0].Comment != "commit 0" || p3.Next != "" {
		t.Fatalf("page 3 = %+v", p3)
	}
}

func branchArts(h1, h2, h3 string) []index.Parsed {
	// root (tagged trunk) ← child (untagged) ← branch start (tagged dev)
	return []index.Parsed{
		{Hash: h1, Art: &artifact.Artifact{Type: artifact.Manifest,
			Date: "2026-08-15T09:00:00", User: "dan", Comment: "root",
			Tags: []artifact.Tag{{Name: "*branch", Target: "*", Value: "trunk"}}}},
		{Hash: h2, Art: &artifact.Artifact{Type: artifact.Manifest,
			Date: "2026-08-15T09:10:00", User: "dan", Comment: "on trunk",
			Parents: []string{h1}}},
		{Hash: h3, Art: &artifact.Artifact{Type: artifact.Manifest,
			Date: "2026-08-15T09:20:00", User: "dan", Comment: "start dev",
			Parents: []string{h2},
			Tags:    []artifact.Tag{{Name: "*branch", Target: "*", Value: "dev"}}}},
	}
}

func TestBranchPropagation(t *testing.T) {
	h1, h2, h3 := strings.Repeat("11", 32), strings.Repeat("22", 32), strings.Repeat("33", 32)
	arts := branchArts(h1, h2, h3)

	// Fold twice: forward order, and children-before-parents. The
	// wholesale recompute must converge identically (order independence
	// is what keeps the branch column disposability-safe).
	for name, order := range map[string][]int{"forward": {0, 1, 2}, "reversed": {2, 1, 0}} {
		ix, err := index.Open(filepath.Join(t.TempDir(), "i.db"))
		if err != nil {
			t.Fatal(err)
		}
		for _, i := range order {
			if err := ix.FoldNow(arts[i : i+1]); err != nil {
				t.Fatal(err)
			}
		}
		tl, _, err := ix.Timeline(10, "", "", "ci")
		if err != nil {
			t.Fatal(err)
		}
		got := map[string]string{}
		for _, e := range tl {
			got[e.Comment] = e.Branch
		}
		want := map[string]string{"root": "trunk", "on trunk": "trunk", "start dev": "dev"}
		for c, b := range want {
			if got[c] != b {
				t.Errorf("%s order: %q on branch %q, want %q", name, c, got[c], b)
			}
		}
		ix.Close()
	}
}

func TestBranchFilterAndControlRetag(t *testing.T) {
	h1, h2, h3 := strings.Repeat("44", 32), strings.Repeat("55", 32), strings.Repeat("66", 32)
	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(branchArts(h1, h2, h3)); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil {
		t.Fatal(err)
	}

	got, err := r.Invoke(context.Background(), "timeline.list",
		timelineIn{Repo: "test", Limit: 10, Branch: "dev"})
	if err != nil {
		t.Fatal(err)
	}
	tl := got.(timelineOut)
	if tl.Count != 1 || tl.Entries[0].Comment != "start dev" {
		t.Fatalf("dev filter = %+v", tl)
	}

	// A later control artifact moves h3 to another branch; the recompute
	// must pick it up. (Tag priority between conflicting sources is
	// future work; a single retag must work.)
	if err := ix.FoldNow([]index.Parsed{{Hash: "ctl1", Art: &artifact.Artifact{
		Type: artifact.Control, Date: "2026-08-15T10:00:00", User: "dan",
		Tags: []artifact.Tag{{Name: "*branch", Target: h3, Value: "renamed"}},
	}}}); err != nil {
		t.Fatal(err)
	}
	got, err = r.Invoke(context.Background(), "timeline.list",
		timelineIn{Repo: "test", Limit: 10, Branch: "renamed"})
	if err != nil {
		t.Fatal(err)
	}
	if got.(timelineOut).Count != 1 {
		t.Fatalf("control retag not applied: %+v", got)
	}
}

func TestUnifiedTimeline(t *testing.T) {
	// wikiIndex has 3 wiki edits; add a check-in and a ticket change,
	// then assert the kinds interleave by date and each filter isolates.
	ix := wikiIndex(t)
	ci := strings.Repeat("77", 32)
	tid := strings.Repeat("88", 20)
	if err := ix.FoldNow([]index.Parsed{
		{Hash: ci, Art: &artifact.Artifact{Type: artifact.Manifest,
			Date: "2026-08-15T10:30:00", User: "dan", Comment: "between the wiki edits"}},
		{Hash: "tk1", Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid,
			Date: "2026-08-15T12:00:00", User: "eve",
			Fields: []artifact.Field{{Name: "title", Value: "a ticket"}, {Name: "status", Value: "Open"}}}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil {
		t.Fatal(err)
	}

	got, err := r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	all := got.(timelineOut)
	// 3 wiki + 1 ci + 1 ticket = 5, newest first: ticket, wiki, ci, wiki, wiki
	kinds := []string{}
	for _, e := range all.Entries {
		kinds = append(kinds, e.Kind)
	}
	want := []string{"t", "w", "ci", "w", "w"}
	if fmt.Sprint(kinds) != fmt.Sprint(want) {
		t.Fatalf("kinds = %v, want %v", kinds, want)
	}
	// Ticket row carries the folded title and its id as ref.
	if e := all.Entries[0]; e.Ref != tid || !strings.Contains(e.Comment, "a ticket") {
		t.Fatalf("ticket event = %+v", e)
	}
	// Wiki row refs its title.
	if e := all.Entries[1]; e.Ref != "Home" || !strings.Contains(e.Comment, "Home") {
		t.Fatalf("wiki event = %+v", e)
	}

	for y, wantN := range map[string]int{"ci": 1, "w": 3, "t": 1} {
		got, err := r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 50, Y: y})
		if err != nil {
			t.Fatal(err)
		}
		if n := got.(timelineOut).Count; n != wantN {
			t.Fatalf("y=%s: count %d, want %d", y, n, wantN)
		}
	}
}

// TestTicketListPagination walks the composite updated~ticket cursor
// across three pages and checks global order and completeness.
func TestTicketListPagination(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	var batch []index.Parsed
	for i := 0; i < 7; i++ {
		tid := strings.Repeat(fmt.Sprintf("%02d", i), 20)
		batch = append(batch, index.Parsed{
			Hash: fmt.Sprintf("h%02d", i),
			Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid,
				// Two tickets share an updated stamp to exercise the id tiebreak.
				Date: fmt.Sprintf("2026-08-15T%02d:00:00", 10+i/2), User: "dan",
				Fields: []artifact.Field{{Name: "title", Value: fmt.Sprintf("t%d", i)}}},
		})
	}
	if err := ix.FoldNow(batch); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil {
		t.Fatal(err)
	}

	var seen []string
	before := ""
	pages := 0
	for {
		got, err := r.Invoke(context.Background(), "ticket.list",
			ticketListIn{Repo: "test", Limit: 3, Before: before})
		if err != nil {
			t.Fatal(err)
		}
		out := got.(ticketListOut)
		if len(out.Tickets) == 0 {
			t.Fatalf("empty page %d", pages)
		}
		for _, tk := range out.Tickets {
			seen = append(seen, tk.ID)
		}
		pages++
		if out.Next == "" {
			break
		}
		before = out.Next
	}
	if pages != 3 || len(seen) != 7 {
		t.Fatalf("pages=%d seen=%d %v", pages, len(seen), seen)
	}
	uniq := map[string]bool{}
	for _, id := range seen {
		if uniq[id] {
			t.Fatalf("duplicate across pages: %s", id)
		}
		uniq[id] = true
	}
}

func forumTechIndex(t *testing.T) *index.Index {
	t.Helper()
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	root := strings.Repeat("f0", 20)
	reply := strings.Repeat("f1", 20)
	ev := strings.Repeat("e0", 20)
	if err := ix.FoldNow([]index.Parsed{
		{Hash: root, Art: &artifact.Artifact{Type: artifact.Forum,
			ThreadTitle: "Design question", Date: "2026-08-16T09:00:00", User: "dan",
			Body: []byte("How should we shape the [[Design]] doc?\n")}},
		{Hash: reply, Art: &artifact.Artifact{Type: artifact.Forum,
			ThreadRoot: root, InReplyTo: root, Date: "2026-08-16T10:00:00", User: "eve",
			Body: []byte("Start from the sync RFC.\n")}},
		{Hash: strings.Repeat("e0a", 14)[:40], Art: &artifact.Artifact{Type: artifact.Technote,
			EventID: ev, EventTime: "2026-08-16T08:00:00", Date: "2026-08-16T08:00:00",
			User: "dan", Mimetype: "text/x-markdown", Comment: "Release notes",
			Body: []byte("# 0.2\n\nshipped the read plane\n")}},
	}); err != nil {
		t.Fatal(err)
	}
	return ix
}

func TestForumOps(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(forumTechIndex(t))}); err != nil {
		t.Fatal(err)
	}
	root := strings.Repeat("f0", 20)

	got, err := r.Invoke(context.Background(), "forum.list", forumListIn{Repo: "test"})
	if err != nil {
		t.Fatal(err)
	}
	list := got.(forumListOut)
	if list.Count != 1 || list.Threads[0].Title != "Design question" || list.Threads[0].Posts != 2 {
		t.Fatalf("forum list = %+v", list)
	}

	got, err = r.Invoke(context.Background(), "forum.get", forumGetIn{Repo: "test", Root: root[:12]})
	if err != nil {
		t.Fatal(err)
	}
	th := got.(forumGetOut)
	if len(th.Posts) != 2 || th.Title != "Design question" {
		t.Fatalf("thread = %+v", th)
	}
	// Oldest first, reply carries its parent, and the opener's [[Design]] does not resolve (no such page) but renders as text.
	if !strings.Contains(th.Posts[0].HTML, "Design") || th.Posts[1].Parent != root {
		t.Fatalf("thread posts = %+v", th.Posts)
	}
	if _, err := r.Invoke(context.Background(), "forum.get", forumGetIn{Repo: "test", Root: "9999"}); err == nil {
		t.Fatal("want error for unknown thread")
	}
}

func TestTechnoteOps(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(forumTechIndex(t))}); err != nil {
		t.Fatal(err)
	}
	ev := strings.Repeat("e0", 20)

	got, err := r.Invoke(context.Background(), "technote.list", technoteListIn{Repo: "test"})
	if err != nil {
		t.Fatal(err)
	}
	if l := got.(technoteListOut); l.Count != 1 || l.Notes[0].Event != ev {
		t.Fatalf("technote list = %+v", l)
	}

	got, err = r.Invoke(context.Background(), "technote.get", technoteGetIn{Repo: "test", Event: ev[:10]})
	if err != nil {
		t.Fatal(err)
	}
	n := got.(technoteGetOut)
	if !strings.Contains(n.HTML, "shipped the read plane") || n.User != "dan" {
		t.Fatalf("technote = %+v", n)
	}
	if _, err := r.Invoke(context.Background(), "technote.get", technoteGetIn{Repo: "test", Event: "zzzz"}); err == nil {
		t.Fatal("want error for unknown technote")
	}
}

// Forum posts and technotes appear on the unified timeline and filter by kind.
func TestForumTechnoteInTimeline(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(forumTechIndex(t))}); err != nil {
		t.Fatal(err)
	}
	got, err := r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 50, Y: "all"})
	if err != nil {
		t.Fatal(err)
	}
	kinds := map[string]int{}
	for _, e := range got.(timelineOut).Entries {
		kinds[e.Kind]++
	}
	if kinds["f"] != 2 || kinds["e"] != 1 {
		t.Fatalf("timeline kinds = %+v", kinds)
	}
	got, _ = r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 50, Y: "f"})
	for _, e := range got.(timelineOut).Entries {
		if e.Kind != "f" {
			t.Fatalf("y=f leaked kind %q", e.Kind)
		}
	}
}

type fakeChat struct{ msgs []chat.Message }

func (f *fakeChat) Post(code string, m chat.Message) error {
	f.msgs = append(f.msgs, m)
	return nil
}
func (f *fakeChat) History(code string, limit int) ([]chat.Message, error) {
	return f.msgs, nil
}

func TestChatOps(t *testing.T) {
	fc := &fakeChat{}
	r := ops.NewRegistry()
	repos := testRepos(wikiIndex(t))
	mustRepo(t, repos, "test").Code = "codeXYZ"
	if err := RegisterAll(r, Deps{Version: "v", Repos: repos, Chat: fc}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(context.Background(), "chat.post", chatPostIn{Repo: "test", Text: "hello chat"}); err != nil {
		t.Fatal(err)
	}
	if len(fc.msgs) != 1 || fc.msgs[0].Text != "hello chat" || fc.msgs[0].User != "anonymous" {
		t.Fatalf("post = %+v", fc.msgs)
	}
	got, err := r.Invoke(context.Background(), "chat.history", chatHistoryIn{Repo: "test"})
	if err != nil {
		t.Fatal(err)
	}
	if h := got.(chatHistoryOut); h.Count != 1 || h.Messages[0].Text != "hello chat" {
		t.Fatalf("history = %+v", h)
	}
	// Empty text is rejected by validation.
	if _, err := r.Invoke(context.Background(), "chat.post", chatPostIn{Repo: "test", Text: ""}); err == nil {
		t.Skip("validation runs in the router layer, not Invoke")
	}
}

// chat ops are absent when no chat plane is configured.
func TestChatOpsAbsentWithoutPlane(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(context.Background(), "chat.post", chatPostIn{Repo: "test", Text: "x"}); err == nil {
		t.Fatal("chat.post should be unregistered without a chat plane")
	}
}

type fakeNotify struct {
	mu     sync.Mutex
	events []NotifyEvent
}

func (f *fakeNotify) Trigger(_ context.Context, e NotifyEvent) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.events = append(f.events, e)
	return nil
}
func (f *fakeNotify) snapshot() []NotifyEvent {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]NotifyEvent(nil), f.events...)
}

func TestChatMentionsFireNotify(t *testing.T) {
	fc := &fakeChat{}
	fn := &fakeNotify{}
	r := ops.NewRegistry()
	repos := testRepos(wikiIndex(t))
	mustRepo(t, repos, "test").Code = "c1"
	if err := RegisterAll(r, Deps{Version: "v", Repos: repos, Chat: fc, Notify: fn}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(context.Background(), "chat.post",
		chatPostIn{Repo: "test", Text: "hey @dan and @eve, see @dan again"}); err != nil {
		t.Fatal(err)
	}
	// fireNotify is async; wait briefly for the goroutines.
	var evs []NotifyEvent
	for i := 0; i < 50; i++ {
		if evs = fn.snapshot(); len(evs) >= 2 {
			break
		}
		time.Sleep(20 * time.Millisecond)
	}
	got := map[string]bool{}
	for _, e := range evs {
		if e.Workflow != "quarry-mention" {
			t.Fatalf("unexpected workflow %q", e.Workflow)
		}
		got[e.To] = true
	}
	if !got["dan"] || !got["eve"] || len(got) != 2 {
		t.Fatalf("mention recipients = %v (want dan, eve, deduped)", got)
	}
}


// mentions() extracts distinct handles.
func TestMentionsParse(t *testing.T) {
	got := mentions("ping @alice, @bob-1 and @alice again; email [email protected] is NOT a mention")
	want := map[string]bool{"alice": true, "bob-1": true}
	for _, g := range got {
		if !want[g] {
			t.Fatalf("unexpected mention %q in %v (email @ should not match)", g, got)
		}
	}
	if len(got) != 2 {
		t.Fatalf("mentions = %v (want alice, bob-1, deduped, no email)", got)
	}
}