Skip to content
quarry Artifact [dc331ac954]
Login

Artifact [dc331ac954]

Artifact dc331ac95444b0cd1001609339c7201498439d11aa67e4ab611b0e218bc52d71:


package api

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"slices"
	"strings"
	"sync"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/zitcli"

	"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)
	// The hooks, not the byte string: the anchor carries its reference
	// token and its preview attributes (RFC 0011 §14).
	if !strings.Contains(page.HTML, `href="/r/test/wiki/Design"`) ||
		!strings.Contains(page.HTML, `data-ref="wiki/Design"`) {
		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
	// The resolved link carries the check-in's comment: hover title on
	// the anchor, visible excerpt beside it (GitHub's referenced-commit
	// rendering).
	if !strings.Contains(html, `href="/r/test/checkin/`+h2+`"`) ||
		!strings.Contains(html, `title="second"`) ||
		!strings.Contains(html, `>`+h2[:12]+`</a>`) ||
		!strings.Contains(html, `<span class="cref"> (second)</span>`) {
		t.Fatalf("hash ref not resolved with description:\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)
	}

	// The default listing is OPEN tickets, so a closed one is absent
	// until asked for. That is the whole point of the default: across
	// this corpus most of a tracker is finished work.
	got, err := r.Invoke(context.Background(), "ticket.list", ticketListIn{Repo: "test"})
	if err != nil {
		t.Fatal(err)
	}
	if list := got.(ticketListOut); list.Count != 0 {
		t.Fatalf("a closed ticket appeared in the open listing: %+v", list)
	}
	got, err = r.Invoke(context.Background(), "ticket.list", ticketListIn{Repo: "test", State: "all"})
	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(index.TimelineFilter{Limit: 10, Kind: "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)
		}
	}
}

// fakeChat is an in-memory ChatPlane over ONE shared log, exactly as
// JetStream holds one stream for every repository's channel: seq is
// assigned from a single counter and each entry remembers its code, so a
// channel's sequences are sparse and interleaved with its neighbours'.
// Every per-channel answer here is therefore derived by filtering on
// code, which is the property the api ops must not undo.
type fakeChat struct {
	mu    sync.Mutex
	msgs  []chat.Message // every channel's, in stream order
	codes []string       // parallel to msgs: which channel each belongs to
	// tail is what Tail delivers before closing, and tailFrom records
	// the resume point it was asked for — enough to drive chat.stream's
	// wire format without a broker.
	tail     []chat.Message
	tailFrom uint64
}

// of returns one channel's messages in seq order.
func (f *fakeChat) of(code string) []chat.Message {
	var out []chat.Message
	for i, m := range f.msgs {
		if f.codes[i] == code {
			out = append(out, m)
		}
	}
	return out
}

func (f *fakeChat) Post(code string, m chat.Message) (uint64, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if m.User == "" {
		m.User = "anonymous"
	}
	if m.Kind == "" {
		m.Kind = chat.KindMsg
	}
	m.Seq = uint64(len(f.msgs) + 1)
	m.At = time.Now().UTC().Format(time.RFC3339Nano)
	f.msgs = append(f.msgs, m)
	f.codes = append(f.codes, code)
	return m.Seq, nil
}

func (f *fakeChat) History(code string, before uint64, limit int) ([]chat.Message, uint64, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if limit <= 0 {
		limit = 100
	}
	mine := f.of(code)
	if len(mine) == 0 {
		return nil, 0, nil
	}
	if before == 0 || before > mine[len(mine)-1].Seq+1 {
		before = mine[len(mine)-1].Seq + 1
	}
	var out []chat.Message
	for _, m := range mine {
		if m.Seq < before {
			out = append(out, m)
		}
	}
	if len(out) > limit {
		out = out[len(out)-limit:]
	}
	var next uint64
	// before is exclusive, so the cursor is this page's oldest seq.
	if len(out) > 0 && out[0].Seq != mine[0].Seq {
		next = out[0].Seq
	}
	return out, next, nil
}

func (f *fakeChat) Purge(code string, seqs ...uint64) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	drop := map[uint64]bool{}
	for _, seq := range seqs {
		drop[seq] = true
	}
	kept := f.msgs[:0:0]
	for _, m := range f.msgs {
		if !drop[m.Seq] {
			kept = append(kept, m)
		}
	}
	f.msgs = kept
	return nil
}

func (f *fakeChat) Forward(code string, after uint64, limit int) ([]chat.Message, uint64, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if limit <= 0 {
		limit = 100
	}
	var out []chat.Message
	for _, m := range f.of(code) {
		if m.Seq > after {
			out = append(out, m)
		}
	}
	var next uint64
	if len(out) > limit {
		out = out[:limit]
		next = out[len(out)-1].Seq
	}
	return out, next, nil
}

func (f *fakeChat) Range(code string, from, to uint64) ([]chat.Message, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	var out []chat.Message
	for _, m := range f.of(code) {
		if m.Seq >= from && (to == 0 || m.Seq <= to) {
			out = append(out, m)
		}
	}
	return out, nil
}

func (f *fakeChat) Bounds(code string) (uint64, uint64, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	mine := f.of(code)
	if len(mine) == 0 {
		return 0, 0, nil
	}
	return mine[0].Seq, mine[len(mine)-1].Seq, nil
}

func (f *fakeChat) Unread(code string, since uint64) (int, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	n := 0
	for _, m := range f.of(code) {
		if m.Seq > since {
			n++
		}
	}
	return n, nil
}

func (f *fakeChat) Tail(code string, from uint64) (<-chan chat.Message, func()) {
	f.mu.Lock()
	f.tailFrom = from
	msgs := append([]chat.Message(nil), f.tail...)
	f.mu.Unlock()
	ch := make(chan chat.Message, len(msgs))
	for _, m := range msgs {
		ch <- m
	}
	close(ch)
	return ch, func() {}
}

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)
	}
	out, err := r.Invoke(context.Background(), "chat.post", chatPostIn{Repo: "test", Text: "hello chat"})
	if err != nil {
		t.Fatal(err)
	}
	if p := out.(chatPostOut); !p.OK || p.Seq == 0 {
		t.Fatalf("post out = %+v", p)
	}
	if len(fc.msgs) != 1 || fc.msgs[0].Text != "hello chat" || fc.msgs[0].User != "anonymous" || fc.msgs[0].Kind != chat.KindMsg {
		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" || h.Messages[0].Seq == 0 || h.Next != 0 {
		t.Fatalf("history = %+v", h)
	}
	// Empty text is rejected by validation, which runs at the router
	// layer rather than inside Invoke — so the assertion is against the
	// validator itself, not against a call that never reaches it.
	if err := ops.Validate(chatPostIn{Repo: "test", Text: ""}); err == nil {
		t.Fatal("empty chat text passed validation")
	}
	if err := ops.Validate(chatPostIn{Repo: "test", Text: "ok"}); err != nil {
		t.Fatalf("valid chat post rejected: %v", err)
	}
}

// A reply must name a seq that exists in the window; a bogus ref is
// ErrInvalid, and the recorded ref is what a folded chat.get reads
// back as the reply relationship.
func TestChatPostReply(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)
	}
	out, err := r.Invoke(context.Background(), "chat.post", chatPostIn{Repo: "test", Text: "root"})
	if err != nil {
		t.Fatal(err)
	}
	rootSeq := out.(chatPostOut).Seq
	if _, err := r.Invoke(context.Background(), "chat.post", chatPostIn{Repo: "test", Text: "reply", Ref: rootSeq}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(context.Background(), "chat.post", chatPostIn{Repo: "test", Text: "bad ref", Ref: 999}); !errors.Is(err, ops.ErrInvalid) {
		t.Fatalf("reply to a nonexistent seq: err = %v, want ErrInvalid", err)
	}
	got, err := r.Invoke(context.Background(), "chat.get", chatGetIn{Repo: "test", Seq: rootSeq})
	if err != nil {
		t.Fatal(err)
	}
	g := got.(chatGetOut)
	if len(g.Replies) != 1 || g.Replies[0].Text != "reply" || g.Replies[0].Ref != rootSeq {
		t.Fatalf("chat.get replies = %+v", g.Replies)
	}
}

