// inboxweb_test.go proves RFC 0011 Phase 3b: the HTML plane over the
// same ops Phase 3a registered. A browser subscribes to push and the
// subscription reaches notify's credentials route; a preferences form
// posted by a page lands as notify preferences; Done on an inbox row
// takes it off the next listing.
//
// Everything here goes through the WEB routes with a cookie, because
// that is what a browser and a service worker have — the API plane
// authenticates by HTTP Basic and a worker holds no credential for it.
package harness
import (
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"testing"
"craftdesign.group/quarry/internal/notify"
)
// webCall performs one cookie-authenticated request against the HTML
// plane. Redirects are NOT followed: a form's 303 is the answer.
func webCall(t *testing.T, f inboxForge, user, method, path, contentType, body string) (*http.Response, string) {
t.Helper()
var rd io.Reader
if body != "" {
rd = strings.NewReader(body)
}
req, err := http.NewRequest(method, f.web.URL+path, rd)
if err != nil {
t.Fatal(err)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.AddCookie(&http.Cookie{Name: "quarry_session", Value: user})
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp, string(b)
}
// A browser turns push on: the subscription reaches notify's
// credentials route, the device appears on the preferences page, and
// turning it off again drops both.
func TestWebPushSubscribeListsAndRemovesADevice(t *testing.T) {
f := buildInboxForge(t)
resp, body := webCall(t, f, "dan", "GET", "/me/push/key", "", "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("/me/push/key answered %d: %s", resp.StatusCode, body)
}
var key struct {
PublicKey string `json:"publicKey"`
}
if err := json.Unmarshal([]byte(body), &key); err != nil {
t.Fatalf("the key is not JSON: %v (%s)", err, body)
}
if key.PublicKey != "BFakeVapidPublicKey" {
t.Fatalf("relayed key %q", key.PublicKey)
}
sub := `{"endpoint":"https://push.example/dan-1","expirationTime":null,` +
`"keys":{"p256dh":"BSomeKey","auth":"SomeAuth"}}`
resp, body = webCall(t, f, "dan", "POST", "/me/push", "application/json", sub)
if resp.StatusCode != http.StatusOK {
t.Fatalf("POST /me/push answered %d: %s", resp.StatusCode, body)
}
if got := f.fake.find("PUT", "/v1/subscribers/dan/credentials"); len(got) != 1 {
t.Fatalf("notify received %d credential writes, want 1", len(got))
}
_, page := webCall(t, f, "dan", "GET", "/me/notifications", "", "")
if !strings.Contains(page, `data-push="on"`) {
t.Errorf("the preferences page does not show push enabled:\n%s", page)
}
if !strings.Contains(page, `data-device="`) {
t.Error("the device is not listed")
}
resp, body = webCall(t, f, "dan", "POST", "/me/push/delete",
"application/x-www-form-urlencoded",
url.Values{"endpoint": {"https://push.example/dan-1"}}.Encode())
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("POST /me/push/delete answered %d: %s", resp.StatusCode, body)
}
_, page = webCall(t, f, "dan", "GET", "/me/notifications", "", "")
if strings.Contains(page, `data-device="`) {
t.Errorf("the device survived its removal:\n%s", page)
}
if !strings.Contains(page, `data-push="off"`) {
t.Error("push still reads as enabled with no devices left")
}
}
// The preferences form is the page's own: every cell posts an explicit
// state, and what comes back is what notify now holds.
func TestWebPreferencesFormRoundTrips(t *testing.T) {
f := buildInboxForge(t)
form := url.Values{}
for _, family := range []string{"mention", "reply", "landing", "activity"} {
for _, ch := range []string{"inapp", "push", "email"} {
form.Add(family+"."+ch, "off")
if !(family == "mention" && ch == "email") {
form.Add(family+"."+ch, "on")
}
}
}
form.Set("toasts", "off")
form.Set("notify_desktop", "on")
form.Set("mute", "alpha")
resp, page := webCall(t, f, "dan", "POST", "/me/notifications",
"application/x-www-form-urlencoded", form.Encode())
if resp.StatusCode != http.StatusOK {
t.Fatalf("saving preferences answered %d: %s", resp.StatusCode, page)
}
if !strings.Contains(page, `data-applied-local="1"`) ||
!strings.Contains(page, `data-applied-notify="1"`) {
t.Errorf("the banner did not report both halves:\n%s", page)
}
if !strings.Contains(page, `data-muted="alpha"`) {
t.Error("the repository the form muted is not listed as muted")
}
if got := f.odb.AccountPref("dan", "toasts"); got != "off" {
t.Errorf("the toasts switch stored %q, want off", got)
}
// The matrix reached notify as one whole write.
puts := f.fake.find("PUT", "/v1/subscribers/dan/preferences")
if len(puts) != 1 {
t.Fatalf("preference PUTs = %d, want 1", len(puts))
}
rows, _ := puts[0].body["preferences"].([]any)
for _, raw := range rows {
row, _ := raw.(map[string]any)
if id, _ := row["workflowId"].(string); id != "quarry-mention" {
continue
}
ch, _ := row["channels"].(map[string]any)
if on, _ := ch["email"].(bool); on {
t.Errorf("the unticked cell reached notify as on: %+v", ch)
}
if on, _ := ch["push"].(bool); !on {
t.Errorf("a ticked cell did not reach notify: %+v", ch)
}
}
// And the page redrawn from the store agrees: the one cell turned
// off comes back unticked.
_, page = webCall(t, f, "dan", "GET", "/me/notifications", "", "")
i := strings.Index(page, `data-cell="mention.email"`)
if i < 0 {
t.Fatal("the redrawn page lost the cell")
}
if strings.Contains(page[i:i+240], "checked") {
t.Errorf("a channel saved off came back ticked:\n%s", page[i:i+240])
}
}
// Done on a notify row archives it, and the next listing does not carry
// it — which is the whole claim of "an inbox you can clear".
func TestWebInboxDoneClearsTheRow(t *testing.T) {
f := buildInboxForge(t)
f.fake.seedFor("dan", notify.Message{ID: "m1", Subject: "alice mentioned you",
CreatedAt: "2026-08-29T12:00:00Z"})
_, page := webCall(t, f, "dan", "GET", "/inbox", "", "")
if !strings.Contains(page, `data-id="m1"`) {
t.Fatalf("the notify message is not on the inbox page:\n%s", page)
}
resp, body := webCall(t, f, "dan", "POST", "/inbox/mark",
"application/x-www-form-urlencoded",
url.Values{"id": {"m1"}, "as": {"done"}, "f": {"all"}}.Encode())
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("Done answered %d: %s", resp.StatusCode, body)
}
_, page = webCall(t, f, "dan", "GET", "/inbox", "", "")
if strings.Contains(page, `data-id="m1"`) {
t.Errorf("a row marked done came back on the next listing:\n%s", page)
}
}