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
}
func (a *attnStub) Attend(_ string, e api.AttentionEvent) (uint64, error) {
a.mu.Lock()
a.seq++
e.Seq = a.seq
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
}
type prefStub struct{ on bool }
func (p *prefStub) Pref(_, key string) string {
if key == notifyDesktopPref && p.on {
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) {
t.Helper()
r, attn := attentionHandler(t)
opts := append([]Option{
WithSessions(fakeSessions{user: "dan"}),
WithPrefs(&prefStub{on: desktop}),
}, 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 TestMeSSEPatchesTheBadgeAndAToast(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)
}
got := readFrames(t, rd, []string{"data: elements <aside", "datastar-patch-signals"}, 5*time.Second)
for _, want := range []string{
`data: selector #toasts`,
`role="status"`,
`aria-live="polite"`,
`href="/r/test/forum/abc"`,
"event: datastar-patch-signals",
"data: signals {attn:",
} {
if !strings.Contains(got, want) {
t.Errorf("stream missing %q:\n%s", want, got)
}
}
// The permission-gated call is absent: no preference, no
// Notification, ever.
if strings.Contains(got, "new Notification(") {
t.Errorf("a Notification call was emitted with the preference off:\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)
}
got := readFrames(t, rd, []string{"data: elements <aside"}, 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, and the permission prompt is raised only from the control.
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/seen"`,
`action="/inbox/notify"`,
`name="state"`,
`id="notify-desktop"`,
`data-state="off"`,
"requestPermission",
} {
if !strings.Contains(body, want) {
t.Errorf("inbox page missing %q", want)
}
}
}
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
}