// Edit and delete: own message only (edit within the window), and
// delete permits an "a" caller to remove someone else's — both append
// a tombstone rather than mutating the original.
func TestChatEditDelete(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)
	}
	ctxAlice := ops.WithUser(context.Background(), "alice")
	out, err := r.Invoke(ctxAlice, "chat.post", chatPostIn{Repo: "test", Text: "mine"})
	if err != nil {
		t.Fatal(err)
	}
	seq := out.(chatPostOut).Seq

	ctxBob := ops.WithUser(context.Background(), "bob")
	if _, err := r.Invoke(ctxBob, "chat.edit", chatEditIn{Repo: "test", Seq: seq, Text: "hijacked"}); !errors.Is(err, ops.ErrForbidden) {
		t.Fatalf("bob editing alice's message: err = %v, want ErrForbidden", err)
	}
	if _, err := r.Invoke(ctxAlice, "chat.edit", chatEditIn{Repo: "test", Seq: seq, Text: "mine, edited"}); err != nil {
		t.Fatal(err)
	}
	got, err := r.Invoke(context.Background(), "chat.get", chatGetIn{Repo: "test", Seq: seq})
	if err != nil {
		t.Fatal(err)
	}
	if g := got.(chatGetOut); g.Message.Text != "mine, edited" || !g.Message.Edited {
		t.Fatalf("chat.get after edit = %+v", g.Message)
	}

	if _, err := r.Invoke(ctxBob, "chat.delete", chatDeleteIn{Repo: "test", Seq: seq}); !errors.Is(err, ops.ErrForbidden) {
		t.Fatalf("bob (no caps) deleting alice's message: err = %v, want ErrForbidden", err)
	}
	ctxAdmin := ops.WithCaps(ops.WithUser(context.Background(), "admin"), allCaps{})
	if _, err := r.Invoke(ctxAdmin, "chat.delete", chatDeleteIn{Repo: "test", Seq: seq}); err != nil {
		t.Fatal(err)
	}
	got, err = r.Invoke(context.Background(), "chat.get", chatGetIn{Repo: "test", Seq: seq})
	if err != nil {
		t.Fatal(err)
	}
	if g := got.(chatGetOut); !g.Message.Deleted || g.Message.Text != "" {
		t.Fatalf("chat.get after admin delete = %+v", g.Message)
	}
}

// allCaps satisfies ops.Caps by holding every letter — the admin-delete
// test's stand-in for a real capability set.
type allCaps struct{}

func (allCaps) HasAny(string) bool { return true }

// chat.get on a seq below the window's first sequence reports Expired
// rather than a plain not-found.
func TestChatGetExpired(t *testing.T) {
	fc := &fakeChat{
		msgs:  []chat.Message{{Seq: 50, Kind: chat.KindMsg, Text: "already aged out", At: time.Now().UTC().Format(time.RFC3339Nano)}},
		codes: []string{"codeXYZ"},
	}
	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)
	}
	got, err := r.Invoke(context.Background(), "chat.get", chatGetIn{Repo: "test", Seq: 5})
	if err != nil {
		t.Fatal(err)
	}
	if g := got.(chatGetOut); !g.Expired {
		t.Fatalf("chat.get(5) with window starting at 50 = %+v, want Expired", g)
	}
}

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

