Artifact b3a165320a9c6d049cec1c5fb8edc5e9e5583e436f589d8d7d224d3a227f0275:
- File
internal/api/register_test.go
— part of check-in
[4a479e6ce2]
at
2026-08-18 01:15:43
on branch trunk
— api: make the repo set a concurrent registry; serve rejects positionals
Deps.Repos was a bare map, read by every op on the request path and written only at startup. Adding repositories at runtime — the point of the registry ticket — makes that a data race with no symptom until it corrupts one. api.Repos owns the lock; a *Repo is published complete or not at all, and Delete hands back the entry so the caller can close the index and stop the pipeline.
serve now refuses leftover arguments. Go's flag package stops at the first non-flag and returns the rest, so one unquoted shell variable silently dropped twelve --repo flags and quarry served the remaining eleven without a word. (user: dmestas, size: 28064) [annotate] [blame] [check-ins using]
package api import ( "context" "errors" "fmt" "path/filepath" "strings" "sync" "testing" "time" "craftdesign.group/quarry/internal/artifact" "craftdesign.group/quarry/internal/chat" "craftdesign.group/quarry/internal/index" "craftdesign.group/quarry/internal/ops" ) func TestRegisterAllAndVersionOp(t *testing.T) { r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "1.2.3-test"}); err != nil { t.Fatal(err) } got, err := r.Invoke(context.Background(), "meta.version", versionIn{}) if err != nil { t.Fatal(err) } if got.(versionOut).Version != "1.2.3-test" { t.Fatalf("got %+v", got) } } func TestRegisteredOpsAppearInSpec(t *testing.T) { r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v"}); err != nil { t.Fatal(err) } spec, err := ops.Spec(r, "quarry", "v") if err != nil { t.Fatal(err) } if spec.Paths["/api/v1/version"].Get == nil { t.Fatal("meta.version missing from generated spec") } } func wikiIndex(t *testing.T) *index.Index { t.Helper() ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) batch := []index.Parsed{ {Hash: "aaa1", Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "Design", Date: "2026-08-15T10:00:00", User: "dan", Mimetype: "text/x-markdown", Body: []byte("# Design\n\ndetails\n")}}, {Hash: "aaa2", Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "Home", Date: "2026-08-15T10:00:01", User: "dan", Mimetype: "text/x-markdown", Body: []byte("See [[Design]] and [[Missing]].\n")}}, {Hash: "aaa3", Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "Home", Date: "2026-08-15T11:00:00", User: "eve", Mimetype: "text/x-markdown", Body: []byte("Revised. See [[Design]].\n")}}, } if err := ix.FoldNow(batch); err != nil { t.Fatal(err) } return ix } func TestWikiOps(t *testing.T) { r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil { t.Fatal(err) } got, err := r.Invoke(context.Background(), "wiki.list", wikiListIn{Repo: "test"}) if err != nil { t.Fatal(err) } list := got.(wikiListOut) if list.Count != 2 || list.Pages[0].Title != "Design" || list.Pages[1].Title != "Home" { t.Fatalf("list = %+v", list) } if list.Pages[1].User != "eve" { t.Fatalf("latest edit not surfaced: %+v", list.Pages[1]) } got, err = r.Invoke(context.Background(), "wiki.get", wikiGetIn{Repo: "test", Name: "Home"}) if err != nil { t.Fatal(err) } page := got.(wikiGetOut) if !strings.Contains(page.HTML, `<a href="/r/test/wiki/Design">Design</a>`) { t.Fatalf("existing page not resolved:\n%s", page.HTML) } if !strings.Contains(page.Source, "Revised") { t.Fatalf("stale edit served: %+v", page) } if _, err := r.Invoke(context.Background(), "wiki.get", wikiGetIn{Repo: "test", Name: "Missing"}); err == nil { t.Fatal("want error for missing page") } } func TestWikiHistoryAndRevision(t *testing.T) { r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil { t.Fatal(err) } got, err := r.Invoke(context.Background(), "wiki.history", wikiHistoryIn{Repo: "test", Name: "Home"}) if err != nil { t.Fatal(err) } h := got.(wikiHistoryOut) if h.Count != 2 || h.Edits[0].Hash != "aaa3" || h.Edits[1].Hash != "aaa2" { t.Fatalf("history = %+v", h) } if h.Edits[0].User != "eve" || h.Edits[1].User != "dan" { t.Fatalf("edit users = %+v", h.Edits) } // An old revision serves its own content, not the fold. got, err = r.Invoke(context.Background(), "wiki.get", wikiGetIn{Repo: "test", Name: "Home", Rev: "aaa2"}) if err != nil { t.Fatal(err) } page := got.(wikiGetOut) if page.Hash != "aaa2" || !strings.Contains(page.Source, "Missing") || strings.Contains(page.Source, "Revised") { t.Fatalf("revision fetch = %+v", page) } if _, err := r.Invoke(context.Background(), "wiki.get", wikiGetIn{Repo: "test", Name: "Home", Rev: "ffff"}); err == nil { t.Fatal("want error for unknown revision") } // "aaa" prefixes both edits of Home — ambiguous resolves to nothing. if _, err := r.Invoke(context.Background(), "wiki.get", wikiGetIn{Repo: "test", Name: "Home", Rev: "aaa"}); err == nil { t.Fatal("want error for ambiguous revision prefix") } if _, err := r.Invoke(context.Background(), "wiki.history", wikiHistoryIn{Repo: "test", Name: "Missing"}); err == nil { t.Fatal("want error for missing page history") } } func TestWikiGetUnresolvedRefStaysText(t *testing.T) { r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil { t.Fatal(err) } // The older Home revision links [[Missing]]; current revision does // not — fetch Design's referrer instead via a fresh page write. got, err := r.Invoke(context.Background(), "wiki.get", wikiGetIn{Repo: "test", Name: "Design"}) if err != nil { t.Fatal(err) } if strings.Contains(got.(wikiGetOut).HTML, "/r/test/wiki/Missing") { t.Fatal("nonexistent page linked") } } func TestCheckinGetAndHashResolution(t *testing.T) { ix := wikiIndex(t) h1 := strings.Repeat("ab", 32) h2 := "abcd" + strings.Repeat("ef", 30) if err := ix.FoldNow([]index.Parsed{ {Hash: h1, Art: &artifact.Artifact{Type: artifact.Manifest, Date: "2026-08-15T09:00:00", User: "dan", Comment: "first"}}, {Hash: h2, Art: &artifact.Artifact{Type: artifact.Manifest, Date: "2026-08-15T09:30:00", User: "dan", Comment: "second", Parents: []string{h1}}}, {Hash: "bbb1", Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "Refs", Date: "2026-08-15T12:00:00", User: "dan", Mimetype: "text/x-markdown", Body: []byte("Fixed in " + h2[:12] + ". Ambiguous: ab.\n")}}, }); err != nil { t.Fatal(err) } r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil { t.Fatal(err) } // Prefix lookup: unique resolves, ambiguous does not. got, err := r.Invoke(context.Background(), "checkin.get", checkinGetIn{Repo: "test", Hash: h2[:12]}) if err != nil { t.Fatal(err) } ci := got.(checkinGetOut) if ci.Hash != h2 || len(ci.Parents) != 1 || ci.Parents[0] != h1 { t.Fatalf("checkin = %+v", ci) } if _, err := r.Invoke(context.Background(), "checkin.get", checkinGetIn{Repo: "test", Hash: "abcdzz"}); err == nil { t.Fatal("want error for unknown hash") } // Wiki body hash references resolve through the same lookup. got, err = r.Invoke(context.Background(), "wiki.get", wikiGetIn{Repo: "test", Name: "Refs"}) if err != nil { t.Fatal(err) } html := got.(wikiGetOut).HTML if !strings.Contains(html, `<a href="/r/test/checkin/`+h2+`">`+h2[:12]+`</a>`) { t.Fatalf("hash ref not resolved:\n%s", html) } } func TestTicketOps(t *testing.T) { ix := wikiIndex(t) tid := strings.Repeat("cd", 20) if err := ix.FoldNow([]index.Parsed{ {Hash: "ccc1", Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid, Date: "2026-08-15T09:00:00", User: "dan", Fields: []artifact.Field{{Name: "status", Value: "Open"}, {Name: "title", Value: "crash on empty input"}}}}, {Hash: "ccc2", Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid, Date: "2026-08-15T10:00:00", User: "eve", Fields: []artifact.Field{{Name: "resolution", Value: "Fixed"}, {Name: "status", Value: "Closed"}}}}, }); err != nil { t.Fatal(err) } r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil { t.Fatal(err) } got, err := r.Invoke(context.Background(), "ticket.list", ticketListIn{Repo: "test"}) if err != nil { t.Fatal(err) } list := got.(ticketListOut) if list.Count != 1 || list.Tickets[0].Status != "Closed" || list.Tickets[0].Title != "crash on empty input" { t.Fatalf("list = %+v", list) } got, err = r.Invoke(context.Background(), "ticket.get", ticketGetIn{Repo: "test", ID: tid[:8]}) if err != nil { t.Fatal(err) } tk := got.(ticketGetOut).Ticket if tk.ID != tid || len(tk.Changes) != 2 { t.Fatalf("ticket = %+v", tk) } if fieldValue(tk.Fields, "status") != "Closed" || fieldValue(tk.Fields, "resolution") != "Fixed" { t.Fatalf("fold wrong: %+v", tk.Fields) } if tk.Created != "2026-08-15T09:00:00" || tk.Updated != "2026-08-15T10:00:00" { t.Fatalf("created/updated: %+v", tk) } } func TestSearchOp(t *testing.T) { ix := wikiIndex(t) r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil { t.Fatal(err) } got, err := r.Invoke(context.Background(), "search.query", searchIn{Repo: "test", Q: "Design", Limit: 50}) if err != nil { t.Fatal(err) } res := got.(searchOut) if res.Count == 0 { t.Fatalf("no hits: %+v", res) } } func TestEntityNotFoundWrapsSentinel(t *testing.T) { r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil { t.Fatal(err) } for name, in := range map[string]any{ "wiki.get": wikiGetIn{Repo: "test", Name: "NoSuchPage"}, "ticket.get": ticketGetIn{Repo: "test", ID: "deadbeef"}, "checkin.get": checkinGetIn{Repo: "test", Hash: "deadbeefdeadbeef"}, "checkin.diff": diffGetIn{Repo: "test", Hash: "deadbeefdeadbeef"}, } { _, err := r.Invoke(context.Background(), name, in) if !errors.Is(err, ops.ErrNotFound) { t.Errorf("%s: err %v does not wrap ErrNotFound", name, err) } } } func TestSearchTicketDocIsFoldedState(t *testing.T) { // tickets 050 + 130: search over tickets sees CURRENT state with a // human label; stale early-change values don't match at all. ix := wikiIndex(t) tid := strings.Repeat("ef", 20) if err := ix.FoldNow([]index.Parsed{ {Hash: "ddd1", Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid, Date: "2026-08-15T09:00:00", User: "dan", Fields: []artifact.Field{{Name: "status", Value: "Openish"}, {Name: "title", Value: "typo in docs"}}}}, {Hash: "ddd2", Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid, Date: "2026-08-15T10:00:00", User: "eve", Fields: []artifact.Field{{Name: "status", Value: "Closedish"}}}}, }); err != nil { t.Fatal(err) } hits, err := ix.Search("typo", 10) if err != nil { t.Fatal(err) } if len(hits) != 1 || hits[0].Label != "typo in docs" { t.Fatalf("hits = %+v", hits) } if !strings.Contains(hits[0].Snippet, "⟦typo⟧") { t.Fatalf("marker missing: %q", hits[0].Snippet) } // The superseded status value is gone from the corpus entirely. if stale, _ := ix.Search("Openish", 10); len(stale) != 0 { t.Fatalf("stale field value still searchable: %+v", stale) } if cur, _ := ix.Search("Closedish", 10); len(cur) != 1 { t.Fatalf("current field value not searchable: %+v", cur) } } func TestSearchLimitZeroIsLiteral(t *testing.T) { // ticket 080: an explicit limit=0 caps to zero results. r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil { t.Fatal(err) } got, err := r.Invoke(context.Background(), "search.query", searchIn{Repo: "test", Q: "Design", Limit: 0}) if err != nil { t.Fatal(err) } if got.(searchOut).Count != 0 { t.Fatalf("limit=0 returned results: %+v", got) } } // mustRepo fetches a registered repo in a test, failing loudly rather // than nil-panicking three lines later. func mustRepo(t *testing.T, rs *Repos, name string) *Repo { t.Helper() r, ok := rs.Get(name) if !ok { t.Fatalf("no repo %q registered", name) } return r } func testRepos(ix *index.Index) *Repos { return NewRepos(map[string]*Repo{"test": {Index: ix}}) } func TestUnknownRepoIsNotFound(t *testing.T) { r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil { t.Fatal(err) } _, err := r.Invoke(context.Background(), "wiki.list", wikiListIn{Repo: "nope"}) if !errors.Is(err, ops.ErrNotFound) { t.Fatalf("unknown repo: err %v does not wrap ErrNotFound", err) } } func TestRepoList(t *testing.T) { ix := wikiIndex(t) r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: NewRepos(map[string]*Repo{ "beta": {Index: ix}, "alpha": {Index: ix}, })}); err != nil { t.Fatal(err) } got, err := r.Invoke(context.Background(), "repo.list", repoListIn{}) if err != nil { t.Fatal(err) } fl := got.(repoListOut) if fl.Count != 2 || fl.Repos[0].Name != "alpha" || fl.Repos[1].Name != "beta" { t.Fatalf("fleet = %+v", fl) } } func TestTimelinePagination(t *testing.T) { ix := wikiIndex(t) var hashes []string var batch []index.Parsed for i := 0; i < 5; i++ { h := strings.Repeat(fmt.Sprintf("%02d", i), 32) hashes = append(hashes, h) batch = append(batch, index.Parsed{Hash: h, Art: &artifact.Artifact{ Type: artifact.Manifest, User: "dan", Date: fmt.Sprintf("2026-08-15T0%d:00:00", i), Comment: fmt.Sprintf("commit %d", i), }}) } if err := ix.FoldNow(batch); err != nil { t.Fatal(err) } r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil { t.Fatal(err) } got, err := r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 2, Y: "ci"}) if err != nil { t.Fatal(err) } p1 := got.(timelineOut) if p1.Count != 2 || p1.Entries[0].Comment != "commit 4" || p1.Next == "" { t.Fatalf("page 1 = %+v", p1) } got, err = r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 2, Before: p1.Next, Y: "ci"}) if err != nil { t.Fatal(err) } p2 := got.(timelineOut) if p2.Count != 2 || p2.Entries[0].Comment != "commit 2" || p2.Next == "" { t.Fatalf("page 2 = %+v", p2) } got, err = r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 2, Before: p2.Next, Y: "ci"}) if err != nil { t.Fatal(err) } p3 := got.(timelineOut) if p3.Count != 1 || p3.Entries[0].Comment != "commit 0" || p3.Next != "" { t.Fatalf("page 3 = %+v", p3) } } func branchArts(h1, h2, h3 string) []index.Parsed { // root (tagged trunk) ← child (untagged) ← branch start (tagged dev) return []index.Parsed{ {Hash: h1, Art: &artifact.Artifact{Type: artifact.Manifest, Date: "2026-08-15T09:00:00", User: "dan", Comment: "root", Tags: []artifact.Tag{{Name: "*branch", Target: "*", Value: "trunk"}}}}, {Hash: h2, Art: &artifact.Artifact{Type: artifact.Manifest, Date: "2026-08-15T09:10:00", User: "dan", Comment: "on trunk", Parents: []string{h1}}}, {Hash: h3, Art: &artifact.Artifact{Type: artifact.Manifest, Date: "2026-08-15T09:20:00", User: "dan", Comment: "start dev", Parents: []string{h2}, Tags: []artifact.Tag{{Name: "*branch", Target: "*", Value: "dev"}}}}, } } func TestBranchPropagation(t *testing.T) { h1, h2, h3 := strings.Repeat("11", 32), strings.Repeat("22", 32), strings.Repeat("33", 32) arts := branchArts(h1, h2, h3) // Fold twice: forward order, and children-before-parents. The // wholesale recompute must converge identically (order independence // is what keeps the branch column disposability-safe). for name, order := range map[string][]int{"forward": {0, 1, 2}, "reversed": {2, 1, 0}} { ix, err := index.Open(filepath.Join(t.TempDir(), "i.db")) if err != nil { t.Fatal(err) } for _, i := range order { if err := ix.FoldNow(arts[i : i+1]); err != nil { t.Fatal(err) } } tl, _, err := ix.Timeline(10, "", "", "ci") if err != nil { t.Fatal(err) } got := map[string]string{} for _, e := range tl { got[e.Comment] = e.Branch } want := map[string]string{"root": "trunk", "on trunk": "trunk", "start dev": "dev"} for c, b := range want { if got[c] != b { t.Errorf("%s order: %q on branch %q, want %q", name, c, got[c], b) } } ix.Close() } } func TestBranchFilterAndControlRetag(t *testing.T) { h1, h2, h3 := strings.Repeat("44", 32), strings.Repeat("55", 32), strings.Repeat("66", 32) ix, err := index.Open(filepath.Join(t.TempDir(), "i.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) if err := ix.FoldNow(branchArts(h1, h2, h3)); err != nil { t.Fatal(err) } r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil { t.Fatal(err) } got, err := r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 10, Branch: "dev"}) if err != nil { t.Fatal(err) } tl := got.(timelineOut) if tl.Count != 1 || tl.Entries[0].Comment != "start dev" { t.Fatalf("dev filter = %+v", tl) } // A later control artifact moves h3 to another branch; the recompute // must pick it up. (Tag priority between conflicting sources is // future work; a single retag must work.) if err := ix.FoldNow([]index.Parsed{{Hash: "ctl1", Art: &artifact.Artifact{ Type: artifact.Control, Date: "2026-08-15T10:00:00", User: "dan", Tags: []artifact.Tag{{Name: "*branch", Target: h3, Value: "renamed"}}, }}}); err != nil { t.Fatal(err) } got, err = r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 10, Branch: "renamed"}) if err != nil { t.Fatal(err) } if got.(timelineOut).Count != 1 { t.Fatalf("control retag not applied: %+v", got) } } func TestUnifiedTimeline(t *testing.T) { // wikiIndex has 3 wiki edits; add a check-in and a ticket change, // then assert the kinds interleave by date and each filter isolates. ix := wikiIndex(t) ci := strings.Repeat("77", 32) tid := strings.Repeat("88", 20) if err := ix.FoldNow([]index.Parsed{ {Hash: ci, Art: &artifact.Artifact{Type: artifact.Manifest, Date: "2026-08-15T10:30:00", User: "dan", Comment: "between the wiki edits"}}, {Hash: "tk1", Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid, Date: "2026-08-15T12:00:00", User: "eve", Fields: []artifact.Field{{Name: "title", Value: "a ticket"}, {Name: "status", Value: "Open"}}}}, }); err != nil { t.Fatal(err) } r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil { t.Fatal(err) } got, err := r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 50}) if err != nil { t.Fatal(err) } all := got.(timelineOut) // 3 wiki + 1 ci + 1 ticket = 5, newest first: ticket, wiki, ci, wiki, wiki kinds := []string{} for _, e := range all.Entries { kinds = append(kinds, e.Kind) } want := []string{"t", "w", "ci", "w", "w"} if fmt.Sprint(kinds) != fmt.Sprint(want) { t.Fatalf("kinds = %v, want %v", kinds, want) } // Ticket row carries the folded title and its id as ref. if e := all.Entries[0]; e.Ref != tid || !strings.Contains(e.Comment, "a ticket") { t.Fatalf("ticket event = %+v", e) } // Wiki row refs its title. if e := all.Entries[1]; e.Ref != "Home" || !strings.Contains(e.Comment, "Home") { t.Fatalf("wiki event = %+v", e) } for y, wantN := range map[string]int{"ci": 1, "w": 3, "t": 1} { got, err := r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 50, Y: y}) if err != nil { t.Fatal(err) } if n := got.(timelineOut).Count; n != wantN { t.Fatalf("y=%s: count %d, want %d", y, n, wantN) } } } // TestTicketListPagination walks the composite updated~ticket cursor // across three pages and checks global order and completeness. func TestTicketListPagination(t *testing.T) { ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) var batch []index.Parsed for i := 0; i < 7; i++ { tid := strings.Repeat(fmt.Sprintf("%02d", i), 20) batch = append(batch, index.Parsed{ Hash: fmt.Sprintf("h%02d", i), Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid, // Two tickets share an updated stamp to exercise the id tiebreak. Date: fmt.Sprintf("2026-08-15T%02d:00:00", 10+i/2), User: "dan", Fields: []artifact.Field{{Name: "title", Value: fmt.Sprintf("t%d", i)}}}, }) } if err := ix.FoldNow(batch); err != nil { t.Fatal(err) } r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil { t.Fatal(err) } var seen []string before := "" pages := 0 for { got, err := r.Invoke(context.Background(), "ticket.list", ticketListIn{Repo: "test", Limit: 3, Before: before}) if err != nil { t.Fatal(err) } out := got.(ticketListOut) if len(out.Tickets) == 0 { t.Fatalf("empty page %d", pages) } for _, tk := range out.Tickets { seen = append(seen, tk.ID) } pages++ if out.Next == "" { break } before = out.Next } if pages != 3 || len(seen) != 7 { t.Fatalf("pages=%d seen=%d %v", pages, len(seen), seen) } uniq := map[string]bool{} for _, id := range seen { if uniq[id] { t.Fatalf("duplicate across pages: %s", id) } uniq[id] = true } } func forumTechIndex(t *testing.T) *index.Index { t.Helper() ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) root := strings.Repeat("f0", 20) reply := strings.Repeat("f1", 20) ev := strings.Repeat("e0", 20) if err := ix.FoldNow([]index.Parsed{ {Hash: root, Art: &artifact.Artifact{Type: artifact.Forum, ThreadTitle: "Design question", Date: "2026-08-16T09:00:00", User: "dan", Body: []byte("How should we shape the [[Design]] doc?\n")}}, {Hash: reply, Art: &artifact.Artifact{Type: artifact.Forum, ThreadRoot: root, InReplyTo: root, Date: "2026-08-16T10:00:00", User: "eve", Body: []byte("Start from the sync RFC.\n")}}, {Hash: strings.Repeat("e0a", 14)[:40], Art: &artifact.Artifact{Type: artifact.Technote, EventID: ev, EventTime: "2026-08-16T08:00:00", Date: "2026-08-16T08:00:00", User: "dan", Mimetype: "text/x-markdown", Comment: "Release notes", Body: []byte("# 0.2\n\nshipped the read plane\n")}}, }); err != nil { t.Fatal(err) } return ix } func TestForumOps(t *testing.T) { r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(forumTechIndex(t))}); err != nil { t.Fatal(err) } root := strings.Repeat("f0", 20) got, err := r.Invoke(context.Background(), "forum.list", forumListIn{Repo: "test"}) if err != nil { t.Fatal(err) } list := got.(forumListOut) if list.Count != 1 || list.Threads[0].Title != "Design question" || list.Threads[0].Posts != 2 { t.Fatalf("forum list = %+v", list) } got, err = r.Invoke(context.Background(), "forum.get", forumGetIn{Repo: "test", Root: root[:12]}) if err != nil { t.Fatal(err) } th := got.(forumGetOut) if len(th.Posts) != 2 || th.Title != "Design question" { t.Fatalf("thread = %+v", th) } // Oldest first, reply carries its parent, and the opener's [[Design]] does not resolve (no such page) but renders as text. if !strings.Contains(th.Posts[0].HTML, "Design") || th.Posts[1].Parent != root { t.Fatalf("thread posts = %+v", th.Posts) } if _, err := r.Invoke(context.Background(), "forum.get", forumGetIn{Repo: "test", Root: "9999"}); err == nil { t.Fatal("want error for unknown thread") } } func TestTechnoteOps(t *testing.T) { r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(forumTechIndex(t))}); err != nil { t.Fatal(err) } ev := strings.Repeat("e0", 20) got, err := r.Invoke(context.Background(), "technote.list", technoteListIn{Repo: "test"}) if err != nil { t.Fatal(err) } if l := got.(technoteListOut); l.Count != 1 || l.Notes[0].Event != ev { t.Fatalf("technote list = %+v", l) } got, err = r.Invoke(context.Background(), "technote.get", technoteGetIn{Repo: "test", Event: ev[:10]}) if err != nil { t.Fatal(err) } n := got.(technoteGetOut) if !strings.Contains(n.HTML, "shipped the read plane") || n.User != "dan" { t.Fatalf("technote = %+v", n) } if _, err := r.Invoke(context.Background(), "technote.get", technoteGetIn{Repo: "test", Event: "zzzz"}); err == nil { t.Fatal("want error for unknown technote") } } // Forum posts and technotes appear on the unified timeline and filter by kind. func TestForumTechnoteInTimeline(t *testing.T) { r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(forumTechIndex(t))}); err != nil { t.Fatal(err) } got, err := r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 50, Y: "all"}) if err != nil { t.Fatal(err) } kinds := map[string]int{} for _, e := range got.(timelineOut).Entries { kinds[e.Kind]++ } if kinds["f"] != 2 || kinds["e"] != 1 { t.Fatalf("timeline kinds = %+v", kinds) } got, _ = r.Invoke(context.Background(), "timeline.list", timelineIn{Repo: "test", Limit: 50, Y: "f"}) for _, e := range got.(timelineOut).Entries { if e.Kind != "f" { t.Fatalf("y=f leaked kind %q", e.Kind) } } } type fakeChat struct{ msgs []chat.Message } func (f *fakeChat) Post(code string, m chat.Message) error { f.msgs = append(f.msgs, m) return nil } func (f *fakeChat) History(code string, limit int) ([]chat.Message, error) { return f.msgs, nil } func TestChatOps(t *testing.T) { fc := &fakeChat{} r := ops.NewRegistry() repos := testRepos(wikiIndex(t)) mustRepo(t, repos, "test").Code = "codeXYZ" if err := RegisterAll(r, Deps{Version: "v", Repos: repos, Chat: fc}); err != nil { t.Fatal(err) } if _, err := r.Invoke(context.Background(), "chat.post", chatPostIn{Repo: "test", Text: "hello chat"}); err != nil { t.Fatal(err) } if len(fc.msgs) != 1 || fc.msgs[0].Text != "hello chat" || fc.msgs[0].User != "anonymous" { t.Fatalf("post = %+v", fc.msgs) } got, err := r.Invoke(context.Background(), "chat.history", chatHistoryIn{Repo: "test"}) if err != nil { t.Fatal(err) } if h := got.(chatHistoryOut); h.Count != 1 || h.Messages[0].Text != "hello chat" { t.Fatalf("history = %+v", h) } // Empty text is rejected by validation. if _, err := r.Invoke(context.Background(), "chat.post", chatPostIn{Repo: "test", Text: ""}); err == nil { t.Skip("validation runs in the router layer, not Invoke") } } // chat ops are absent when no chat plane is configured. func TestChatOpsAbsentWithoutPlane(t *testing.T) { r := ops.NewRegistry() if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(wikiIndex(t))}); err != nil { t.Fatal(err) } if _, err := r.Invoke(context.Background(), "chat.post", chatPostIn{Repo: "test", Text: "x"}); err == nil { t.Fatal("chat.post should be unregistered without a chat plane") } } type fakeNotify struct { mu sync.Mutex events []NotifyEvent } func (f *fakeNotify) Trigger(_ context.Context, e NotifyEvent) error { f.mu.Lock() defer f.mu.Unlock() f.events = append(f.events, e) return nil } func (f *fakeNotify) snapshot() []NotifyEvent { f.mu.Lock() defer f.mu.Unlock() return append([]NotifyEvent(nil), f.events...) } func TestChatMentionsFireNotify(t *testing.T) { fc := &fakeChat{} fn := &fakeNotify{} r := ops.NewRegistry() repos := testRepos(wikiIndex(t)) mustRepo(t, repos, "test").Code = "c1" if err := RegisterAll(r, Deps{Version: "v", Repos: repos, Chat: fc, Notify: fn}); err != nil { t.Fatal(err) } if _, err := r.Invoke(context.Background(), "chat.post", chatPostIn{Repo: "test", Text: "hey @dan and @eve, see @dan again"}); err != nil { t.Fatal(err) } // fireNotify is async; wait briefly for the goroutines. var evs []NotifyEvent for i := 0; i < 50; i++ { if evs = fn.snapshot(); len(evs) >= 2 { break } time.Sleep(20 * time.Millisecond) } got := map[string]bool{} for _, e := range evs { if e.Workflow != "quarry-mention" { t.Fatalf("unexpected workflow %q", e.Workflow) } got[e.To] = true } if !got["dan"] || !got["eve"] || len(got) != 2 { t.Fatalf("mention recipients = %v (want dan, eve, deduped)", got) } } // mentions() extracts distinct handles. func TestMentionsParse(t *testing.T) { got := mentions("ping @alice, @bob-1 and @alice again; email [email protected] is NOT a mention") want := map[string]bool{"alice": true, "bob-1": true} for _, g := range got { if !want[g] { t.Fatalf("unexpected mention %q in %v (email @ should not match)", g, got) } } if len(got) != 2 { t.Fatalf("mentions = %v (want alice, bob-1, deduped, no email)", got) } }