package api
import (
"context"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/chat"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/markup"
"craftdesign.group/quarry/internal/ops"
)
// fakeCaps answers a fixed letter set.
type fakeCaps string
func (c fakeCaps) HasAny(letters string) bool {
for _, l := range letters {
for _, have := range string(c) {
if l == have {
return true
}
}
}
return false
}
// The resolver table of RFC 0012 Phase 1: each token shape reaches the
// right page, and a token naming a repository the reader cannot see
// reaches nothing at all.
func TestResolveTokenTable(t *testing.T) {
// A scope whose CanRead admits "open" and refuses "secret", and
// whose caps grant everything on what it admits.
d := Deps{
Repos: &Repos{},
CanRead: func(_ context.Context, repo string) bool { return repo == "open" },
CapsFor: func(_, _ string) CapSet { return fakeCaps(anyCapLetters) },
}
ctx := ops.WithUser(context.Background(), "dan")
r := resolver{repo: "demo", scope: &refScope{ctx: ctx, d: d}}
for _, tt := range []struct {
token string
href string // "" means: must not resolve
}{
{token: "#412", href: "/r/demo/chat/412"},
{token: "@alice", href: "/people/alice"},
// An artifact nobody has ever indexed is not a reference. With
// no index at all NOTHING is known, so both of these stay text.
{token: "/r/demo/ticket/xy9"},
{token: "https://q.example/r/demo/landing/ab12"},
// Cross-repo: "open" is readable, "secret" is not. Neither is
// registered as a repository here, so only the refusal is
// asserted — the admitted one is exercised in the harness.
{token: "secret:#9"},
{token: "secret:[a91f3c2e91]"},
{token: "notatoken"},
} {
tok, ok := markup.ParseRef(tt.token)
if !ok {
if tt.href != "" {
t.Fatalf("%q did not parse", tt.token)
}
continue
}
href, ok := r.ResolveToken(tok)
if tt.href == "" {
if ok {
t.Fatalf("%q resolved to %q; it must stay plain text", tt.token, href)
}
continue
}
if !ok || href != tt.href {
t.Fatalf("%q resolved to %q (ok=%v), want %q", tt.token, href, ok, tt.href)
}
}
}
// A resolver with no scope is the render path that has no request in
// hand: it must not reach across repositories at all.
func TestResolveTokenWithoutScopeStaysHome(t *testing.T) {
r := resolver{repo: "demo"}
tok, _ := markup.ParseRef("other:#7")
if href, ok := r.ResolveToken(tok); ok {
t.Fatalf("scopeless resolver crossed repositories: %q", href)
}
tok, _ = markup.ParseRef("#7")
if href, ok := r.ResolveToken(tok); !ok || href != "/r/demo/chat/7" {
t.Fatalf("same-repo token = %q (ok=%v)", href, ok)
}
}
// refCap reaches for the letter the TARGET's own op is registered with,
// so a preview can never say more than the page it links to would.
func TestRefCapMatchesTheTargetOp(t *testing.T) {
for kind, want := range map[string]string{
markup.RefChat: "C", markup.RefCheckin: "o", markup.RefHash: "o",
markup.RefTicket: "r", markup.RefLanding: "2", markup.RefForum: "2",
markup.RefWiki: "j", markup.RefPerson: "",
} {
if got := refCap(kind); got != want {
t.Fatalf("refCap(%q) = %q, want %q", kind, got, want)
}
}
}
// The quote and the reply count come from one pass over one slice.
func TestChatQuoteFillsBothSides(t *testing.T) {
msgs := []chatMsgOut{
{Seq: 4, Kind: "msg", User: "alice", Text: "first line\nsecond"},
{Seq: 5, Kind: "msg", User: "bob", Ref: 4, Text: "agreed"},
{Seq: 6, Kind: "msg", User: "carol", Ref: 4, Text: "same"},
{Seq: 7, Kind: "msg", User: "dan", Ref: 99, Text: "orphan"},
}
chatQuote(msgs)
if msgs[0].Replies != 2 {
t.Fatalf("replies = %d, want 2", msgs[0].Replies)
}
if msgs[1].RefUser != "alice" || msgs[1].RefLine != "first line" {
t.Fatalf("quote = %q/%q", msgs[1].RefUser, msgs[1].RefLine)
}
if msgs[3].RefUser != "" {
t.Fatalf("a quote was invented for an unsourceable reply")
}
}
// refIndex is a repository with one of each artifact a reference can
// name, so existence is a fact this test can check rather than assume.
func refIndex(t *testing.T) (*index.Index, string, string) {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "refs.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
tid := strings.Repeat("ab", 20)
root := strings.Repeat("f0", 20)
if err := ix.FoldNow([]index.Parsed{
{Hash: "r1", 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: "a real ticket"}}}},
{Hash: root, Art: &artifact.Artifact{Type: artifact.Forum, ThreadTitle: "Design question",
Date: "2026-08-16T09:00:00", User: "dan", Body: []byte("body\n")}},
{Hash: "r2", Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "Guide",
Date: "2026-08-15T10:00:00", User: "dan", Mimetype: "text/x-markdown",
Body: []byte("# Guide\n\ntext\n")}},
}); err != nil {
t.Fatal(err)
}
return ix, tid, root
}
func refResolverFor(t *testing.T, ix *index.Index, caps, user string) resolver {
t.Helper()
d := Deps{
Repos: &Repos{},
CanRead: func(_ context.Context, _ string) bool { return true },
CapsFor: func(_, _ string) CapSet { return fakeCaps(caps) },
}
return resolver{ix: ix, repo: "demo", scope: &refScope{ctx: ops.WithUser(context.Background(), user), d: d}}
}
// The cap is checked on EVERY branch, the addressed repository
// included. `demo:ticket/x` and `ticket/x` are the same reference; a
// reader without `r` must be refused both spellings.
func TestSameRepoTokenIsNotACapBypass(t *testing.T) {
ix, tid, _ := refIndex(t)
for _, token := range []string{"ticket/" + tid, "demo:ticket/" + tid} {
tok, ok := markup.ParseRef(token)
if !ok {
t.Fatalf("%q did not parse", token)
}
// C but no r: chat resolves, the ticket does not.
if href, ok := refResolverFor(t, ix, "C", "dan").ResolveToken(tok); ok {
t.Fatalf("%q resolved to %q without r", token, href)
}
if href, ok := refResolverFor(t, ix, "Cr", "dan").ResolveToken(tok); !ok ||
href != "/r/demo/ticket/"+tid {
t.Fatalf("%q with r resolved to %q (ok=%v)", token, href, ok)
}
}
}
// An artifact the index has never heard of is not a reference: no
// link, and nothing for a backlink row to point at.
func TestUnknownArtifactsStayPlainText(t *testing.T) {
ix, tid, root := refIndex(t)
r := refResolverFor(t, ix, anyCapLetters, "dan")
unknown := strings.Repeat("9d", 20)
for _, token := range []string{
"ticket/" + unknown, "checkin/" + unknown, "landing/" + unknown,
"forum/" + unknown, "wiki/NoSuchPage",
} {
tok, ok := markup.ParseRef(token)
if !ok {
t.Fatalf("%q did not parse", token)
}
if href, ok := r.ResolveToken(tok); ok {
t.Fatalf("unknown %q resolved to %q", token, href)
}
}
// The ones that DO exist still resolve, or the check is just a wall.
for token, want := range map[string]string{
"ticket/" + tid: "/r/demo/ticket/" + tid,
"forum/" + root: "/r/demo/forum/" + root,
"wiki/Guide": "/r/demo/wiki/Guide",
} {
tok, _ := markup.ParseRef(token)
if href, ok := r.ResolveToken(tok); !ok || href != want {
t.Fatalf("%q resolved to %q (ok=%v), want %q", token, href, ok, want)
}
}
}
// A bare hash is whatever the INDEX says it is, and the letter checked
// must be that kind's. `[<ticket>]` behind `o` alone was the bypass:
// refCap said "hash means check-in", the resolver admitted, and the
// answer came back a ticket — with its title and status.
func TestBracketedHashIsCappedByWhatItNames(t *testing.T) {
ix, tid, root := refIndex(t)
cid := strings.Repeat("c3", 20)
if err := ix.FoldNow([]index.Parsed{{Hash: cid, Art: &artifact.Artifact{
Type: artifact.Manifest, Date: "2026-08-17T09:00:00", User: "dan",
Comment: "a real check-in"}}}); err != nil {
t.Fatal(err)
}
for _, tt := range []struct {
name string
id string
caps string
href string // "" means: must not resolve
kind string
}{
{"ticket behind o alone", tid, "Co", "", ""},
{"forum post behind o alone", root, "Co", "", ""},
{"check-in behind o", cid, "Co", "/r/demo/checkin/" + cid, markup.RefCheckin},
{"ticket behind r", tid, "Cor", "/r/demo/ticket/" + tid, markup.RefTicket},
{"forum post behind 2", root, "Co2", "/r/demo/forum/" + root[:16] + "#p-" + root[:12], markup.RefForum},
} {
tok, ok := markup.ParseRef("[" + tt.id + "]")
if !ok || tok.Kind != markup.RefHash {
t.Fatalf("%s: [%s] parsed as %+v (ok=%v)", tt.name, tt.id, tok, ok)
}
r := refResolverFor(t, ix, tt.caps, "dan")
href, ok := r.ResolveToken(tok)
if tt.href == "" {
if ok {
t.Fatalf("%s: resolved to %q with caps %q", tt.name, href, tt.caps)
}
continue
}
if !ok || href != tt.href {
t.Fatalf("%s: resolved to %q (ok=%v), want %q", tt.name, href, ok, tt.href)
}
// The card is described against the kind the cap ran against.
_, ixt, kind, _ := r.target(tok)
if kind != tt.kind {
t.Fatalf("%s: target kind = %q, want %q", tt.name, kind, tt.kind)
}
var out refResolveOut
Deps{}.describeRef(context.Background(), &out, ixt, "demo", tok, kind)
if out.Kind != tt.kind {
t.Fatalf("%s: card kind = %q, want %q", tt.name, out.Kind, tt.kind)
}
}
}
// A person card names capabilities. That is `h`, and `h` is never
// anonymous: a signed-out reader learns nothing, not even whether the
// handle exists.
func TestPersonReferenceNeedsH(t *testing.T) {
ix, _, _ := refIndex(t)
tok, _ := markup.ParseRef("@alice")
if href, ok := refResolverFor(t, ix, "hmnc", "").ResolveToken(tok); ok {
t.Fatalf("anonymous resolved a person to %q", href)
}
if href, ok := refResolverFor(t, ix, "mnc", "dan").ResolveToken(tok); ok {
t.Fatalf("a reader without h resolved a person to %q", href)
}
if href, ok := refResolverFor(t, ix, "hmnc", "dan").ResolveToken(tok); !ok || href != "/people/alice" {
t.Fatalf("h did not resolve a person: %q (ok=%v)", href, ok)
}
}
// recordingChat is a ChatPlane that remembers what was asked of it.
type recordingChat struct {
msgs []chat.Message
ranges [][2]uint64
}
func (c *recordingChat) Post(string, chat.Message) (uint64, error) { return 0, nil }
func (c *recordingChat) History(string, uint64, int) ([]chat.Message, uint64, error) {
return nil, 0, nil
}
func (c *recordingChat) Tail(string, uint64) (<-chan chat.Message, func()) { return nil, func() {} }
func (c *recordingChat) Bounds(string) (uint64, uint64, error) { return 1, 9000, nil }
func (c *recordingChat) Unread(string, uint64) (int, error) { return 0, nil }
func (c *recordingChat) Range(_ string, from, to uint64) ([]chat.Message, error) {
c.ranges = append(c.ranges, [2]uint64{from, to})
var out []chat.Message
for _, m := range c.msgs {
if m.Seq >= from && (to == 0 || m.Seq <= to) {
out = append(out, m)
}
}
return out, nil
}
// countingReplies is the cheap reply source: rows, not a channel scan.
type countingReplies struct{ n int }
func (countingReplies) RecordChatRef(string, string, uint64, string) error { return nil }
func (countingReplies) PruneChatRefs(string, uint64) error { return nil }
func (countingReplies) ChatRefs(string, string) (int, uint64, string, error) {
return 0, 0, "", nil
}
func (countingReplies) RecordChatReply(string, uint64, uint64) error { return nil }
func (countingReplies) DropChatReply(string, uint64) error { return nil }
func (countingReplies) PruneChatReplies(string, uint64) error { return nil }
func (c countingReplies) ChatReplyCount(string, uint64) (int, error) { return c.n, nil }
// Hovering a chat reference is anonymous, constant, and must never
// read the channel to its tip. RFC 0011 §14's card is three facts; a
// full-channel scan per hover is a drain wearing a card's clothes.
func TestChatCardDoesNotDrainTheChannel(t *testing.T) {
room := &recordingChat{msgs: []chat.Message{
{Seq: 5, Kind: chat.KindMsg, User: "dan", Text: "the line\nmore", At: "2026-08-29T10:00:00Z"},
{Seq: 6, Kind: chat.KindMsg, User: "eve", Ref: 5, Text: "agreed", At: "2026-08-29T10:01:00Z"},
}}
repos := NewRepos(nil)
repos.Put("demo", &Repo{Code: "demo"})
d := Deps{Repos: repos, Chat: room, ChatRefs: countingReplies{n: 3},
CanRead: func(_ context.Context, _ string) bool { return true }}
var out refResolveOut
d.describeChatRef(context.Background(), &out, "demo", markup.RefToken{Kind: markup.RefChat, ID: "5"})
if out.Author != "dan" || out.Title != "the line" {
t.Fatalf("card = %+v", out)
}
// The count comes from the rows, not from the two messages above.
if out.Summary != "3 replies" {
t.Fatalf("summary = %q, want the row count", out.Summary)
}
// Not "bounded somehow": the ONE window the card is allowed —
// [seq, seq+chatFoldSpan]. A tip read is Range(seq, 0), and a
// wider span is the same drain with a bigger number in it.
if len(room.ranges) == 0 {
t.Fatalf("the card fetched nothing at all")
}
for _, r := range room.ranges {
if r[0] != 5 || r[1] != 5+chatFoldSpan {
t.Fatalf("card read Range(%d, %d), want Range(5, %d)", r[0], r[1], 5+chatFoldSpan)
}
}
}
// replyRows is a chat_reply table that actually holds what it is told,
// idempotently on (ref_seq, seq) the way the real one does.
type replyRows struct{ rows map[[2]uint64]bool }
func (replyRows) RecordChatRef(string, string, uint64, string) error { return nil }
func (replyRows) PruneChatRefs(string, uint64) error { return nil }
func (replyRows) ChatRefs(string, string) (int, uint64, string, error) {
return 0, 0, "", nil
}
func (r *replyRows) RecordChatReply(_ string, refSeq, seq uint64) error {
if r.rows == nil {
r.rows = map[[2]uint64]bool{}
}
r.rows[[2]uint64{refSeq, seq}] = true
return nil
}
func (replyRows) DropChatReply(string, uint64) error { return nil }
func (replyRows) PruneChatReplies(string, uint64) error { return nil }
func (r *replyRows) ChatReplyCount(_ string, refSeq uint64) (int, error) {
n := 0
for k := range r.rows {
if k[0] == refSeq {
n++
}
}
return n, nil
}
// A reply the window still holds but no row ever recorded — everything
// posted before chat_reply shipped — must count the same on the log,
// the permalink and the card. One idempotent pass over the window at
// startup is what makes the three agree.
func TestChatReplyBackfillCountsARowlessReply(t *testing.T) {
room := &recordingChat{msgs: []chat.Message{
{Seq: 5, Kind: chat.KindMsg, User: "dan", Text: "the line", At: "2026-08-29T10:00:00Z"},
{Seq: 6, Kind: chat.KindMsg, User: "eve", Ref: 5, Text: "agreed", At: "2026-08-29T10:01:00Z"},
{Seq: 7, Kind: chat.KindMsg, User: "mal", Ref: 5, Text: "gone", At: "2026-08-29T10:02:00Z"},
{Seq: 8, Kind: chat.KindDel, Ref: 7, User: "mal", At: "2026-08-29T10:03:00Z"},
}}
store := &replyRows{}
repos := NewRepos(nil)
repos.Put("demo", &Repo{Code: "demo"})
d := Deps{Repos: repos, Chat: room, ChatRefs: store,
CanRead: func(_ context.Context, _ string) bool { return true }}
// The log folds and says one visible reply; the rows say none.
folded := []chatMsgOut{{Seq: 5, Kind: chat.KindMsg}, {Seq: 6, Kind: chat.KindMsg, Ref: 5}}
chatQuote(folded)
if folded[0].Replies != 1 {
t.Fatalf("the log folded %d replies, want 1", folded[0].Replies)
}
if n := d.chatReplyCount("demo", 5); n != 0 {
t.Fatalf("rows started at %d, want 0", n)
}
d.ChatReplyBackfill()
if n := d.chatReplyCount("demo", 5); n != 1 {
t.Fatalf("after the backfill the rows say %d replies, want 1", n)
}
// The card reads those rows, so it says the same number.
var out refResolveOut
d.describeChatRef(context.Background(), &out, "demo",
markup.RefToken{Kind: markup.RefChat, ID: "5"})
if out.Summary != "1 replies" {
t.Fatalf("card summary = %q, want the folded count", out.Summary)
}
// A second pass is a no-op: the rows are keyed, not appended.
d.ChatReplyBackfill()
if n := d.chatReplyCount("demo", 5); n != 1 {
t.Fatalf("a second backfill doubled the count to %d", n)
}
// A forge with no store backfills nothing rather than panicking.
Deps{Repos: repos}.ChatReplyBackfill()
}
// pruningRefs records what a sweep asked it to drop.
type pruningRefs struct{ refs, replies []string }
func (p *pruningRefs) RecordChatRef(string, string, uint64, string) error { return nil }
func (p *pruningRefs) PruneChatRefs(repo string, first uint64) error {
p.refs = append(p.refs, repo+":"+strconv.FormatUint(first, 10))
return nil
}
func (p *pruningRefs) ChatRefs(string, string) (int, uint64, string, error) { return 0, 0, "", nil }
func (p *pruningRefs) RecordChatReply(string, uint64, uint64) error { return nil }
func (p *pruningRefs) DropChatReply(string, uint64) error { return nil }
func (p *pruningRefs) PruneChatReplies(repo string, first uint64) error {
p.replies = append(p.replies, repo+":"+strconv.FormatUint(first, 10))
return nil
}
func (p *pruningRefs) ChatReplyCount(string, uint64) (int, error) { return 0, nil }
// The window moves with the stream's retention, not only with a post,
// so the sweep visits every repository and prunes BOTH tables.
func TestChatWindowSweepVisitsEveryRepository(t *testing.T) {
repos := NewRepos(nil)
repos.Put("demo", &Repo{Code: "demo"})
repos.Put("other", &Repo{Code: "other"})
store := &pruningRefs{}
d := Deps{Repos: repos, Chat: &recordingChat{}, ChatRefs: store}
d.ChatWindowSweep()
for _, got := range []struct {
name string
rows []string
}{{"chat_ref", store.refs}, {"chat_reply", store.replies}} {
sort.Strings(got.rows)
if len(got.rows) != 2 || got.rows[0] != "demo:1" || got.rows[1] != "other:1" {
t.Fatalf("%s sweep = %v", got.name, got.rows)
}
}
// A forge with no store sweeps nothing rather than panicking.
Deps{Repos: repos}.ChatWindowSweep()
}