Skip to content
quarry Artifact [488dde7efc]
Login

Artifact [488dde7efc]

Artifact 488dde7efcb48f634709fd54e59e520af6186d691bc350c88a3fc1f3180f483e:


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, `\&#39;`) && !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
}