package web
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"sync"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ops"
)
// memPrefs is the forge-local preference store. It answers both seams —
// the ops' AccountPrefStore and the web layer's Prefs — because in a
// running forge they are one table, and a test that splits them would
// prove the page against a store the stream cannot see.
type memPrefs struct {
mu sync.Mutex
v map[string]string
}
func newMemPrefs() *memPrefs { return &memPrefs{v: map[string]string{}} }
func (m *memPrefs) AccountPref(account, key string) string {
m.mu.Lock()
defer m.mu.Unlock()
return m.v[account+"/"+key]
}
func (m *memPrefs) SetAccountPref(account, key, value string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.v[account+"/"+key] = value
return nil
}
func (m *memPrefs) Pref(account, key string) string { return m.AccountPref(account, key) }
func (m *memPrefs) SetPref(account, key, value string) error {
return m.SetAccountPref(account, key, value)
}
// memDevices is the push-device store. It hashes the endpoint the way
// the real one does, so the page never sees an endpoint it could post
// back — which is the constraint the control is designed around.
type memDevices struct {
mu sync.Mutex
rows map[string][]api.PushDevice
pending map[string]string // hash -> endpoint
}
func newMemDevices() *memDevices {
return &memDevices{rows: map[string][]api.PushDevice{}, pending: map[string]string{}}
}
func endpointHash(endpoint string) string {
sum := sha256.Sum256([]byte(endpoint))
return hex.EncodeToString(sum[:])
}
func (m *memDevices) AddPushDevice(account, endpoint, ua string) error {
m.mu.Lock()
defer m.mu.Unlock()
h := endpointHash(endpoint)
for _, row := range m.rows[account] {
if row.EndpointHash == h {
return nil
}
}
m.rows[account] = append(m.rows[account], api.PushDevice{
EndpointHash: h, UA: ua, At: "2026-08-29T10:00:00Z"})
return nil
}
func (m *memDevices) DeletePushDevice(account, endpoint string) error {
m.mu.Lock()
defer m.mu.Unlock()
h := endpointHash(endpoint)
kept := m.rows[account][:0]
for _, row := range m.rows[account] {
if row.EndpointHash != h {
kept = append(kept, row)
}
}
m.rows[account] = kept
return nil
}
func (m *memDevices) MarkPushDevicePendingDelete(account, endpoint string) error {
m.mu.Lock()
defer m.mu.Unlock()
h := endpointHash(endpoint)
m.pending[h] = endpoint
for i, row := range m.rows[account] {
if row.EndpointHash == h {
m.rows[account][i].PendingDelete = true
}
}
return nil
}
func (m *memDevices) PendingPushDeletes(account string) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := []string{}
for _, row := range m.rows[account] {
if row.PendingDelete {
out = append(out, m.pending[row.EndpointHash])
}
}
return out, nil
}
func (m *memDevices) PushDevices(account string) ([]api.PushDevice, error) {
m.mu.Lock()
defer m.mu.Unlock()
return append([]api.PushDevice(nil), m.rows[account]...), nil
}
// fakeNotify is the notification companion as the ops need it.
type fakeNotify struct {
mu sync.Mutex
markErr error
key string
stored map[string]map[string]bool
resolved bool
msgs []api.InboxMessage
subs []string
unsubbed []string
subP256dh string
}
func newFakeNotify() *fakeNotify {
return &fakeNotify{key: "BPublicKeyBase64Url", stored: map[string]map[string]bool{}, resolved: true}
}
func (f *fakeNotify) Unread(context.Context, string) (int, error) { return 0, nil }
func (f *fakeNotify) List(_ context.Context, _, before string, _ int) ([]api.InboxMessage, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if before != "" || len(f.msgs) == 0 {
return nil, "", nil
}
return append([]api.InboxMessage(nil), f.msgs...), "", nil
}
func (f *fakeNotify) Mark(context.Context, string, []string, bool, string) error {
f.mu.Lock()
defer f.mu.Unlock()
return f.markErr
}
func (f *fakeNotify) Preferences(context.Context, string) (map[string]map[string]bool, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := map[string]map[string]bool{}
for wf, row := range f.stored {
copied := map[string]bool{}
for k, v := range row {
copied[k] = v
}
out[wf] = copied
}
return out, f.resolved, nil
}
func (f *fakeNotify) SetPreferences(_ context.Context, _ string, prefs map[string]map[string]bool) error {
f.mu.Lock()
defer f.mu.Unlock()
f.stored = prefs
return nil
}
func (f *fakeNotify) PushKey(context.Context) (string, error) { return f.key, nil }
func (f *fakeNotify) PushSubscribe(_ context.Context, _, endpoint, p256dh, _ string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.subs = append(f.subs, endpoint)
f.subP256dh = p256dh
return nil
}
func (f *fakeNotify) PushUnsubscribe(_ context.Context, _, endpoint string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.unsubbed = append(f.unsubbed, endpoint)
return nil
}
// memRead is the attention read pointer. Forward-only, like the store
// it stands in for: a stale page must not resurrect dismissed mail.
type memRead struct {
mu sync.Mutex
at map[string]uint64
}
func (m *memRead) MarkAttentionRead(account string, seq uint64) error {
m.mu.Lock()
defer m.mu.Unlock()
if seq > m.at[account] {
m.at[account] = seq
}
return nil
}
func (m *memRead) AttentionReadSeq(account string) uint64 {
m.mu.Lock()
defer m.mu.Unlock()
return m.at[account]
}
type prefsForge struct {
h http.Handler
prefs *memPrefs
devices *memDevices
notify *fakeNotify
attn *attnStub
}
// prefsForge builds the whole preferences surface: the ops behind it and
// the web handler in front. notify nil is a forge with no companion,
// which is the state the push control has to say something honest about.
func newPrefsForge(t *testing.T, notify *fakeNotify) prefsForge {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
prefs, devices, attn := newMemPrefs(), newMemDevices(), &attnStub{}
deps := api.Deps{Version: "t",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix, Code: "c1"}}),
Attention: attn,
AttnRead: &memRead{at: map[string]uint64{}},
Prefs: prefs,
PushDevices: devices,
}
if notify != nil {
deps.Inbox = notify
}
r := ops.NewRegistry()
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
h := HandlerWith(r, nil, nil, []Option{
WithSessions(fakeSessions{user: "dan"}), WithPrefs(prefs)})
return prefsForge{h: h, prefs: prefs, devices: devices, notify: notify, attn: attn}
}
func (f prefsForge) get(t *testing.T, path string) (int, string) {
t.Helper()
rec := httptest.NewRecorder()
f.h.ServeHTTP(rec, withCookie(path))
return rec.Code, rec.Body.String()
}
func (f prefsForge) post(t *testing.T, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
rec := httptest.NewRecorder()
f.h.ServeHTTP(rec, req)
return rec
}
// fullMatrix is what the page itself posts: every cell, both states.
func fullMatrix() url.Values {
form := url.Values{}
for _, family := range []string{"mention", "reply", "landing", "activity"} {
for _, ch := range []string{"inapp", "push", "email"} {
form.Add(family+"."+ch, "off")
form.Add(family+"."+ch, "on")
}
}
form.Set("toasts", "off")
form.Set("notify_desktop", "off")
return form
}
// RFC 0011 §6's matrix: four families by three channels, and every cell
// posts an EXPLICIT state. A checkbox alone posts nothing when it is
// unticked, which reads at the op as "no channels on" only by accident —
// the hidden off beside it is what makes the absence deliberate.
func TestPrefsPageRendersTwelveExplicitCells(t *testing.T) {
f := newPrefsForge(t, newFakeNotify())
code, body := f.get(t, "/me/notifications")
if code != http.StatusOK {
t.Fatalf("/me/notifications answered %d", code)
}
for _, family := range []string{"mention", "reply", "landing", "activity"} {
for _, ch := range []string{"inapp", "push", "email"} {
name := family + "." + ch
if !strings.Contains(body, `data-cell="`+name+`"`) {
t.Errorf("no cell for %s", name)
}
if !strings.Contains(body, `<input type="hidden" name="`+name+`" value="off">`) {
t.Errorf("cell %s posts no explicit off", name)
}
if !strings.Contains(body, `name="`+name+`" value="on"`) {
t.Errorf("cell %s posts no on", name)
}
}
}
if n := strings.Count(body, `data-cell="`); n != 12 {
t.Errorf("%d cells, want 12", n)
}
if !strings.Contains(body, `action="/me/notifications"`) {
t.Error("the matrix posts nowhere")
}
}
// A family notify could not be read is shown as UNKNOWN, not as on: the
// values on screen are quarry's default, and saying "on" would report a
// setting off the strength of a lookup that never ran.
func TestPrefsPageMarksUnknownFamilies(t *testing.T) {
n := newFakeNotify()
n.resolved = false
f := newPrefsForge(t, n)
_, body := f.get(t, "/me/notifications")
if !strings.Contains(body, `data-unknown="1"`) {
t.Error("no unknown hook on a family notify could not resolve")
}
if !strings.Contains(body, "notify is unreachable") {
t.Error("the page does not say why the cells are unknown")
}
// A forge whose companion answers reports nothing unknown.
known := newPrefsForge(t, newFakeNotify())
if _, ok := known.get(t, "/me/notifications"); strings.Contains(ok, `data-unknown="1"`) {
t.Error("a readable companion still reported unknown families")
}
}
// The mute list is the per-repository half of RFC 0011 §6: named repos
// with a way out, and the repos this account can see offered to add.
func TestPrefsPageListsMutesAndOffersRepos(t *testing.T) {
f := newPrefsForge(t, newFakeNotify())
if err := f.prefs.SetAccountPref("dan", "mute", `["tcl"]`); err != nil {
t.Fatal(err)
}
_, body := f.get(t, "/me/notifications")
if !strings.Contains(body, `data-muted="tcl"`) {
t.Error("the muted repository is not listed")
}
if !strings.Contains(body, `name="unmute" value="tcl"`) {
t.Error("no unmute control for a muted repository")
}
if !strings.Contains(body, `name="mute"`) {
t.Error("no way to mute a repository")
}
if !strings.Contains(body, `<option value="alpha">`) {
t.Error("a repository this account can see was not offered")
}
}
// The form round-trips through me.prefs.set: what comes back on the page
// is what the store now holds, and the banner says which halves took.
func TestPrefsFormRoundTrips(t *testing.T) {
notify := newFakeNotify()
f := newPrefsForge(t, notify)
form := fullMatrix()
form.Del("mention.email") // every state of that cell: off, and nothing else
form.Add("mention.email", "off")
form.Del("mention.push")
form.Add("mention.push", "off")
rec := f.post(t, "/me/notifications", form)
if rec.Code != http.StatusOK {
t.Fatalf("saving preferences answered %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `data-applied-local="1"`) {
t.Error("the banner does not report the forge-local half")
}
if !strings.Contains(body, `data-applied-notify="1"`) {
t.Error("the banner does not report the notify half")
}
notify.mu.Lock()
row := notify.stored["quarry-mention"]
notify.mu.Unlock()
if row["push"] || row["email"] {
t.Errorf("the unticked channels stayed on: %+v", row)
}
if !row["in_app"] {
t.Errorf("the ticked channel did not take: %+v", row)
}
if got := f.prefs.AccountPref("dan", "toasts"); got != "off" {
t.Errorf("the toasts switch stored %q, want off", got)
}
// And the page redrawn from the store agrees with what was saved.
_, page := f.get(t, "/me/notifications")
i := strings.Index(page, `data-cell="mention.push"`)
if i < 0 {
t.Fatal("the redrawn page lost the cell")
}
if strings.Contains(page[i:i+220], "checked") {
t.Error("a channel saved off came back ticked")
}
}
// A forge with no companion — or one that did not answer. The local
// half must still take, and the page must say the other half did not.
func TestPrefsBannerIsHonestWhenNotifyIsAbsent(t *testing.T) {
f := newPrefsForge(t, nil)
rec := f.post(t, "/me/notifications", fullMatrix())
body := rec.Body.String()
if !strings.Contains(body, `data-applied-local="1"`) {
t.Error("the forge-local half did not report as saved")
}
if !strings.Contains(body, `data-applied-notify="0"`) {
t.Error("the page claimed notify took the write")
}
}
// The push control states, all from what the SERVER knows: whether this
// forge has a companion at all, and whether the account has devices.
func TestPushControlStates(t *testing.T) {
none := newPrefsForge(t, nil)
if _, body := none.get(t, "/me/notifications"); !strings.Contains(body, `data-push="unsupported"`) {
t.Error("a forge with no companion did not say push is unavailable")
}
f := newPrefsForge(t, newFakeNotify())
_, body := f.get(t, "/me/notifications")
if !strings.Contains(body, `data-push="off"`) {
t.Error("an account with no devices did not read as not enabled")
}
if err := f.devices.AddPushDevice("dan", "https://push.example/x1", "Firefox on Linux"); err != nil {
t.Fatal(err)
}
_, body = f.get(t, "/me/notifications")
if !strings.Contains(body, `data-push="on"`) {
t.Error("an account with a device did not read as enabled")
}
if !strings.Contains(body, "Firefox on Linux") {
t.Error("the device is not listed")
}
if !strings.Contains(body, `data-device="`+endpointHash("https://push.example/x1")+`"`) {
t.Error("the device row carries no hook")
}
}
// RFC 0011 §11: push is the ONE recorded exception to Principle 3, and
// the control says so rather than failing quietly.
func TestPushControlSaysItNeedsScript(t *testing.T) {
f := newPrefsForge(t, newFakeNotify())
_, body := f.get(t, "/me/notifications")
for _, want := range []string{
"Needs JavaScript and a browser that supports push.",
"add quarry to the Home Screen first",
"navigator.serviceWorker.register('/sw.js')",
"Notification.requestPermission()",
"pushManager.subscribe",
"/me/push/key",
"@get('/me/push.frag')",
} {
if !strings.Contains(body, want) {
t.Errorf("the push control misses %q", want)
}
}
// No script FILE, ever: the exception is one expression on one
// control, not a bundle (RFC 0001 Principle 3).
if strings.Contains(body, "<script src=\"/me/push") {
t.Error("the push control pulled in a script file")
}
}
// The key route is what both the control and the service worker read.
// It needs a session: a worker offers a cookie, an anonymous prober
// offers nothing, and the two must not be answered the same way.
func TestPushKeyNeedsASessionAndRelaysTheKey(t *testing.T) {
f := newPrefsForge(t, newFakeNotify())
rec := httptest.NewRecorder()
f.h.ServeHTTP(rec, httptest.NewRequest("GET", "/me/push/key", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous /me/push/key answered %d, want 401", rec.Code)
}
code, body := f.get(t, "/me/push/key")
if code != http.StatusOK {
t.Fatalf("/me/push/key answered %d", code)
}
var out struct {
PublicKey string `json:"publicKey"`
}
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatalf("the key is not JSON: %v (%s)", err, body)
}
if out.PublicKey != "BPublicKeyBase64Url" {
t.Errorf("relayed key %q", out.PublicKey)
}
}
// The subscription is posted as the browser's own PushSubscription JSON,
// verbatim — reshaping it in the page is how a field goes missing.
func TestPushSubscribeForwardsAndRecordsTheDevice(t *testing.T) {
notify := newFakeNotify()
f := newPrefsForge(t, notify)
body := `{"endpoint":"https://push.example/abc","expirationTime":null,` +
`"keys":{"p256dh":"BPk","auth":"AUTH"}}`
req := httptest.NewRequest("POST", "/me/push", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0")
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
rec := httptest.NewRecorder()
f.h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("POST /me/push answered %d: %s", rec.Code, rec.Body.String())
}
notify.mu.Lock()
subs, p := append([]string(nil), notify.subs...), notify.subP256dh
notify.mu.Unlock()
if len(subs) != 1 || subs[0] != "https://push.example/abc" {
t.Fatalf("notify received %v", subs)
}
if p != "BPk" {
t.Errorf("the p256dh key did not reach notify: %q", p)
}
devs, err := f.devices.PushDevices("dan")
if err != nil {
t.Fatal(err)
}
if len(devs) != 1 {
t.Fatalf("%d devices recorded, want 1", len(devs))
}
if devs[0].UA == "" {
t.Error("the device was recorded with no label")
}
// Anonymous posts are refused: a subscription belongs to somebody.
anon := httptest.NewRecorder()
req = httptest.NewRequest("POST", "/me/push", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
f.h.ServeHTTP(anon, req)
if anon.Code != http.StatusUnauthorized {
t.Errorf("anonymous POST /me/push answered %d, want 401", anon.Code)
}
}
// Turning push off drops the credential in notify and forgets the row.
func TestPushDeleteDropsTheDevice(t *testing.T) {
notify := newFakeNotify()
f := newPrefsForge(t, notify)
if err := f.devices.AddPushDevice("dan", "https://push.example/abc", "Firefox"); err != nil {
t.Fatal(err)
}
rec := f.post(t, "/me/push/delete", url.Values{"endpoint": {"https://push.example/abc"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /me/push/delete answered %d", rec.Code)
}
notify.mu.Lock()
gone := append([]string(nil), notify.unsubbed...)
notify.mu.Unlock()
if len(gone) != 1 || gone[0] != "https://push.example/abc" {
t.Fatalf("notify was asked to drop %v", gone)
}
devs, err := f.devices.PushDevices("dan")
if err != nil {
t.Fatal(err)
}
if len(devs) != 0 {
t.Errorf("%d devices left, want 0", len(devs))
}
}
// The fragment is how the control redraws after the browser subscribed,
// with no page reload and no second source of truth for its state.
func TestPushFragmentRedrawsTheControl(t *testing.T) {
f := newPrefsForge(t, newFakeNotify())
if err := f.devices.AddPushDevice("dan", "https://push.example/abc", "Firefox"); err != nil {
t.Fatal(err)
}
code, body := f.get(t, "/me/push.frag")
if code != http.StatusOK {
t.Fatalf("/me/push.frag answered %d", code)
}
for _, want := range []string{
"event: datastar-patch-elements",
`id="push-control"`,
`data-push="on"`,
} {
if !strings.Contains(body, want) {
t.Errorf("the fragment misses %q:\n%s", want, body)
}
}
}
// The preferences page is reachable from the two places somebody looks:
// the account menu and the inbox's own settings fold.
func TestPreferencesAreLinkedFromTheInbox(t *testing.T) {
f := newPrefsForge(t, newFakeNotify())
_, body := f.get(t, "/inbox")
if !strings.Contains(body, `href="/me/notifications"`) {
t.Error("the inbox does not link to the preferences page")
}
if strings.Contains(body, `action="/inbox/notify"`) {
t.Error("the switches are still on the inbox as well as the preferences page")
}
}
// The inbox pages through `inbox.list`'s composite cursor: the "older"
// link carries it verbatim, and the page it leads to is a different set
// of rows rather than the same ones again.
func TestInboxOlderLinkCarriesTheCursor(t *testing.T) {
f := newPrefsForge(t, nil)
for i := 0; i < 60; i++ {
if _, err := f.attn.Attend("dan", api.AttentionEvent{
Kind: "mention", Repo: "alpha", From: "eve",
Summary: "note", Link: "/r/alpha/forum/abc",
}); err != nil {
t.Fatal(err)
}
}
_, body := f.get(t, "/inbox")
i := strings.Index(body, `class="inbox-older-link"`)
if i < 0 {
t.Fatalf("60 rows and no older link:\n%s", body)
}
href := body[strings.Index(body[i:], `href="`)+i+6:]
href = href[:strings.Index(href, `"`)]
if !strings.Contains(href, "before=") {
t.Fatalf("the older link carries no cursor: %q", href)
}
if !strings.Contains(body, `data-id="local:60"`) {
t.Error("the first page does not start at the newest row")
}
_, older := f.get(t, strings.ReplaceAll(href, "&", "&"))
if strings.Contains(older, `data-id="local:60"`) {
t.Error("the older page repeated the newest row")
}
if !strings.Contains(older, `data-id="local:10"`) {
t.Errorf("the older page does not carry the rows the first one cut off:\n%s", older)
}
}
// The row control reaches `inbox.mark` with the state that half of the
// list can actually reach: a notify message is archived (done), one of
// quarry's own attention events has no per-item archive, so it is
// marked read. Without script the same form gets a redirect back to the
// filter AND the page it came from (Principle 3).
func TestInboxRowControlMarksWhatEachHalfCanReach(t *testing.T) {
notify := newFakeNotify()
notify.msgs = []api.InboxMessage{{ID: "m1", Workflow: "quarry-mention",
Subject: "alice mentioned you", At: "2026-08-29T12:00:00Z"}}
f := newPrefsForge(t, notify)
if _, err := f.attn.Attend("dan", api.AttentionEvent{
Kind: "mention", Repo: "alpha", From: "eve", Link: "/r/alpha/forum/abc",
At: "2026-08-29T11:00:00Z",
}); err != nil {
t.Fatal(err)
}
_, body := f.get(t, "/inbox")
local := body[strings.Index(body, `data-id="local:1"`):]
local = local[:strings.Index(local, "</li>")]
if !strings.Contains(local, `name="as" value="read"`) {
t.Errorf("a local row offers an archive it cannot perform:\n%s", local)
}
notifyRow := body[strings.Index(body, `data-id="m1"`):]
notifyRow = notifyRow[:strings.Index(notifyRow, "</li>")]
if !strings.Contains(notifyRow, `name="as" value="done"`) {
t.Errorf("a notify row does not archive:\n%s", notifyRow)
}
rec := f.post(t, "/inbox/mark", url.Values{
"id": {"local:1"}, "as": {"read"}, "f": {"mentions"}, "before": {"CURSOR"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("a form submit answered %d, want 303", rec.Code)
}
if got := rec.Header().Get("Location"); got != "/inbox?f=mentions&before=CURSOR" {
t.Errorf("redirected to %q, want the filter and page it came from", got)
}
_, body = f.get(t, "/inbox")
if !strings.Contains(body, `data-unread="0">`) {
t.Errorf("the row did not come back read:\n%s", body)
}
}
// The live answer removes a row only where a reload would also stop
// showing it: an archived notify row anywhere, a row marked read in the
// unread tab. Removing it anywhere else would show something a refresh
// contradicts.
func TestInboxLiveRemovalMatchesAReload(t *testing.T) {
f := newPrefsForge(t, newFakeNotify())
if _, err := f.attn.Attend("dan", api.AttentionEvent{Kind: "mention", Repo: "alpha"}); err != nil {
t.Fatal(err)
}
live := func(form url.Values) string {
t.Helper()
req := httptest.NewRequest("POST", "/inbox/mark", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("datastar-request", "true")
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
rec := httptest.NewRecorder()
f.h.ServeHTTP(rec, req)
return rec.Body.String()
}
got := live(url.Values{"id": {"local:1"}, "as": {"read"}, "f": {"unread"}})
for _, want := range []string{
"event: datastar-patch-elements", "data: mode remove", "data: selector #attn-local-1",
} {
if !strings.Contains(got, want) {
t.Errorf("the unread tab did not drop a row it just read: missing %q:\n%s", want, got)
}
}
if got := live(url.Values{"id": {"local:1"}, "as": {"read"}, "f": {"all"}}); strings.Contains(got, "mode remove") {
t.Errorf("a read row was removed from a tab that still lists it:\n%s", got)
}
}
// A mark that did not land must not look like one that did.
func TestInboxMarkFailureRemovesNothing(t *testing.T) {
notify := newFakeNotify()
notify.markErr = errors.New("notify is down")
f := newPrefsForge(t, notify)
form := url.Values{"id": {"m1"}, "as": {"done"}, "f": {"all"}}
rec := f.post(t, "/inbox/mark", form)
if rec.Code != http.StatusSeeOther {
t.Fatalf("a failed mark answered %d", rec.Code)
}
if got := rec.Header().Get("Location"); !strings.Contains(got, "err=mark") {
t.Errorf("the redirect says nothing about the failure: %q", got)
}
_, body := f.get(t, "/inbox?f=all&err=mark")
if !strings.Contains(body, `id="inbox-error" class="attn-none" data-error="1"`) {
t.Errorf("the page does not report the failed mark:\n%s", body)
}
req := httptest.NewRequest("POST", "/inbox/mark", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("datastar-request", "true")
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
live := httptest.NewRecorder()
f.h.ServeHTTP(live, req)
got := live.Body.String()
if strings.Contains(got, "mode remove") {
t.Errorf("a failed mark still took the row off the page:\n%s", got)
}
if !strings.Contains(got, `data-error="1"`) {
t.Errorf("the live answer does not report the failure:\n%s", got)
}
}
// The id is echoed into a CSS selector, so anything that is not a row
// this page could have drawn never reaches one.
func TestInboxMarkRefusesAForeignID(t *testing.T) {
f := newPrefsForge(t, newFakeNotify())
form := url.Values{"id": {`x" onerror="alert(1)`}, "as": {"done"}, "f": {"all"}}
req := httptest.NewRequest("POST", "/inbox/mark", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("datastar-request", "true")
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
rec := httptest.NewRecorder()
f.h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "onerror") {
t.Errorf("a hand-written id reached the selector:\n%s", rec.Body.String())
}
}
// A family notify could not be read is not posted and not written: the
// values on screen are quarry's default, and saving them would store
// that default as though somebody had chosen it.
func TestPrefsUnknownFamiliesAreNeverWritten(t *testing.T) {
n := newFakeNotify()
n.resolved = false
f := newPrefsForge(t, n)
_, body := f.get(t, "/me/notifications")
if strings.Contains(body, `name="mention.inapp"`) {
t.Error("an unreadable family still posts its cells")
}
rec := f.post(t, "/me/notifications", fullMatrix())
if rec.Code != http.StatusOK {
t.Fatalf("saving answered %d", rec.Code)
}
n.mu.Lock()
stored := len(n.stored)
n.mu.Unlock()
if stored != 0 {
t.Errorf("a matrix quarry could not read was written anyway: %+v", n.stored)
}
if !strings.Contains(rec.Body.String(), `data-applied-notify="0"`) {
t.Error("the banner claimed notify took a write it never made")
}
}
// The two forge-local switches default OFF, and the preferences page,
// the inbox fold and the stream must all say so: a page that showed
// them on would describe a forge the reader is not using.
func TestLocalSwitchesDefaultOff(t *testing.T) {
f := newPrefsForge(t, newFakeNotify())
_, prefs := f.get(t, "/me/notifications")
i := strings.Index(prefs, `id="attn-toasts"`)
if i < 0 {
t.Fatal("no toasts switch")
}
if strings.Contains(prefs[i:i+80], "checked") {
t.Error("in-page banners default on")
}
if j := strings.Index(prefs, `id="notify-desktop"`); strings.Contains(prefs[j:j+80], "checked") {
t.Error("desktop notifications default on")
}
_, inbox := f.get(t, "/inbox")
for _, want := range []string{
`data-pref="toasts">In-page banners: <strong>off`,
`data-pref="notify_desktop">Desktop notifications: <strong>off`,
} {
if !strings.Contains(inbox, want) {
t.Errorf("the inbox fold disagrees with the default: missing %q", want)
}
}
}
// Every way the push control can fail ends at the fragment, worded by
// the server — a click that silently does nothing is the failure mode
// this replaces.
func TestPushControlReportsWhatWentWrong(t *testing.T) {
f := newPrefsForge(t, newFakeNotify())
_, body := f.get(t, "/me/notifications")
for _, want := range []string{
"'serviceWorker' in navigator", "'PushManager' in window",
"r.ok ? r.json() : Promise.reject", "@get('/me/push.frag?err=blocked')",
"@get('/me/push.frag?err=failed')",
} {
if !strings.Contains(body, want) {
t.Errorf("the push expression misses %q", want)
}
}
for code, want := range map[string]string{
"blocked": "blocked",
"unsupported": "cannot do push",
"failed": "Try again",
} {
_, frag := f.get(t, "/me/push.frag?err="+code)
if !strings.Contains(frag, `data-push-err="1"`) || !strings.Contains(frag, want) {
t.Errorf("the %s fragment does not say what happened:\n%s", code, frag)
}
}
// An unknown code says nothing rather than echoing itself back.
if _, frag := f.get(t, "/me/push.frag?err=%3Cscript%3E"); strings.Contains(frag, "data-push-err") {
t.Errorf("a hand-written code was echoed into the control:\n%s", frag)
}
}