// A fleet-wide search must never consult, count, or name a repository
// the caller cannot read. Leaking through a search result is the same
// leak as leaking through a listing, and the hit count moving with a
// private repository's contents would leak it just as surely.
func TestGlobalSearchExcludesUnreadableRepos(t *testing.T) {
	mk := func(t *testing.T, comment string) *index.Index {
		ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
		if err != nil {
			t.Fatal(err)
		}
		t.Cleanup(func() { ix.Close() })
		if err := ix.FoldNow([]index.Parsed{{Hash: strings.Repeat("5c", 32),
			Art: &artifact.Artifact{Type: artifact.Manifest, Date: "2026-08-15T10:00:00",
				User: "dan", Comment: comment}}}); err != nil {
			t.Fatal(err)
		}
		return ix
	}
	open, secret := mk(t, "flamingo in the open"), mk(t, "flamingo in the vault")

	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v",
		Repos:   NewRepos(map[string]*Repo{"open": {Index: open}, "secret": {Index: secret}}),
		CanRead: func(_ context.Context, repo string) bool { return repo != "secret" },
	}); err != nil {
		t.Fatal(err)
	}

	out, err := r.Invoke(context.Background(), "search.all", searchAllIn{Q: "flamingo", Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	got := out.(searchAllOut)

	if got.Searched != 1 {
		t.Errorf("searched %d repositories, want 1 — the private one was consulted", got.Searched)
	}
	for _, h := range got.Results {
		if h.Repo == "secret" {
			t.Fatalf("a private repository's content reached a fleet-wide result: %+v", h)
		}
	}
	for _, b := range got.ByRepo {
		if b.Repo == "secret" {
			t.Fatalf("a private repository was named in the facet: %+v", b)
		}
	}
	if len(got.Results) != 1 {
		t.Errorf("got %d results, want the one public match", len(got.Results))
	}

	// Naming it directly must report NOT FOUND, never an empty result:
	// an empty answer says "exists but nothing matched".
	if _, err := r.Invoke(context.Background(), "search.all",
		searchAllIn{Q: "flamingo", Repo: "secret", Limit: 50}); !errors.Is(err, ops.ErrNotFound) {
		t.Errorf("naming a private repo gave %v, want ErrNotFound", err)
	}
}

// The facet must carry a true count, not the page size. A facet reading
// the limit for every repository tells a reader nothing about where to
// look, which is the only thing it exists to do.
func TestGlobalSearchFacetCountsAreNotThePageSize(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 parsed []index.Parsed
	for i := range 7 {
		parsed = append(parsed, index.Parsed{
			Hash: fmt.Sprintf("%02x%s", i, strings.Repeat("a1", 31)),
			Art: &artifact.Artifact{Type: artifact.Manifest, Date: "2026-08-15T10:00:00",
				User: "dan", Comment: fmt.Sprintf("pelican number %d", i)}})
	}
	if err := ix.FoldNow(parsed); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v",
		Repos: NewRepos(map[string]*Repo{"birds": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	out, err := r.Invoke(context.Background(), "search.all", searchAllIn{Q: "pelican", Limit: 2})
	if err != nil {
		t.Fatal(err)
	}
	got := out.(searchAllOut)
	if len(got.Results) != 2 {
		t.Fatalf("returned %d results, want the limit of 2", len(got.Results))
	}
	if len(got.ByRepo) != 1 || got.ByRepo[0].Count != 7 {
		t.Errorf("facet = %+v, want birds counted 7 despite the limit of 2", got.ByRepo)
	}
}

// The people surface is built from names on artifacts a reader can
// already see, so it exposes nothing new — but a repository they may
// NOT read must contribute neither a name nor a count, or the listing
// leaks that the repository exists and who works on it.
func TestPeopleListExcludesUnreadableRepos(t *testing.T) {
	mk := func(user string) *index.Index {
		ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
		if err != nil {
			t.Fatal(err)
		}
		t.Cleanup(func() { ix.Close() })
		if err := ix.FoldNow([]index.Parsed{{Hash: strings.Repeat("7e", 32),
			Art: &artifact.Artifact{Type: artifact.Manifest, Date: "2026-08-15T10:00:00",
				User: user, Comment: "work"}}}); err != nil {
			t.Fatal(err)
		}
		return ix
	}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v",
		Repos: NewRepos(map[string]*Repo{
			"open": {Index: mk("visible")}, "secret": {Index: mk("hidden")}}),
		CanRead: func(_ context.Context, repo string) bool { return repo != "secret" },
	}); err != nil {
		t.Fatal(err)
	}
	out, err := r.Invoke(context.Background(), "people.list", peopleListIn{Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	got := out.(peopleListOut)
	for _, p := range got.People {
		if p.User == "hidden" {
			t.Fatalf("a private repository's contributor was listed: %+v", p)
		}
		if slices.Contains(p.Repos, "secret") {
			t.Fatalf("a private repository was named on a contributor: %+v", p)
		}
	}
	if got.Total != 1 {
		t.Errorf("total = %d, want 1 — the private repo's person was counted", got.Total)
	}
}

// One person working in several repositories is ONE person on the
// fleet listing, with their events summed and their range spanning all
// of them — not one row per repository.
func TestPeopleListMergesAcrossRepos(t *testing.T) {
	mk := func(date string) *index.Index {
		ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
		if err != nil {
			t.Fatal(err)
		}
		t.Cleanup(func() { ix.Close() })
		if err := ix.FoldNow([]index.Parsed{{Hash: strings.Repeat("8f", 32),
			Art: &artifact.Artifact{Type: artifact.Manifest, Date: date,
				User: "drh", Comment: "work"}}}); err != nil {
			t.Fatal(err)
		}
		return ix
	}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: NewRepos(map[string]*Repo{
		"early": {Index: mk("2001-01-01T00:00:00")},
		"late":  {Index: mk("2026-01-01T00:00:00")},
	})}); err != nil {
		t.Fatal(err)
	}
	out, err := r.Invoke(context.Background(), "people.list", peopleListIn{Limit: 50})
	if err != nil {
		t.Fatal(err)
	}
	got := out.(peopleListOut)
	if len(got.People) != 1 {
		t.Fatalf("got %d rows, want one merged person: %+v", len(got.People), got.People)
	}
	p := got.People[0]
	if p.Events != 2 {
		t.Errorf("events = %d, want 2 summed across repositories", p.Events)
	}
	if len(p.Repos) != 2 {
		t.Errorf("repos = %v, want both", p.Repos)
	}
	if p.First[:4] != "2001" || p.Last[:4] != "2026" {
		t.Errorf("range = %s..%s, want it to span both repositories", p.First, p.Last)
	}
}

// The fossil clone command is offered only where it would work. A
// store holding a sub-manifest artifact is refused by peers below
// Fossil 2.29, and zit documents that disabling emission "cannot
// retire artifacts already written" — so the flag must default to
// FALSE. Offering a command that fails is worse than withholding one
// that would have worked.
func TestFossilCloneDefaultsOff(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v",
		Repos: NewRepos(map[string]*Repo{"unprobed": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	out, err := r.Invoke(context.Background(), "repo.get", repoGetIn{Repo: "unprobed"})
	if err != nil {
		t.Fatal(err)
	}
	if out.(repoInfo).FossilClone {
		t.Error("a repository nobody probed is advertised as fossil-cloneable")
	}
}

// .quarry/tickets.json is repo-authored content feeding sort order and
// form suggestions. The parser's contract: unknown FIELDS are ignored
// (a newer file against an older quarry must not break), unknown SHAPES
// are an error (a typo must not silently become "defaults"), and
// oversized input is refused rather than truncated (a silently
// shortened list would reorder the tail of every sorted view).
func TestParseTicketVocab(t *testing.T) {
	got, err := parseTicketVocab([]byte(`{
		"priority": [" P0", "P1 ", ""],
		"team": ["red", "blue"],
		"severity": []
	}`))
	if err != nil {
		t.Fatal(err)
	}
	if !slices.Equal(got["priority"], []string{"P0", "P1"}) {
		t.Errorf("priority = %v, want trimmed [P0 P1]", got["priority"])
	}
	if _, leaked := got["team"]; leaked {
		t.Error("an unknown field was not ignored")
	}
	if v, present := got["severity"]; !present || len(v) != 0 {
		t.Errorf("an explicit empty list must override (= no suggestions), got %v", v)
	}

	for name, bad := range map[string]string{
		"not an object":  `["P0"]`,
		"wrong values":   `{"priority": "P0"}`,
		"word too long":  `{"priority": ["` + strings.Repeat("x", 81) + `"]}`,
		"too many words": `{"priority": [` + strings.Repeat(`"w",`, 50) + `"w"]}`,
	} {
		if _, err := parseTicketVocab([]byte(bad)); err == nil {
			t.Errorf("%s: accepted", name)
		}
	}
}

// The detail surface is where a private ticket leaked whole: anyone
// holding an id prefix got description, discussion and attachments.
// ticket.get must answer NOT FOUND — indistinguishable from an id that
// never existed, because a "forbidden" confirms there is something to
// want.
func TestTicketGetRefusesPrivateAsNotFound(t *testing.T) {
	ix := wikiIndex(t)
	priv := strings.Repeat("dd", 20)
	if err := ix.FoldNow([]index.Parsed{
		{Hash: "pp1", Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: priv,
			Date: "2026-08-15T09:00:00", User: "dan",
			Fields: []artifact.Field{{Name: "title", Value: "secret"},
				{Name: "status", Value: "Open"}, {Name: "is_private", Value: "1"}}}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil {
		t.Fatal(err)
	}
	_, err := r.Invoke(context.Background(), "ticket.get", ticketGetIn{Repo: "test", ID: priv[:8]})
	if !errors.Is(err, ops.ErrNotFound) {
		t.Fatalf("ticket.get on a private ticket: %v, want ErrNotFound", err)
	}
}

// repo.reindex is the repair path that used to require stopping the
// forge: the offline CLI and serve contend for the same files, so a
// one-repo repair was priced at a whole-forge outage. In process it is
// just an op — but one that wipes derived state, so it must be gated on
// the repository's own `a` letter (its own admin's call, not only the
// forge operator's — docs/hosting.md) and must refuse cleanly where no
// pipeline is wired.
func TestReindexOp(t *testing.T) {
	ix := wikiIndex(t)
	called := 0
	stub := &Repo{Index: ix, Reindex: func() (int, error) { called++; return 42, nil }}
	repos := NewRepos(map[string]*Repo{"test": stub})
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: repos}); err != nil {
		t.Fatal(err)
	}
	d, ok := r.Lookup("repo.reindex")
	if !ok || d.Auth != ops.Admin {
		t.Fatalf("repo.reindex auth = %v, want Admin — a replay competes for the machine, so it is forge performance, not repository business", d)
	}
	got, err := r.Invoke(context.Background(), "repo.reindex", reindexIn{Repo: "test"})
	if err != nil {
		t.Fatal(err)
	}
	if out := got.(reindexOut); out.Artifacts != 42 || called != 1 {
		t.Fatalf("out=%+v called=%d", out, called)
	}
	stub.Reindex = nil
	if _, err := r.Invoke(context.Background(), "repo.reindex", reindexIn{Repo: "test"}); !errors.Is(err, ops.ErrInvalid) {
		t.Fatalf("nil pipeline: %v, want ErrInvalid", err)
	}
}

// parseRepoConfig guards the same boundary parseTicketVocab does: a
// repo-authored file that feeds links and labels. The homepage must be
// http(s) — javascript: in an <a href> on every landing view is the
// attack this check exists for — and a tab without a label names
// itself from its filename stem.
func TestParseRepoConfig(t *testing.T) {
	home, topics, docs, err := parseRepoConfig([]byte(`{
		"homepage": "https://example.org",
		"topics": [" ci ", "forge"],
		"docs": [{"path": "docs/INSTALL.md"}, {"path": "README.md", "label": "Read Me"}]
	}`))
	if err != nil {
		t.Fatal(err)
	}
	if home != "https://example.org" || topics[0] != "ci" {
		t.Fatalf("home=%q topics=%v", home, topics)
	}
	if docs[0].Label != "INSTALL" || docs[1].Label != "Read Me" {
		t.Fatalf("labels = %+v", docs)
	}
	for name, bad := range map[string]string{
		"javascript homepage": `{"homepage": "javascript:alert(1)"}`,
		"not an object":       `[1]`,
		"pathless tab":        `{"docs": [{"label": "x"}]}`,
	} {
		if _, _, _, err := parseRepoConfig([]byte(bad)); err == nil {
			t.Errorf("%s: accepted", name)
		}
	}
}

// The convention discovers marker documents at the ROOT only, dedupes
// LICENSE/COPYING into one tab, and README always leads: it is the
// default tab, and a panel that opens on SECURITY.md would be a strange
// front door.
func TestMarkerDocs(t *testing.T) {
	docs := markerDocs([]zitcli.LsEntry{
		{Name: "SECURITY.md"}, {Name: "docs/README.md"}, {Name: "readme.txt"},
		{Name: "COPYING"}, {Name: "LICENSE.txt"}, {Name: "main.go"},
	})
	var got []string
	for _, d := range docs {
		got = append(got, d.Label+":"+d.Path)
	}
	want := []string{"README:readme.txt", "License:LICENSE.txt", "Security:SECURITY.md"}
	if !slices.Equal(got, want) {
		t.Fatalf("docs = %v, want %v", got, want)
	}
}

// stubAccounts is the minimum AccountStore a dashboard test needs.
type stubAccounts struct{ grants map[string]map[string]string }

func (s stubAccounts) Accounts() ([]Account, error)          { return nil, nil }
func (s stubAccounts) AddAccount(string, string, bool) error { return nil }
func (s stubAccounts) SetPassword(string, string) error      { return nil }
func (s stubAccounts) DeleteAccount(string) error            { return nil }
func (s stubAccounts) Grant(string, string, string) error    { return nil }
func (s stubAccounts) Revoke(string, string) error           { return nil }
func (s stubAccounts) GrantsFor(a string) (map[string]string, error) {
	return s.grants[a], nil
}

// me.feed is the signed-in dashboard: grant-scoped, so a reader sees
// THEIR repos and their merged activity — and only theirs. The caller
// comes from the request context, never from an input field, so nobody
// asks for someone else's grants by name.
func TestMeFeed(t *testing.T) {
	mk := func(t *testing.T, seedUser, date, comment string) *index.Index {
		ix := wikiIndex(t)
		if err := ix.FoldNow([]index.Parsed{{Hash: strings.Repeat(comment[:1], 32),
			Art: &artifact.Artifact{Type: artifact.Manifest, Date: date,
				User: seedUser, Comment: comment}}}); err != nil {
			t.Fatal(err)
		}
		return ix
	}
	repos := NewRepos(map[string]*Repo{
		"alpha": {Index: mk(t, "dan", "2026-08-19T10:00:00", "a newest")},
		"beta":  {Index: mk(t, "eve", "2026-08-18T10:00:00", "b older")},
		"gamma": {Index: mk(t, "dan", "2026-08-20T10:00:00", "c ungrannted")},
	})
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: repos,
		Accounts: stubAccounts{grants: map[string]map[string]string{
			"dan": {"alpha": "write", "beta": "read"},
		}}}); err != nil {
		t.Fatal(err)
	}

	got, err := r.Invoke(ops.WithUser(context.Background(), "dan"), "me.feed", meFeedIn{})
	if err != nil {
		t.Fatal(err)
	}
	out := got.(meFeedOut)
	if len(out.Repos) != 2 || out.Repos[0].Name != "alpha" {
		t.Fatalf("repos = %+v, want [alpha beta] by activity; gamma is not granted", out.Repos)
	}
	if len(out.Feed) < 2 || out.Feed[0].Comment != "a newest" || out.Feed[1].Comment != "b older" {
		t.Fatalf("feed not merged newest-first: %+v", out.Feed)
	}
	for _, e := range out.Feed {
		if e.Repo == "gamma" {
			t.Fatalf("an ungranted repo's event leaked into the feed: %+v", e)
		}
	}
	// Mine: string-equality on the author name; dan's newest leads and
	// nothing of eve's appears.
	if len(out.Mine) == 0 || out.Mine[0].Comment != "a newest" {
		t.Fatalf("mine = %+v, want dan's check-in first", out.Mine)
	}
	for _, e := range out.Mine {
		if e.User != "dan" {
			t.Fatalf("someone else's work in mine: %+v", e)
		}
	}
	// No identity, no feed.
	if _, err := r.Invoke(context.Background(), "me.feed", meFeedIn{}); !errors.Is(err, ops.ErrNotFound) {
		t.Fatalf("anonymous me.feed: %v, want ErrNotFound", err)
	}
}

// A repository's own documentation lives in its TREE — fossil keeps
// every page of www/ there, not as wiki artifacts — so which extensions
// are documents is a contract, not a detail. file.get answers the same
// question from the same table, or the source view offers a rendered
// form that does not exist.
func TestDocMimetypeMatchesFossilsOwnDispatch(t *testing.T) {
	for _, tc := range []struct {
		path string
		want string
		ok   bool
	}{
		{"www/index.wiki", "", true}, // absent mimetype IS fossil wiki
		{"README.md", "text/x-markdown", true},
		{"doc/guide.markdown", "text/x-markdown", true},
		{"www/permutedindex.html", "text/html", true},
		{"www/page.HTM", "text/html", true}, // extensions are case-folded
		{"notes.txt", "text/plain", true},
		{"diagram.pikchr", "text/x-pikchr", true},
		{"src/main.c", "", false}, // source stays source
		{"Makefile", "", false},   // no extension at all
		{"logo.png", "", false},
	} {
		got, ok := docMimetype(tc.path)
		if ok != tc.ok || got != tc.want {
			t.Errorf("docMimetype(%q) = %q,%v; want %q,%v", tc.path, got, ok, tc.want, tc.ok)
		}
	}
}

// Repository-authored HTML renders, but through the sanitizer every
// other rendered path uses. Fossil serves it closer to as-is and warns
// that a script inside it trips the browser's CSP; quarry serves other
// people's repositories, so the gate stays shut.
func TestEmbeddedHTMLIsRenderedButSanitized(t *testing.T) {
	body := []byte(`<h2>Title</h2><p>ok</p><script>alert(1)</script>` +
		`<a href="javascript:alert(2)">bad</a>`)
	got, err := renderBody(nil, "repo", "text/html", body)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(got, "<h2>Title</h2>") || !strings.Contains(got, "<p>ok</p>") {
		t.Errorf("the document did not render:\n%s", got)
	}
	if strings.Contains(got, "<script") || strings.Contains(got, "alert(1)") {
		t.Errorf("a script survived the sanitizer:\n%s", got)
	}
	if strings.Contains(got, "javascript:") {
		t.Errorf("a javascript: href survived the sanitizer:\n%s", got)
	}
}

// ticket.across is the tracker page asked of every repository at once.
// Its whole reason to exist is that a person's work does not live in one
// repository, so the two things it must get right are the merge (rows
// carry their tracker, counts and facets sum across them) and the scope
// (a tracker you were not granted contributes nothing, and naming it
// explicitly does not change that).
func TestTicketAcross(t *testing.T) {
	mk := func(t *testing.T, sub, author, date, title string) *index.Index {
		ix := wikiIndex(t)
		id := strings.Repeat(title[:1], 40)
		if err := ix.FoldNow([]index.Parsed{{Hash: title[:4], Art: &artifact.Artifact{
			Type: artifact.Ticket, TicketID: id, Date: date, User: author,
			Fields: []artifact.Field{{Name: "status", Value: "Open"},
				{Name: "title", Value: title}, {Name: "subsystem", Value: sub}}}}}); err != nil {
			t.Fatal(err)
		}
		return ix
	}
	repos := NewRepos(map[string]*Repo{
		"alpha": {Index: mk(t, "web", "dan", "2026-08-19T10:00:00", "alpha bug")},
		"beta":  {Index: mk(t, "web", "eve", "2026-08-20T10:00:00", "beta bug")},
		"gamma": {Index: mk(t, "api", "dan", "2026-08-21T10:00:00", "gamma bug")},
	})
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: repos,
		Accounts: stubAccounts{grants: map[string]map[string]string{
			"dan": {"alpha": "write", "beta": "read"},
		}}}); err != nil {
		t.Fatal(err)
	}
	ctx := ops.WithUser(context.Background(), "dan")

	got, err := r.Invoke(ctx, "ticket.across", ticketAcrossIn{})
	if err != nil {
		t.Fatal(err)
	}
	out := got.(ticketAcrossOut)
	if out.Count != 2 {
		t.Fatalf("count = %d, want 2: gamma is not granted", out.Count)
	}
	// Newest-updated first, and every row knows its tracker — without
	// that the listing cannot be clicked through to anything.
	if out.Tickets[0].Repo != "beta" || out.Tickets[1].Repo != "alpha" {
		t.Fatalf("order/repos = %+v, want beta then alpha", out.Tickets)
	}
	if out.Open != 2 {
		t.Fatalf("open = %d, want the two granted trackers summed", out.Open)
	}
	// Facets sum across trackers: one "web" with the work of both.
	if len(out.Subsystems) != 1 || out.Subsystems[0].Value != "web" || out.Subsystems[0].Open != 2 {
		t.Fatalf("subsystems = %+v, want web=2 merged", out.Subsystems)
	}
	if len(out.Authors) != 2 {
		t.Fatalf("authors = %+v, want dan and eve", out.Authors)
	}
	// Repository is the axis that only exists here.
	if len(out.Repos) != 2 {
		t.Fatalf("repo facet = %+v, want the two granted trackers", out.Repos)
	}

	// Repo is a facet, not addressing: it narrows within what you may
	// already see.
	got, err = r.Invoke(ctx, "ticket.across", ticketAcrossIn{Repo: "alpha"})
	if err != nil {
		t.Fatal(err)
	}
	if out := got.(ticketAcrossOut); out.Count != 1 || out.Tickets[0].Repo != "alpha" {
		t.Fatalf("repo facet = %+v, want alpha alone", out.Tickets)
	}
	// Naming a tracker you were not granted returns the empty listing —
	// never someone else's tickets, and never an error that confirms the
	// repository exists.
	got, err = r.Invoke(ctx, "ticket.across", ticketAcrossIn{Repo: "gamma"})
	if err != nil {
		t.Fatal(err)
	}
	if out := got.(ticketAcrossOut); out.Count != 0 {
		t.Fatalf("ungranted tracker leaked %+v", out.Tickets)
	}
	// The other report axes still work, one repository deep or three.
	got, err = r.Invoke(ctx, "ticket.across", ticketAcrossIn{Author: "eve"})
	if err != nil {
		t.Fatal(err)
	}
	if out := got.(ticketAcrossOut); out.Count != 1 || out.Tickets[0].Repo != "beta" {
		t.Fatalf("author axis = %+v", out.Tickets)
	}
	// No identity, no listing.
	if _, err := r.Invoke(context.Background(), "ticket.across", ticketAcrossIn{}); !errors.Is(err, ops.ErrNotFound) {
		t.Fatalf("anonymous ticket.across: %v, want ErrNotFound", err)
	}
}

