package web
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/chat"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ops"
)
// Principle 3: the plain link is the whole feature. The Datastar hooks
// ride ON the same anchor, so a reader without script still navigates.
func TestReferenceLinkKeepsItsHrefAndGainsHooks(t *testing.T) {
in := `<p>see <a class="ref" data-ref="#412" data-ref-kind="chat" href="/r/demo/chat/412">#412</a></p>`
got := string(enhanceRefs(in, "demo"))
for _, want := range []string{
`href="/r/demo/chat/412"`,
`data-ref="#412"`,
`data-on:mouseenter__debounce.300ms=`,
`data-on:focus=`,
`data-on:click=`,
`evt.preventDefault()`,
`/r/demo/ref.frag?ref=`,
// Hover and focus previews are for hover-capable pointers; the
// tap path is for everything else. One expression, gated.
`matchMedia('(hover: hover)')`,
} {
if !strings.Contains(got, want) {
t.Fatalf("enhanced reference missing %q:\n%s", want, got)
}
}
// The anchor is not rewritten into something else.
if !strings.HasPrefix(got, "<p>see <a ") {
t.Fatalf("the reference stopped being an anchor:\n%s", got)
}
}
// A link that is not a reference is left alone: enhancement is opt-in
// by the resolver's own marker, never by shape.
func TestEnhanceRefsLeavesOrdinaryLinksAlone(t *testing.T) {
in := `<a href="/r/demo/ticket/x">a ticket</a>`
if got := string(enhanceRefs(in, "demo")); got != in {
t.Fatalf("an ordinary link was enhanced:\n%s", got)
}
}
// One popover element for the whole page: a per-link popover is a
// per-link element, and the page grows one for every reference in it.
func TestBaseLayoutHasOneSharedPopover(t *testing.T) {
if n := strings.Count(baseHTML, `id="refpop"`); n != 1 {
t.Fatalf("base layout declares %d #refpop elements, want 1", n)
}
if !strings.Contains(baseHTML, `<div id="refpop" popover`) {
t.Fatalf("the shared preview is not a native popover:\n%s", baseHTML)
}
}
// Each card says what it is, structurally, and offers the page it
// previews. The copy is not asserted; the hooks are.
func TestRefCardCarriesKindAndOpenLink(t *testing.T) {
for _, tt := range []struct{ kind, href string }{
{"chat", "/r/demo/chat/412"},
{"checkin", "/r/demo/checkin/abc"},
{"ticket", "/r/demo/ticket/xy9"},
{"landing", "/r/demo/landing/ab"},
{"forum", "/r/demo/forum/ab"},
{"wiki", "/r/demo/wiki/Home"},
{"person", "/people/alice"},
} {
got := string(refCardHTML(refCard{Kind: tt.kind, Href: tt.href, Title: "t"}))
if !strings.Contains(got, `data-kind="`+tt.kind+`"`) {
t.Fatalf("%s card lost its data-kind:\n%s", tt.kind, got)
}
if !strings.Contains(got, `href="`+tt.href+`"`) {
t.Fatalf("%s card lost its open link:\n%s", tt.kind, got)
}
}
// An aged-out chat reference says so as a state, not as prose the
// caller has to parse.
got := string(refCardHTML(refCard{Kind: "chat", Href: "/r/demo/chat/1", Expired: true}))
if !strings.Contains(got, `data-expired`) {
t.Fatalf("expired card carries no state hook:\n%s", got)
}
// A person card never carries an address (RFC 0004 keeps those
// behind `e`), so there is nothing shaped like one to leak.
person := string(refCardHTML(refCard{Kind: "person", Href: "/people/alice", Title: "alice"}))
if strings.Contains(person, "mailto:") || strings.Contains(person, "@") {
t.Fatalf("person card leaked an address:\n%s", person)
}
}
// The reply-quote: a message posted with Ref shows who it answers and
// the first line of what they said, linked to the permalink.
func TestReplyRendersItsQuote(t *testing.T) {
got := string(chatMsgHTML(ChatMessage{
Seq: 9, Kind: "msg", User: "bob", Text: "agreed", Ref: 4,
RefUser: "alice", RefLine: "the first line",
}, "demo", "", false))
for _, want := range []string{
`class="chatquote"`,
`data-quote-seq="4"`,
`href="/r/demo/chat/4"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("reply quote missing %q:\n%s", want, got)
}
}
}
// The original says how many answered it, and the count links to the
// permalink where they are.
func TestOriginalShowsItsReplyCount(t *testing.T) {
got := string(chatMsgHTML(ChatMessage{
Seq: 4, Kind: "msg", User: "alice", Text: "hi", Replies: 3,
}, "demo", "", false))
if !strings.Contains(got, `data-replies="3"`) {
t.Fatalf("reply count hook missing:\n%s", got)
}
if !strings.Contains(got, `href="/r/demo/chat/4"`) {
t.Fatalf("reply count does not link to the permalink:\n%s", got)
}
}
// A message with no replies grows no count element at all: "0 replies"
// is noise on every line of a busy channel.
func TestNoRepliesRendersNoCount(t *testing.T) {
got := string(chatMsgHTML(ChatMessage{Seq: 4, Kind: "msg", User: "alice", Text: "hi"}, "demo", "", false))
if strings.Contains(got, "data-replies") {
t.Fatalf("a message with no replies grew a count:\n%s", got)
}
}
// The fragment route: one card, wrapped so the shared popover comes
// back still being a popover.
func TestRefFragServesTheCard(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()
fc := &webChatStub{msgs: []chat.Message{
{Seq: 1, Kind: chat.KindMsg, User: "dan", Text: "hello", At: "2026-08-16T12:00:00Z"},
}}
if err := api.RegisterAll(r, api.Deps{Version: "t",
Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix, Code: "c1"}}),
Chat: fc,
Forum: &webForumReadStub{},
CanRead: func(context.Context, string) bool { return true }}); err != nil {
t.Fatal(err)
}
h := HandlerWith(r, nil, newFakeChatLive(), nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/ref.frag?ref=%231", nil))
body := rec.Body.String()
for _, want := range []string{
"event: datastar-patch-elements",
`<div id="refpop" popover>`,
`data-kind="chat"`,
`href="/r/test/chat/1"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("ref.frag missing %q:\n%s", want, body)
}
}
if rec.Header().Get("ETag") == "" {
t.Fatalf("ref.frag served no ETag:\n%v", rec.Header())
}
// A repeat hover on an unchanged reference costs a 304, not a redraw.
rec2 := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/r/test/ref.frag?ref=%231", nil)
req.Header.Set("If-None-Match", rec.Header().Get("ETag"))
h.ServeHTTP(rec2, req)
if rec2.Code != http.StatusNotModified {
t.Fatalf("unchanged reference re-rendered: %d", rec2.Code)
}
}
// Principle 3, asserted on a real page: every reference on the chat
// page is an anchor with a real href, so navigation never depends on
// the Datastar attributes sitting beside it.
func TestChatPageReferencesNavigateWithoutScript(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()
fc := &webChatStub{msgs: []chat.Message{
{Seq: 1, Kind: chat.KindMsg, User: "dan", Text: "start", At: "2026-08-16T12:00:00Z"},
{Seq: 2, Kind: chat.KindMsg, User: "ann", Text: "see #1", Ref: 1, At: "2026-08-16T12:00:01Z"},
}}
if err := api.RegisterAll(r, api.Deps{Version: "t",
Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix, Code: "c1"}}),
Chat: fc,
Forum: &webForumReadStub{},
CanRead: func(context.Context, string) bool { return true }}); err != nil {
t.Fatal(err)
}
h := HandlerWith(r, nil, newFakeChatLive(), nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/chat", nil))
body := rec.Body.String()
// The reference minted by the resolver, and the quote block.
if !strings.Contains(body, `data-ref="#1"`) {
t.Fatalf("chat page did not link a #seq reference:\n%s", body)
}
if !strings.Contains(body, `data-quote-seq="1"`) {
t.Fatalf("reply did not render its quote:\n%s", body)
}
if !strings.Contains(body, `data-replies="1"`) {
t.Fatalf("original did not show its reply count:\n%s", body)
}
// Every marked reference carries a real destination.
for _, m := range refAnchor.FindAllStringSubmatch(body, -1) {
if !strings.Contains(m[1], `href="/`) {
t.Fatalf("a reference has no navigable href: %s", m[0])
}
}
}