package web
import (
"bufio"
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ops"
)
// attnStub is the attention plane the ops read, driven by the test.
type attnStub struct {
mu sync.Mutex
seq uint64
live []chan api.AttentionEvent
past []api.AttentionEvent
}
func (a *attnStub) Attend(_ string, e api.AttentionEvent) (uint64, error) {
a.mu.Lock()
a.seq++
e.Seq = a.seq
a.past = append(a.past, e)
chans := append([]chan api.AttentionEvent(nil), a.live...)
a.mu.Unlock()
for _, ch := range chans {
ch <- e
}
return e.Seq, nil
}
func (a *attnStub) TailAttention(string, uint64) (<-chan api.AttentionEvent, func()) {
ch := make(chan api.AttentionEvent)
a.mu.Lock()
a.live = append(a.live, ch)
a.mu.Unlock()
return ch, func() {}
}
func (a *attnStub) AttentionPending(string, uint64) (int, error) {
a.mu.Lock()
defer a.mu.Unlock()
return int(a.seq), nil
}
func (a *attnStub) AttentionTip(string) (uint64, error) {
a.mu.Lock()
defer a.mu.Unlock()
return a.seq, nil
}
func (a *attnStub) RecentAttention(_ string, limit int) ([]api.AttentionEvent, error) {
a.mu.Lock()
defer a.mu.Unlock()
evs := append([]api.AttentionEvent(nil), a.past...)
if limit > 0 && len(evs) > limit {
evs = evs[len(evs)-limit:]
}
return evs, nil
}
type prefStub struct {
on bool // desktop notifications
toasts bool // in-page banners
}
func (p *prefStub) Pref(_, key string) string {
if key == notifyDesktopPref && p.on {
return "on"
}
if key == toastsPref && p.toasts {
return "on"
}
return ""
}
func (p *prefStub) SetPref(_, _, _ string) error { return nil }
func attentionHandler(t *testing.T) (*ops.Registry, *attnStub) {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
r := ops.NewRegistry()
attn := &attnStub{}
if err := api.RegisterAll(r, api.Deps{Version: "t",
Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix, Code: "c1"}}),
Attention: attn}); err != nil {
t.Fatal(err)
}
return r, attn
}
func attentionServer(t *testing.T, desktop bool, extra ...Option) (*httptest.Server, *attnStub) {
return attentionServerWith(t, &prefStub{on: desktop}, extra...)
}
func attentionServerWith(t *testing.T, prefs Prefs, extra ...Option) (*httptest.Server, *attnStub) {
t.Helper()
r, attn := attentionHandler(t)
opts := append([]Option{
WithSessions(fakeSessions{user: "dan"}),
WithPrefs(prefs),
}, extra...)
srv := httptest.NewServer(HandlerWith(r, nil, nil, opts))
t.Cleanup(srv.Close)
return srv, attn
}
// readFrames reads SSE text until it has seen every marker or the
// deadline passes, and returns what it read.
func readFrames(t *testing.T, rd *bufio.Reader, want []string, d time.Duration) string {
t.Helper()
var got strings.Builder
done := make(chan struct{})
go func() {
defer close(done)
for {
line, err := rd.ReadString('\n')
got.WriteString(line)
if err != nil {
return
}
all := true
for _, w := range want {
if !strings.Contains(got.String(), w) {
all = false
break
}
}
if all {
return
}
}
}()
select {
case <-done:
case <-time.After(d):
}
return got.String()
}
func TestMeSSEPatchesTheBadgeAndNoToastByDefault(t *testing.T) {
srv, attn := attentionServer(t, false)
req, err := newSSERequest(srv.URL + "/me.sse")
if err != nil {
t.Fatal(err)
}
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
rd := bufio.NewReader(resp.Body)
// The badge on connect, before anything new arrives.
first := readFrames(t, rd, []string{`id="inbox-dot"`}, 5*time.Second)
if !strings.Contains(first, `id="inbox-dot"`) {
t.Fatalf("no badge patch on connect:\n%s", first)
}
if _, err := attn.Attend("dan", api.AttentionEvent{
Kind: "mention", Repo: "test", From: "eve",
Summary: "look at this", Link: "/r/test/forum/abc",
}); err != nil {
t.Fatal(err)
}
// The badge redraw for the new event, which is the whole of what an
// account that asked for nothing else gets.
got := readFrames(t, rd, []string{"data: signals {attn: 1}"}, 5*time.Second)
for _, want := range []string{
`id="inbox-dot" class="on"`,
"event: datastar-patch-signals",
"data: signals {attn: 1}",
} {
if !strings.Contains(got, want) {
t.Errorf("stream missing %q:\n%s", want, got)
}
}
// Banners are an opt-in, gated on the server: nothing is patched
// into the region at all, so nothing has to be hidden.
for _, unwanted := range []string{"data: selector #toasts", "<aside", "new Notification("} {
if strings.Contains(got, unwanted) {
t.Errorf("the default stream emitted %q:\n%s", unwanted, got)
}
}
}
// With the banner preference on, the toast arrives — and its headline
// is the REASON the event reached this person, with the message's own
// words as the muted line under it.
func TestMeSSEToastsWithThePreference(t *testing.T) {
srv, attn := attentionServerWith(t, &prefStub{toasts: true})
req, err := newSSERequest(srv.URL + "/me.sse")
if err != nil {
t.Fatal(err)
}
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
rd := bufio.NewReader(resp.Body)
readFrames(t, rd, []string{`id="inbox-dot"`}, 5*time.Second)
if _, err := attn.Attend("dan", api.AttentionEvent{
Kind: "mention", Repo: "test", From: "eve",
Summary: "look at this", Link: "/r/test/forum/abc",
}); err != nil {
t.Fatal(err)
}
got := readFrames(t, rd, []string{"data: elements <aside"}, 5*time.Second)
for _, want := range []string{
`data: selector #toasts`,
`role="status"`,
`aria-live="polite"`,
`class="attn-reason" href="/r/test/forum/abc"`,
`class="attn-summary"`,
} {
if !strings.Contains(got, want) {
t.Errorf("toast missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "new Notification(") {
t.Errorf("a Notification call was emitted with the desktop preference off:\n%s", got)
}
}
// A summary is somebody else's prose. It reaches the toast as text and
// as a JavaScript string literal, and neither may be escapable.
func TestToastEscapesAHostileSummary(t *testing.T) {
rec := httptest.NewRecorder()
st := &attentionSSE{w: rec, fl: rec, toasts: true, desktop: true}
st.toast(attentionEvent{
Seq: 7, Kind: "mention", Repo: "te'st", From: `ev"e`,
Summary: "</aside><script>alert(1)</script>'+alert(2)+'\nsecond line",
Link: `/x"><script>alert(3)</script>`,
})
got := rec.Body.String()
for _, unwanted := range []string{"<script>", "</aside><", `alert(2)+'`} {
if strings.Contains(got, unwanted) {
t.Errorf("the toast carried %q unescaped:\n%s", unwanted, got)
}
}
// And the frame is still one SSE data line: a raw newline in the
// prose would end it early and truncate the patch.
body := got[strings.Index(got, "data: elements "):]
if strings.Count(strings.TrimSuffix(body, "\n\n"), "\n") != 0 {
t.Errorf("the elements payload spans more than one data line:\n%s", got)
}
}
func TestMeSSENotifiesOnlyWithThePreference(t *testing.T) {
srv, attn := attentionServer(t, true)
req, err := newSSERequest(srv.URL + "/me.sse")
if err != nil {
t.Fatal(err)
}
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
rd := bufio.NewReader(resp.Body)
readFrames(t, rd, []string{`id="inbox-dot"`}, 5*time.Second)
if _, err := attn.Attend("dan", api.AttentionEvent{
Kind: "mention", Repo: "test", From: "eve", Summary: "look", Link: "/x",
}); err != nil {
t.Fatal(err)
}
// Banners are off, so the expression rides on the hidden carrier,
// not on a toast.
got := readFrames(t, rd, []string{`data: elements <span class="attn-notify"`}, 5*time.Second)
if !strings.Contains(got, "new Notification(") {
t.Errorf("the preference is on but no Notification expression was emitted:\n%s", got)
}
// It is still gated on the tab being in the background and on the
// permission already having been granted — the prompt belongs to
// the inbox control alone.
for _, want := range []string{"document.visibilityState", "Notification.permission"} {
if !strings.Contains(got, want) {
t.Errorf("the Notification expression is missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "requestPermission") {
t.Errorf("the stream asked for permission; only the inbox control may:\n%s", got)
}
}
// The stream costs a connection, and plain HTTP/1.1 gives a browser
// six. It is opened on repository pages, and everywhere only when the
// deployment says it multiplexes.
func TestAttentionStreamIsGatedToRepoPages(t *testing.T) {
r, _ := attentionHandler(t)
h := HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "dan"})})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, withCookie("/r/test/timeline"))
if !strings.Contains(rec.Body.String(), `@get('/me.sse')`) {
t.Error("a repository page did not open the attention stream")
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, withCookie("/"))
if strings.Contains(rec.Body.String(), `@get('/me.sse')`) {
t.Error("a fleet page opened the attention stream without --attention-everywhere")
}
everywhere := HandlerWith(r, nil, nil, []Option{
WithSessions(fakeSessions{user: "dan"}), WithAttentionEverywhere()})
rec = httptest.NewRecorder()
everywhere.ServeHTTP(rec, withCookie("/"))
body := rec.Body.String()
if !strings.Contains(body, `@get('/me.sse')`) {
t.Error("--attention-everywhere did not open the stream on a fleet page")
}
// The title reflects the count, and the region the toasts land in
// exists whether or not anything has arrived.
if !strings.Contains(body, `id="toasts"`) {
t.Error("no toasts region on the page")
}
if !strings.Contains(body, "$attn") {
t.Error("the document title does not reflect the attention signal")
}
// With the stream open the one-shot must not also fire: two ways to
// learn one number, never both at once.
if strings.Contains(body, `@get('/inbox.unread')`) {
t.Error("the one-shot badge fetch fired alongside the stream")
}
}
// Principle 3: the inbox controls are forms that work with no script at
// all. Every one of them targets `inbox.mark`, which is the op that
// clears BOTH halves of the list.
func TestInboxControlsRenderWithoutScript(t *testing.T) {
r, _ := attentionHandler(t)
h := HandlerWith(r, nil, nil, []Option{
WithSessions(fakeSessions{user: "dan"}), WithPrefs(&prefStub{})})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, withCookie("/inbox"))
body := rec.Body.String()
for _, want := range []string{
`action="/inbox/mark"`,
`name="all" value="1"`,
`name="as" value="read"`,
`href="/me/notifications"`,
} {
if !strings.Contains(body, want) {
t.Errorf("inbox page missing %q", want)
}
}
// The switches moved to the preferences page; a second copy here
// would be a second form that can disagree with the first.
for _, gone := range []string{`action="/inbox/notify"`, `action="/inbox/toasts"`} {
if strings.Contains(body, gone) {
t.Errorf("inbox page still carries %q", gone)
}
}
}
func TestAttentionTitleAttrQuotesTheTitle(t *testing.T) {
got := string(attentionTitleAttr("dan's ticket"))
if !strings.Contains(got, `\'`) && !strings.Contains(got, `\'`) {
t.Fatalf("an apostrophe was not escaped into the JS literal: %s", got)
}
if strings.Contains(got, `+ 'dan's`) {
t.Fatalf("the literal is unterminated: %s", got)
}
}
func TestAttentionUnreadReadsTheOp(t *testing.T) {
r, attn := attentionHandler(t)
if _, err := attn.Attend("dan", api.AttentionEvent{Kind: "mention"}); err != nil {
t.Fatal(err)
}
n, err := attentionUnread(ops.WithUser(context.Background(), "dan"), r)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("inbox.count answered %d, want 1", n)
}
}
// newSSERequest is a GET carrying the session cookie the fake sessions
// plane recognises.
func newSSERequest(url string) (*http.Request, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
return req, nil
}
// RFC 0011 §5: a forge with no notify companion still tells people what
// was addressed to them, and the inbox is where they read it. The rows
// come from the page's own load-time state — no stream, no script.
func TestInboxListsAttentionWithoutNotify(t *testing.T) {
r, attn := attentionHandler(t)
if _, err := attn.Attend("dan", api.AttentionEvent{
Kind: "mention", Repo: "test", From: "eve",
Summary: "look at this", Link: "/r/test/forum/abc",
}); err != nil {
t.Fatal(err)
}
h := HandlerWith(r, nil, nil, []Option{
WithSessions(fakeSessions{user: "dan"}), WithPrefs(&prefStub{})})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, withCookie("/inbox"))
body := rec.Body.String()
for _, want := range []string{
`class="attn-rows list-card"`,
`class="attn-row unread"`,
`data-seq="1"`,
// Through the marking route, carrying its destination: opening a
// row is reading it. The live toast, above, still links straight
// out — a toast is seen, not opened.
`class="attn-reason" href="/inbox/go?id=`,
`to=%2fr%2ftest%2fforum%2fabc"`,
`class="attn-summary"`,
} {
if !strings.Contains(body, want) {
t.Errorf("inbox page missing %q", want)
}
}
// With no notify companion there is one list, so it is not labelled
// against a second one that does not exist.
if strings.Contains(body, `class="attn-head"`) {
t.Error("the single list was headed as though a second one existed")
}
}
// The stream is opened by the layout for a signed-in reader only, so an
// anonymous request is a mistake or a probe. An empty 200 tells it it
// worked.
func TestMeSSERefusesAnonymous(t *testing.T) {
r, _ := attentionHandler(t)
h := HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "dan"})})
rec := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/me.sse", nil)
if err != nil {
t.Fatal(err)
}
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous /me.sse answered %d, want 401", rec.Code)
}
}
// The reason is the one sentence the toast and the inbox row share, so
// the two can never describe the same event differently.
func TestAttentionReasonNamesTheWorkflow(t *testing.T) {
for _, tc := range []struct {
e attentionEvent
want string
}{
{attentionEvent{Kind: "mention", From: "eve", Repo: "alpha"}, "eve mentioned you in alpha"},
{attentionEvent{Kind: "reply", From: "eve"}, "eve replied to a thread you follow"},
{attentionEvent{Kind: "landing.accepted", Repo: "alpha"}, "your landing was accepted in alpha"},
{attentionEvent{Kind: "mention"}, "somebody mentioned you"},
} {
if got := attentionReason(tc.e); got != tc.want {
t.Errorf("attentionReason(%+v) = %q, want %q", tc.e, got, tc.want)
}
}
}
// inboxHTML renders the inbox page's content for one filter over one
// set of rows, the way the handler does: tabs from every row, listing
// from the ones the filter keeps.
func inboxHTML(t *testing.T, f string, rows []attnRow) string {
t.Helper()
f = inboxFilter(f)
data := pageData{Title: "inbox", HasPrefs: true, AttnUnread: 1,
AttnFilter: f, AttnTabs: attnFilters(rows, f), AttnEmpty: inboxEmptyLine(f)}
for _, row := range rows {
if attnMatches(f, row) {
data.AttnRecent = append(data.AttnRecent, row)
}
}
var b strings.Builder
if err := inboxPage.ExecuteTemplate(&b, "content", data); err != nil {
t.Fatal(err)
}
return b.String()
}
func inboxRows() []attnRow {
return []attnRow{
{ID: "local:9", Seq: 9, Reason: "alice mentioned you in sqlite", Kind: "mention",
Repo: "sqlite", At: "2026-08-29T14:05:00Z", Link: "/r/sqlite/chat/4", Unread: true},
{ID: "msg-7", Reason: "bob replied to a thread you follow", Kind: "reply", Repo: "sqlite",
Notify: true, At: "2026-08-29T13:00:00Z",
Link: "/r/sqlite/forum/abcdef123456#p-9", Root: "abcdef123456"},
}
}
// The bar is links, so a filtered inbox is a URL and the page needs no
// script to narrow itself.
func TestInboxFilterTabsAreLinks(t *testing.T) {
got := inboxHTML(t, "", inboxRows())
for _, want := range []string{
`data-filter="unread"`, `data-filter="all"`, `data-filter="mentions"`,
`data-filter="replies"`, `data-filter="landings"`,
`href="/inbox?f=mentions"`,
} {
if !strings.Contains(got, want) {
t.Errorf("the filter bar misses %q:\n%s", want, got)
}
}
}
// One row per item: what it is, which sequence it is, and — only when
// it is unread — the dot.
func TestInboxRowCarriesItsHooks(t *testing.T) {
got := inboxHTML(t, "all", inboxRows())
for _, want := range []string{
`data-seq="9"`, `data-reason-kind="mention"`, `data-reason-kind="reply"`,
`class="attn-dot"`,
// Done archives THIS row, through the same op the bar uses, and
// by the id `inbox.list` gave it — a sequence alone cannot
// address the notify half of the list.
`action="/inbox/mark"`, `name="id" value="local:9"`, `name="id" value="msg-7"`,
`data-id="msg-7"`, `data-source="notify"`, `name="as" value="done"`,
// A thread reply can be left from the inbox.
`action="/r/sqlite/forum/abcdef123456/subscribe"`,
} {
if !strings.Contains(got, want) {
t.Errorf("the row misses %q:\n%s", want, got)
}
}
if n := strings.Count(got, `class="attn-dot"`); n != 1 {
t.Errorf("%d unread dots for one unread row, want 1", n)
}
}
// A filter narrows the listing on the server: the mentions tab shows no
// forum reply, and says so in its own words when it is empty.
func TestInboxFilterNarrowsTheListing(t *testing.T) {
got := inboxHTML(t, "mentions", inboxRows())
if !strings.Contains(got, `data-reason-kind="mention"`) {
t.Errorf("the mentions filter dropped a mention:\n%s", got)
}
if strings.Contains(got, `data-reason-kind="reply"`) {
t.Errorf("the mentions filter kept a reply:\n%s", got)
}
empty := inboxHTML(t, "landings", inboxRows())
if !strings.Contains(empty, inboxEmptyLine("landings")) {
t.Errorf("an empty filter says nothing about itself:\n%s", empty)
}
}
// Settings are folded away at the bottom: a person sets them once and
// triages every day.
func TestInboxSettingsAreFoldedAway(t *testing.T) {
got := inboxHTML(t, "all", inboxRows())
i := strings.Index(got, `<details class="inbox-settings">`)
if i < 0 {
t.Fatalf("the settings are not folded away:\n%s", got)
}
for _, want := range []string{`data-pref="toasts"`, `data-pref="notify_desktop"`,
`href="/me/notifications"`} {
if j := strings.Index(got, want); j < i {
t.Errorf("%q is outside the settings fold", want)
}
}
if strings.Index(got, `class="inbox-bar"`) > i {
t.Error("the filter bar is below the settings")
}
}
// A forum link names its thread, which is what "unfollow" needs; a link
// to anything else names none.
func TestForumRootIsReadFromTheLink(t *testing.T) {
if got := attnForumRoot("/r/sqlite/forum/abc123#p-9"); got != "abc123" {
t.Errorf("forum root is %q, want abc123", got)
}
if got := attnForumRoot("/r/sqlite/chat/4"); got != "" {
t.Errorf("a chat link yielded a thread root %q", got)
}
}
// The inbox row states its read state as a hook, not as a class a
// stylesheet edit can quietly drop, and the actions are a pill and a
// text link rather than two matching outlines.
func TestInboxRowCarriesItsReadState(t *testing.T) {
got := inboxHTML(t, "", inboxRows())
if n := strings.Count(got, `data-unread="1">`); n != 1 {
t.Errorf("%d rows marked unread, want 1:\n%s", n, got)
}
if n := strings.Count(got, `data-unread="0">`); n != 1 {
t.Errorf("%d rows marked read, want 1:\n%s", n, got)
}
for _, want := range []string{`class="attn-done"`, `class="attn-unfollow"`} {
if !strings.Contains(got, want) {
t.Errorf("the inbox is missing %q", want)
}
}
// The RULES are asserted against the served sheet, not the page:
// they used to be an inline <style> here, which a strict style-src
// drops on the floor. A rule present in the HTML and absent from
// the stylesheet is a rule nobody ever sees.
for _, want := range []string{
".attn-done{", ".attn-row:hover{", "var(--sp-", "var(--r-", "var(--t-fast)",
} {
if !strings.Contains(sheet, want) {
t.Errorf("the stylesheet is missing %q", want)
}
}
}
// A row with nothing left to do is offered nothing: one of quarry's
// own events, already read, has no archive, so a control there would
// be a button that appears to do nothing. The columns are the page's
// too — the stamp and the control keep their track whatever the words
// say — so a list of rows reads as a list.
func TestInboxOffersNoControlWithNothingToDo(t *testing.T) {
read := attnRow{ID: "local:4", Seq: 4, Reason: "a landing you opened was accepted",
Kind: "landing", Repo: "quarry", At: "2026-08-29T09:00:00Z"}
unread := read
unread.ID, unread.Seq, unread.Unread = "local:5", 5, true
if got := inboxHTML(t, "", []attnRow{read}); strings.Contains(got, `class="attn-done"`) {
t.Error("FAIL: a read local row was given a control with nothing to do")
}
if got := inboxHTML(t, "", []attnRow{unread}); !strings.Contains(got, `class="attn-done"`) {
t.Error("FAIL: an unread row was not offered a way to mark it read")
}
for _, want := range []string{"grid-template-columns:var(--sp-3) minmax(0,1fr)", "-webkit-line-clamp:2"} {
if !strings.Contains(sheet, want) {
t.Errorf("FAIL: the stylesheet lets content size the row: missing %q", want)
}
}
t.Log("ok: a control appears only where it acts, and the columns are fixed")
}