// Awaiting is the landing page's question: of the tickets I opened,
// which has somebody else answered and left with me? A ticket nobody
// has touched since I filed it is not owed a reply, and neither is one
// whose last word was my own — those two exclusions are the whole rule,
// and getting either wrong turns the band into a second inbox of noise.
func TestTicketAcrossAwaiting(t *testing.T) {
	ix := wikiIndex(t)
	fold := func(id, author, lastUser, date, title string) {
		batch := []index.Parsed{{Hash: id[:4], Art: &artifact.Artifact{
			Type: artifact.Ticket, TicketID: id, Date: date, User: author,
			Fields: []artifact.Field{{Name: "status", Value: "Open"},
				{Name: "title", Value: title}}}}}
		if lastUser != "" {
			batch = append(batch, index.Parsed{Hash: id[:4] + "z", Art: &artifact.Artifact{
				Type: artifact.Ticket, TicketID: id, Date: date[:11] + "23:00:00", User: lastUser,
				Fields: []artifact.Field{{Name: "status", Value: "Open"}}}})
		}
		if err := ix.FoldNow(batch); err != nil {
			t.Fatal(err)
		}
	}
	fold(strings.Repeat("a", 40), "dan", "eve", "2026-08-19T09:00:00", "eve answered me")
	fold(strings.Repeat("b", 40), "dan", "", "2026-08-19T09:00:00", "nobody has answered")
	fold(strings.Repeat("c", 40), "dan", "dan", "2026-08-19T09:00:00", "I spoke last")
	fold(strings.Repeat("d", 40), "eve", "dan", "2026-08-19T09:00:00", "not my ticket")

	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix),
		Accounts: stubAccounts{grants: map[string]map[string]string{
			"dan": {"test": "write"},
		}}}); err != nil {
		t.Fatal(err)
	}
	got, err := r.Invoke(ops.WithUser(context.Background(), "dan"), "ticket.across",
		ticketAcrossIn{Awaiting: true})
	if err != nil {
		t.Fatal(err)
	}
	out := got.(ticketAcrossOut)
	if out.Count != 1 || out.Tickets[0].Title != "eve answered me" {
		var titles []string
		for _, t := range out.Tickets {
			titles = append(titles, t.Title)
		}
		t.Fatalf("awaiting = %v, want only the one somebody else answered", titles)
	}
	// The header must describe exactly the rows beneath it: a count the
	// rule cannot produce is a count that lies.
	if out.Open != 1 || out.Closed != 0 {
		t.Fatalf("counts = %d/%d, want the listing's own size", out.Open, out.Closed)
	}
	// Awaiting owns author and state; it cannot be bent into someone
	// else's inbox.
	got, err = r.Invoke(ops.WithUser(context.Background(), "dan"), "ticket.across",
		ticketAcrossIn{Awaiting: true, Author: "eve", State: "all"})
	if err != nil {
		t.Fatal(err)
	}
	if out := got.(ticketAcrossOut); out.Count != 1 || out.Tickets[0].Author != "dan" {
		t.Fatalf("awaiting answered for someone else: %+v", out.Tickets)
	}
}

