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) 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
// admin-gated 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 — this op wipes derived state", 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)
}
}
}