// Fossil's own documentation links directories with a trailing slash —
// www/index.wiki carries [./server/|a variety of techniques] — so a
// directory has to answer with its index the way every web server has.
// Sending those to the file view produced a 404 with a real page
// sitting behind it.
func TestDocIndexNamesPreferRenderedFormats(t *testing.T) {
	// Preference order matters: a directory holding both index.md and
	// index.html should serve the one that renders into the page.
	md, html := -1, -1
	for i, n := range docIndexNames {
		switch n {
		case "index.md":
			md = i
		case "index.html":
			html = i
		}
	}
	if md < 0 || html < 0 {
		t.Fatalf("docIndexNames lost a candidate: %v", docIndexNames)
	}
	if md > html {
		t.Errorf("index.html is preferred over index.md: %v", docIndexNames)
	}
	// Every candidate must actually be renderable, or a directory would
	// resolve to a document doc.get then refuses.
	for _, n := range docIndexNames {
		if _, ok := docMimetype(n); !ok {
			t.Errorf("index candidate %q is not a renderable document", n)
		}
	}
}

// Fossil content links its own documentation absolutely —
// [/doc/trunk/www/inout.wiki|importing from Git] appears in fossil's
// Cookbook — and those must land on the RENDERED document. The mapping
// pointed at the file route while that was the only route there was;
// once doc.get existed, it was quietly sending readers to source
// listings of pages quarry can render.
func TestFossilDocLinksResolveToTheRenderedDocument(t *testing.T) {
	res := resolver{repo: "fossil"}
	for _, tc := range []struct{ in, want string }{
		{"/doc/trunk/www/inout.wiki", "/r/fossil/doc/trunk/www/inout.wiki"},
		{"/doc/trunk/www/server/any/cgi.md", "/r/fossil/doc/trunk/www/server/any/cgi.md"},
		{"/doc/abc123/README.md", "/r/fossil/doc/abc123/README.md"},
	} {
		if got := res.ResolvePath(tc.in); got != tc.want {
			t.Errorf("ResolvePath(%q) = %q, want %q", tc.in, got, tc.want)
		}
	}
	// Unversioned content is a fossil feature quarry does not have, and
	// a dropped link beats one that 404s — this is why "Download" in
	// fossil's own index renders as plain text.
	if got := res.ResolvePath("/uv/download.html"); got != "" {
		t.Errorf("ResolvePath(/uv/...) = %q, want a dropped link", got)
	}
}

// Two channels interleaved on the one stream: every per-repository
// number the api hands out — history, unread, bounds-derived read marks
// — must come from that repository's own messages. Stream-wide sequence
// arithmetic (last - since) counts the neighbour's traffic as this
// reader's unread mail.
func TestChatChannelsDoNotCountEachOther(t *testing.T) {
	fc := &fakeChat{}
	ix := wikiIndex(t)
	repos := NewRepos(map[string]*Repo{"alpha": {Index: ix, Code: "codeA"}, "beta": {Index: ix, Code: "codeB"}})
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: repos, Chat: fc}); err != nil {
		t.Fatal(err)
	}
	ctx := ops.WithUser(context.Background(), "dan")

	var alpha []uint64
	for i := 0; i < 4; i++ {
		out, err := r.Invoke(ctx, "chat.post", chatPostIn{Repo: "alpha", Text: "a"})
		if err != nil {
			t.Fatal(err)
		}
		alpha = append(alpha, out.(chatPostOut).Seq)
		if _, err := r.Invoke(ctx, "chat.post", chatPostIn{Repo: "beta", Text: "b"}); err != nil {
			t.Fatal(err)
		}
	}

	h, err := r.Invoke(ctx, "chat.history", chatHistoryIn{Repo: "alpha"})
	if err != nil {
		t.Fatal(err)
	}
	hist := h.(chatHistoryOut)
	if hist.Count != 4 {
		t.Fatalf("alpha history count = %d, want 4 (beta's messages leaked in)", hist.Count)
	}
	for _, m := range hist.Messages {
		if m.Text != "a" {
			t.Fatalf("alpha history carries a beta message: %+v", m)
		}
	}

	// Unread with no read mark is the whole channel, not the whole stream.
	u, err := r.Invoke(ctx, "chat.unread", chatUnreadIn{})
	if err != nil {
		t.Fatal(err)
	}
	un := u.(chatUnreadOut)
	if got := un.Repos["alpha"].Unread; got != 4 {
		t.Fatalf("alpha unread = %d, want 4 (stream-wide arithmetic would say 8)", got)
	}
	if got := un.Repos["beta"].Unread; got != 4 {
		t.Fatalf("beta unread = %d, want 4", got)
	}

	// chat.read with no seq marks the CHANNEL's tip, not the stream's.
	rd, err := r.Invoke(ctx, "chat.read", chatReadIn{Repo: "alpha"})
	if err != nil {
		t.Fatal(err)
	}
	if got := rd.(chatReadOut).Seq; got != alpha[len(alpha)-1] {
		t.Fatalf("chat.read marked seq %d, want alpha's own tip %d", got, alpha[len(alpha)-1])
	}
}

// chat.unread counts against the reader's own mark, per repository, from
// the channel's own messages.
func TestChatUnreadCountsAfterTheReadMark(t *testing.T) {
	fc := &fakeChat{}
	ix := wikiIndex(t)
	repos := NewRepos(map[string]*Repo{"alpha": {Index: ix, Code: "codeA"}, "beta": {Index: ix, Code: "codeB"}})
	fs := &chatForumStub{}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: repos, Chat: fc, Forum: fs}); err != nil {
		t.Fatal(err)
	}
	ctx := ops.WithUser(context.Background(), "dan")
	var alpha []uint64
	for i := 0; i < 4; i++ {
		out, err := r.Invoke(ctx, "chat.post", chatPostIn{Repo: "alpha", Text: "a"})
		if err != nil {
			t.Fatal(err)
		}
		alpha = append(alpha, out.(chatPostOut).Seq)
		if _, err := r.Invoke(ctx, "chat.post", chatPostIn{Repo: "beta", Text: "b"}); err != nil {
			t.Fatal(err)
		}
	}
	if _, err := r.Invoke(ctx, "chat.read", chatReadIn{Repo: "alpha", Seq: alpha[1]}); err != nil {
		t.Fatal(err)
	}
	u, err := r.Invoke(ctx, "chat.unread", chatUnreadIn{})
	if err != nil {
		t.Fatal(err)
	}
	un := u.(chatUnreadOut)
	if got := un.Repos["alpha"].Unread; got != 2 {
		t.Fatalf("alpha unread after reading 2 of 4 = %d, want 2", got)
	}
	if un.Repos["alpha"].Since != alpha[1] {
		t.Fatalf("alpha since = %d, want %d", un.Repos["alpha"].Since, alpha[1])
	}

	// A fully-read channel drops out of the listing entirely, which is
	// why the chat PAGE cannot source its divider from here.
	if _, err := r.Invoke(ctx, "chat.read", chatReadIn{Repo: "alpha"}); err != nil {
		t.Fatal(err)
	}
	u, err = r.Invoke(ctx, "chat.unread", chatUnreadIn{})
	if err != nil {
		t.Fatal(err)
	}
	if _, ok := u.(chatUnreadOut).Repos["alpha"]; ok {
		t.Fatal("a fully-read repo is still listed in chat.unread")
	}
	// chat.history carries the mark instead, so a fully-read channel
	// renders with no unread divider.
	h, err := r.Invoke(ctx, "chat.history", chatHistoryIn{Repo: "alpha"})
	if err != nil {
		t.Fatal(err)
	}
	if got := h.(chatHistoryOut).Read; got != alpha[len(alpha)-1] {
		t.Fatalf("chat.history read mark = %d, want the channel tip %d", got, alpha[len(alpha)-1])
	}
}

// A delete is terminal. chat.edit refuses a message that already carries
// a del tombstone, and Fold ignores an edit that reaches a deleted
// message whichever order the two were published in.
func TestChatEditRefusesADeletedMessage(t *testing.T) {
	fc := &fakeChat{}
	ix := wikiIndex(t)
	repos := NewRepos(map[string]*Repo{"alpha": {Index: ix, Code: "codeA"}})
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: repos, Chat: fc}); err != nil {
		t.Fatal(err)
	}
	ctx := ops.WithUser(context.Background(), "dan")
	out, err := r.Invoke(ctx, "chat.post", chatPostIn{Repo: "alpha", Text: "hello"})
	if err != nil {
		t.Fatal(err)
	}
	seq := out.(chatPostOut).Seq
	if _, err := r.Invoke(ctx, "chat.delete", chatDeleteIn{Repo: "alpha", Seq: seq}); err != nil {
		t.Fatal(err)
	}
	_, err = r.Invoke(ctx, "chat.edit", chatEditIn{Repo: "alpha", Seq: seq, Text: "resurrected"})
	if !errors.Is(err, ops.ErrInvalid) {
		t.Fatalf("edit of a deleted message: err = %v, want ErrInvalid", err)
	}
	// And even if a tombstone-ordering race let one through, the fold
	// keeps the message deleted.
	if _, err := fc.Post("codeA", chat.Message{Repo: "alpha", User: "dan",
		Kind: chat.KindEdit, Ref: seq, Text: "resurrected"}); err != nil {
		t.Fatal(err)
	}
	h, err := r.Invoke(ctx, "chat.history", chatHistoryIn{Repo: "alpha"})
	if err != nil {
		t.Fatal(err)
	}
	msgs := h.(chatHistoryOut).Messages
	if len(msgs) != 1 || !msgs[0].Deleted || msgs[0].Text != "" {
		t.Fatalf("deleted message resurrected by a later edit: %+v", msgs)
	}
}

// chatForumStub is the ForumStore surface the chat read-state tests
// need: one mark per (repo, account), nothing else.
type chatForumStub struct {
	ForumStore
	mu    sync.Mutex
	marks map[string]uint64
}

func (s *chatForumStub) key(repo, account string) string { return repo + "\x00" + account }

func (s *chatForumStub) MarkChatRead(repo, account string, seq uint64) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.marks == nil {
		s.marks = map[string]uint64{}
	}
	if seq > s.marks[s.key(repo, account)] {
		s.marks[s.key(repo, account)] = seq
	}
	return nil
}

func (s *chatForumStub) ChatReadSeq(repo, account string) uint64 {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.marks[s.key(repo, account)]
}

func (s *chatForumStub) ChatReadAll(account string) (map[string]uint64, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := map[string]uint64{}
	suffix := "\x00" + account
	for k, v := range s.marks {
		if strings.HasSuffix(k, suffix) {
			out[strings.TrimSuffix(k, suffix)] = v
		}
	}
	return out, nil
}

// chat.stream says so when a resume point is older than the window:
// an `event: gap` frame naming what was asked for and what is actually
// available, ahead of the replay, so a renderer re-renders rather than
// appending onto a history it no longer shares.
func TestChatStreamEmitsAGapFrame(t *testing.T) {
	fc := &fakeChat{tail: []chat.Message{
		{Kind: chat.KindGap, Ref: 3, Seq: 10},
		{Seq: 10, Kind: chat.KindMsg, User: "dan", Text: "after the gap"},
	}}
	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)
	}
	h, err := Router(r, "v")
	if err != nil {
		t.Fatal(err)
	}
	req := httptest.NewRequest("GET", "/api/v1/r/test/chat/stream", nil)
	req.Header.Set("Last-Event-ID", "3")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	if ct := rec.Header().Get("Content-Type"); ct != "text/event-stream" {
		t.Fatalf("Content-Type = %q, want text/event-stream", ct)
	}
	body := rec.Body.String()
	gapAt := strings.Index(body, "event: gap")
	if gapAt < 0 {
		t.Fatalf("no gap frame:\n%s", body)
	}
	if !strings.Contains(body, `"first":10`) || !strings.Contains(body, `"from":3`) {
		t.Fatalf("gap frame does not name from and first:\n%s", body)
	}
	msgAt := strings.Index(body, "id: 10")
	if msgAt < 0 || msgAt < gapAt {
		t.Fatalf("gap frame must precede the replay:\n%s", body)
	}
}

// An explicit ?from= is the caller stating a cursor for THIS request;
// Last-Event-ID is one the browser remembered from a connection that
// dropped. The header only fills in when the query says nothing.
func TestLastEventIDDoesNotOverrideAnExplicitFrom(t *testing.T) {
	newStream := func(t *testing.T) (*fakeChat, http.Handler) {
		t.Helper()
		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)
		}
		h, err := Router(r, "v")
		if err != nil {
			t.Fatal(err)
		}
		return fc, h
	}

	fc, h := newStream(t)
	req := httptest.NewRequest("GET", "/api/v1/r/test/chat/stream?from=7", nil)
	req.Header.Set("Last-Event-ID", "3")
	h.ServeHTTP(httptest.NewRecorder(), req)
	if fc.tailFrom != 7 {
		t.Fatalf("Tail(from) = %d, want 7 — the header overwrote an explicit cursor", fc.tailFrom)
	}

	fc, h = newStream(t)
	req = httptest.NewRequest("GET", "/api/v1/r/test/chat/stream", nil)
	req.Header.Set("Last-Event-ID", "3")
	h.ServeHTTP(httptest.NewRecorder(), req)
	if fc.tailFrom != 3 {
		t.Fatalf("Tail(from) = %d, want 3 — the header must fill in when the query says nothing", fc.tailFrom)
	}
}

// `before` is exclusive, so Next is the oldest returned message's own
// seq — not that seq plus one, which would hand the next page a cursor
// one message too high and repeat a row at every page boundary.
func TestChatHistoryPagesWithoutDuplicates(t *testing.T) {
	fc := &fakeChat{}
	ix := wikiIndex(t)
	repos := NewRepos(map[string]*Repo{"alpha": {Index: ix, Code: "codeA"}})
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: repos, Chat: fc}); err != nil {
		t.Fatal(err)
	}
	ctx := ops.WithUser(context.Background(), "dan")
	var posted []uint64
	for i := 0; i < 5; i++ {
		out, err := r.Invoke(ctx, "chat.post", chatPostIn{Repo: "alpha", Text: fmt.Sprintf("m%d", i)})
		if err != nil {
			t.Fatal(err)
		}
		posted = append(posted, out.(chatPostOut).Seq)
	}

	seen := map[uint64]int{}
	var walked []uint64
	before := uint64(0)
	for i := 0; i < 10; i++ {
		got, err := r.Invoke(ctx, "chat.history", chatHistoryIn{Repo: "alpha", Before: before, Limit: 2})
		if err != nil {
			t.Fatal(err)
		}
		page := got.(chatHistoryOut)
		if len(page.Messages) == 0 {
			break
		}
		seqs := make([]uint64, len(page.Messages))
		for j, m := range page.Messages {
			seen[m.Seq]++
			if seen[m.Seq] > 1 {
				t.Fatalf("seq %d returned on two pages (Next was one too high)", m.Seq)
			}
			seqs[j] = m.Seq
		}
		walked = append(seqs, walked...)
		if page.Next == 0 {
			break
		}
		before = page.Next
	}
	if len(walked) != len(posted) {
		t.Fatalf("walked %v, want all of %v", walked, posted)
	}
	for i := range posted {
		if walked[i] != posted[i] {
			t.Fatalf("walked[%d] = %d, want %d (full walk %v)", i, walked[i], posted[i], walked)
		}
	}
}

// Purge is the SECOND step of removal. It refuses a message that is
// still standing: the tombstone is what told the room something went,
// and a one-click erase would let a message vanish with no mark at all.
// It is also the only irreversible operation in the chat plane, so its
// permission check is pinned here rather than left to the UI.
func TestChatPurgeIsTheSecondStepAndIsGuarded(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)
	}

	ctx := ops.WithUser(context.Background(), "dan")
	posted, err := r.Invoke(ctx, "chat.post", chatPostIn{Repo: "test", Text: "mine"})
	if err != nil {
		t.Fatal(err)
	}
	seq := posted.(chatPostOut).Seq

	// Still standing: refused.
	if _, err := r.Invoke(ctx, "chat.purge", chatPurgeIn{Repo: "test", Seq: seq}); !errors.Is(err, ops.ErrInvalid) {
		t.Fatalf("purging a live message returned %v, want ErrInvalid", err)
	}

	if _, err := r.Invoke(ctx, "chat.delete", chatDeleteIn{Repo: "test", Seq: seq}); err != nil {
		t.Fatal(err)
	}

	// Not yours, and not an admin: refused.
	eve := ops.WithUser(context.Background(), "eve")
	if _, err := r.Invoke(eve, "chat.purge", chatPurgeIn{Repo: "test", Seq: seq}); !errors.Is(err, ops.ErrForbidden) {
		t.Fatalf("purging another account's message returned %v, want ErrForbidden", err)
	}

	// Yours, and deleted: gone, tombstone and all.
	if _, err := r.Invoke(ctx, "chat.purge", chatPurgeIn{Repo: "test", Seq: seq}); err != nil {
		t.Fatal(err)
	}
	left, err := fc.Range("codeXYZ", 0, 0)
	if err != nil {
		t.Fatal(err)
	}
	if len(left) != 0 {
		t.Fatalf("the channel still holds %d rows after a purge: %+v", len(left), left)
	}
}

// A reference in a ticket outlives the message it names. There are two
// ways for that message to be gone — swept by the seven-day window, or
// removed from the timeline — and from the outside they are the same
// fact: it is not here any more. A bare 404 would read as a typo.
func TestPurgedPermalinkSaysGoneNotNotFound(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)
	}
	ctx := ops.WithUser(context.Background(), "dan")
	first, err := r.Invoke(ctx, "chat.post", chatPostIn{Repo: "test", Text: "first"})
	if err != nil {
		t.Fatal(err)
	}
	gone := first.(chatPostOut).Seq
	if _, err := r.Invoke(ctx, "chat.post", chatPostIn{Repo: "test", Text: "second"}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(ctx, "chat.delete", chatDeleteIn{Repo: "test", Seq: gone}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(ctx, "chat.purge", chatPurgeIn{Repo: "test", Seq: gone}); err != nil {
		t.Fatal(err)
	}

	got, err := r.Invoke(context.Background(), "chat.get", chatGetIn{Repo: "test", Seq: gone})
	if err != nil {
		t.Fatalf("a purged permalink errored instead of answering: %v", err)
	}
	if g := got.(chatGetOut); !g.Expired {
		t.Errorf("a purged permalink does not report the message as gone: %+v", g)
	}

	// A sequence the channel never held is still Not Found: "gone" is
	// a statement about a message that existed.
	if _, err := r.Invoke(context.Background(), "chat.get",
		chatGetIn{Repo: "test", Seq: 999999}); !errors.Is(err, ops.ErrNotFound) {
		t.Errorf("a sequence that never existed returned %v, want ErrNotFound", err)
	}
}

// chat.history pages forward with `after` — the other half of walking
// history — and every page says whether a newer one exists, so a
// "later" control is never drawn pointing at nothing.
func TestChatHistoryPagesForward(t *testing.T) {
	fc := &fakeChat{}
	r := ops.NewRegistry()
	repos := testRepos(wikiIndex(t))
	mustRepo(t, repos, "test").Code = "codeF"
	if err := RegisterAll(r, Deps{Version: "v", Repos: repos, Chat: fc}); err != nil {
		t.Fatal(err)
	}
	var seqs []uint64
	for i := 0; i < 6; i++ {
		out, err := r.Invoke(context.Background(), "chat.post",
			chatPostIn{Repo: "test", Text: fmt.Sprintf("m%d", i)})
		if err != nil {
			t.Fatal(err)
		}
		seqs = append(seqs, out.(chatPostOut).Seq)
	}

	got, err := r.Invoke(context.Background(), "chat.history",
		chatHistoryIn{Repo: "test", After: seqs[1], Limit: 2})
	if err != nil {
		t.Fatal(err)
	}
	h := got.(chatHistoryOut)
	if len(h.Messages) != 2 || h.Messages[0].Seq != seqs[2] || h.Messages[1].Seq != seqs[3] {
		t.Fatalf("forward page = %+v, want seqs %v", h.Messages, seqs[2:4])
	}
	if h.Later != seqs[3] {
		t.Errorf("Later = %d, want %d (this page's newest)", h.Later, seqs[3])
	}
	if h.Next != seqs[2] {
		t.Errorf("Next (older cursor) = %d, want %d", h.Next, seqs[2])
	}

	// The newest page reports no later: the control would lead nowhere.
	got, err = r.Invoke(context.Background(), "chat.history", chatHistoryIn{Repo: "test"})
	if err != nil {
		t.Fatal(err)
	}
	if h := got.(chatHistoryOut); h.Later != 0 {
		t.Errorf("the newest page claims a later page exists: %d", h.Later)
	}
}

// Headings in a chat message parse as CommonMark says: "# " at a line
// start is a heading (demoted one level — the page owns its h1), and
// "#4" is a chat reference, never a heading, because the space is what
// separates them. Pinned because a draft that previews one way and
// posts another would be worse than either.
func TestChatHeadingsParse(t *testing.T) {
	fc := &fakeChat{}
	r := ops.NewRegistry()
	repos := testRepos(wikiIndex(t))
	mustRepo(t, repos, "test").Code = "codeH2"
	if err := RegisterAll(r, Deps{Version: "v", Repos: repos, Chat: fc}); err != nil {
		t.Fatal(err)
	}
	render := func(txt string) string {
		got, err := r.Invoke(context.Background(), "chat.preview",
			chatPreviewIn{Repo: "test", Text: txt})
		if err != nil {
			t.Fatalf("%q: %v", txt, err)
		}
		return got.(chatPreviewOut).HTML
	}
	if h := render("# Hi All\n\nbody"); !strings.Contains(h, "<h2") || !strings.Contains(h, ">Hi All<") {
		t.Errorf("a heading did not parse: %s", h)
	}
	if h := render("#4 thanks"); !strings.Contains(h, `data-ref="#4"`) || strings.Contains(h, "<h2") {
		t.Errorf("#4 should be a reference, got: %s", h)
	}
	if h := render("#quarry"); strings.Contains(h, "<h2") {
		t.Errorf("#word without a space is not a heading: %s", h)
	}
}

// The newest page never offers "later messages". The channel's tip is
// frequently a tombstone or an edit — which fold away — so comparing
// the newest MESSAGE against the raw tip made every reader at the end
// see a control leading to nothing.
func TestNewestPageHasNoLaterAfterADelete(t *testing.T) {
	fc := &fakeChat{}
	r := ops.NewRegistry()
	repos := testRepos(wikiIndex(t))
	mustRepo(t, repos, "test").Code = "codeL"
	if err := RegisterAll(r, Deps{Version: "v", Repos: repos, Chat: fc}); err != nil {
		t.Fatal(err)
	}
	ctx := ops.WithUser(context.Background(), "dan")
	first, err := r.Invoke(ctx, "chat.post", chatPostIn{Repo: "test", Text: "keep"})
	if err != nil {
		t.Fatal(err)
	}
	gone, err := r.Invoke(ctx, "chat.post", chatPostIn{Repo: "test", Text: "delete me"})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(ctx, "chat.delete", chatDeleteIn{Repo: "test", Seq: gone.(chatPostOut).Seq}); err != nil {
		t.Fatal(err)
	}
	got, err := r.Invoke(ctx, "chat.history", chatHistoryIn{Repo: "test"})
	if err != nil {
		t.Fatal(err)
	}
	if h := got.(chatHistoryOut); h.Later != 0 {
		t.Errorf("the newest page claims later messages exist (Later=%d) when the tip is a tombstone", h.Later)
	}
	// And a genuinely older page still does.
	got, err = r.Invoke(ctx, "chat.history", chatHistoryIn{Repo: "test", Before: first.(chatPostOut).Seq + 1})
	if err != nil {
		t.Fatal(err)
	}
	// The deleted message still renders as a "message deleted" row, so
	// a page ending at "keep" genuinely has something later.
	if h := got.(chatHistoryOut); h.Later != first.(chatPostOut).Seq {
		t.Errorf("an older page lost its later cursor: Later=%d", h.Later)
	}
}

// Landings are a chip on the feed like every other kind, counting the
// record and the replies on it together — the "l" filter reaches both.
func TestMeFeedOffersALandingsChip(t *testing.T) {
	ix := wikiIndex(t)
	root := strings.Repeat("a1", 20)
	if err := ix.FoldNow([]index.Parsed{
		{Hash: root, Art: &artifact.Artifact{Type: artifact.Forum, ThreadTitle: "Ship it",
			Date: "2026-08-19T10:00:00", User: "dan",
			Body: []byte("zit-landing: 1\nbranch: feat/x\ntarget: trunk\n\nready\n")}},
		{Hash: strings.Repeat("b2", 20), Art: &artifact.Artifact{Type: artifact.Forum, ThreadRoot: root,
			Date: "2026-08-19T11:00:00", User: "eve", Body: []byte("looks fine")}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"alpha": {Index: ix}}),
		Accounts: stubAccounts{grants: map[string]map[string]string{"dan": {"alpha": "write"}}}}); err != nil {
		t.Fatal(err)
	}
	got, err := r.Invoke(ops.WithUser(context.Background(), "dan"), "me.feed", meFeedIn{})
	if err != nil {
		t.Fatal(err)
	}
	var landings int
	for _, k := range got.(meFeedOut).Kinds {
		if k.Kind == "l" {
			landings = k.Count
		}
		if k.Kind == "lc" {
			t.Errorf("landing replies are their own chip; they belong under landings")
		}
	}
	if landings != 2 {
		t.Fatalf("landings chip counts %d, want the record and its reply", landings)
	}
}

// A relative target in a document resolves against the DOCUMENT's
// directory, not the URL of whatever page is rendering it. The README
// case that started this: /r/sqlite rendered doc/compile-for-unix.md as
// a bare relative href, the browser resolved it against /r/ and landed
// on a 404.
func TestDocRelativeResolvesAgainstTheDocumentsDirectory(t *testing.T) {
	for _, tc := range []struct{ dir, target, want string }{
		// The README at the tree root — the SQLite case.
		{"", "doc/compile-for-unix.md", "/r/sqlite/doc/trunk/doc/compile-for-unix.md"},
		{".", "./doc/lemon.html", "/r/sqlite/doc/trunk/doc/lemon.html"},
		// A document that is itself in a subdirectory.
		{"doc", "lemon.html", "/r/sqlite/doc/trunk/doc/lemon.html"},
		{"doc", "../README.md", "/r/sqlite/doc/trunk/README.md"},
		{"doc/sub", "../lemon.html", "/r/sqlite/doc/trunk/doc/lemon.html"},
		// Query and fragment ride along.
		{"", "doc/lemon.html#syntax", "/r/sqlite/doc/trunk/doc/lemon.html#syntax"},
		{"doc", "./x.md?raw=1", "/r/sqlite/doc/trunk/doc/x.md?raw=1"},
		// Climbing out of the tree names nothing this repository holds.
		{"", "../../etc/passwd", ""},
		{"doc", "../../../etc/passwd", ""},
		{"", "", ""},
	} {
		d := docResolver{resolver{repo: "sqlite"}, "trunk", tc.dir}
		if got := d.DocRelative(tc.target); got != tc.want {
			t.Errorf("FAIL DocRelative(dir=%q, %q) = %q, want %q", tc.dir, tc.target, got, tc.want)
		} else {
			t.Logf("ok dir=%q %q -> %q", tc.dir, tc.target, got)
		}
	}
}

// A caller that cannot say which check-in the document was read at gets
// no rewrite at all — better a literal target than a URL aimed at a
// version that does not exist.
func TestDocRelativeWithoutAVersionRewritesNothing(t *testing.T) {
	d := docResolver{resolver{repo: "sqlite"}, "", ""}
	if got := d.DocRelative("doc/lemon.html"); got != "" {
		t.Errorf("FAIL DocRelative with no version = %q, want empty", got)
	} else {
		t.Log("ok no version, no rewrite")
	}
}

// Fossil verbs quarry has pages for but was dropping. Each target here
// is a route quarry actually serves — the links were being unwrapped to
// plain text while the page sat there the whole time.
func TestFossilVerbsQuarryServesAreNoLongerDropped(t *testing.T) {
	res := resolver{repo: "sqlite"}
	for _, tc := range []struct{ in, want string }{
		{"/dir?ci=trunk&name=doc", "/r/sqlite/tree/trunk/doc"},
		{"/dir?name=src", "/r/sqlite/tree/trunk/src"},
		{"/dir?ci=abc123", "/r/sqlite/tree/abc123"},
		{"/brlist", "/r/sqlite/branch"},
		// finfo: the commonest fossil verb in imported content, gated
		// until zit could answer a PATH walk in under a second.
		{"/finfo?name=src/btree.c", "/r/sqlite/history/trunk/src/btree.c"},
		{"/finfo?name=README.md&ci=abc123", "/r/sqlite/history/abc123/README.md"},
		{"/finfo?name=/doc/lemon.html", "/r/sqlite/history/trunk/doc/lemon.html"},
		{"/tarball/trunk/sqlite.tar.gz", "/r/sqlite/archive/trunk"},
		{"/zip/abc123/sqlite.zip", "/r/sqlite/archive/abc123"},
		{"/tarball?uuid=deadbeef", "/r/sqlite/archive/deadbeef"},
		// tktview was in FossilRoute's table but never delegated to
		// from here, so imported content dropped it while a check-in
		// comment resolved it.
		{"/tktview/1a2b3c4d", "/r/sqlite/ticket/1a2b3c4d"},
		{"/tktview?name=1a2b3c4d", "/r/sqlite/ticket/1a2b3c4d"},
	} {
		if got := res.ResolvePath(tc.in); got != tc.want {
			t.Errorf("FAIL ResolvePath(%q) = %q, want %q", tc.in, got, tc.want)
		} else {
			t.Logf("ok %s -> %s", tc.in, got)
		}
	}
}

// Verbs quarry genuinely has no page for stay dropped. finfo used to be
// here — quarry had the page but it took two minutes on a repository the
// size of SQLite's — and moved to the served table once zit#188 and
// 0.3.15 made the walk fast and its index durable.
func TestVerbsWithoutAQuarryPageStayDropped(t *testing.T) {
	res := resolver{repo: "sqlite"}
	for _, in := range []string{
		"/uv/download.html", "/leaves", "/vdiff?from=a&to=b",
		"/help", "/stat", "/md_rules", "/timewarps",
	} {
		if got := res.ResolvePath(in); got != "" {
			t.Errorf("FAIL ResolvePath(%q) = %q, want a dropped link", in, got)
		} else {
			t.Logf("ok %s dropped", in)
		}
	}
}

// A tarball link that names no version maps to nothing: quarry's
// archive route has no "whatever the tip is" spelling, and guessing
// trunk would hand a reader a different tree than the link meant.
func TestArchiveLinkWithoutAVersionIsDropped(t *testing.T) {
	res := resolver{repo: "sqlite"}
	if got := res.ResolvePath("/tarball/sqlite.tar.gz"); got != "" {
		t.Errorf("FAIL versionless tarball = %q, want dropped", got)
	} else {
		t.Log("ok versionless tarball dropped")
	}
}

// A finfo link with no file named resolves to nothing: /history needs a
// path, and sending a reader to the history of nothing is worse than
// showing them the label.
func TestFinfoWithoutANameIsDropped(t *testing.T) {
	res := resolver{repo: "sqlite"}
	for _, in := range []string{"/finfo", "/finfo?ci=trunk", "/finfo?name=", "/finfo?name=/"} {
		if got := res.ResolvePath(in); got != "" {
			t.Errorf("FAIL ResolvePath(%q) = %q, want a dropped link", in, got)
		} else {
			t.Logf("ok %s dropped", in)
		}
	}
}