// inbox_test.go proves RFC 0011 Phase 3a end to end: quarry's inbox is
// notify's inbox and quarry's own attention stream shown as ONE list,
// a mark reaches both halves, a push subscription reaches notify's
// credentials route, a muted repository stops being SENT without
// stopping being noticed, and a preferences write lands as notify
// preferences.
//
// The fake notify here implements the routes agreed on notify#300
// (mark), notify#301 (count and pagination) and notify#302 (web push),
// plus the preference pair read off notify's internal/preferences
// service. Regenerate it from notify's OpenAPI when those land.
package harness
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/chat"
"craftdesign.group/quarry/internal/events"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ingest"
"craftdesign.group/quarry/internal/notify"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/zitcli"
)
// phase3Req is one call the Phase 3 fake saw, with its query — which
// matters here in a way it did not for Phase 0: unsubscribe names its
// endpoint in the query string and nowhere else.
type phase3Req struct {
method, path, query string
body map[string]any
}
// phase3Notify answers the Phase 3 routes and remembers every call.
// Messages are stateful so a mark actually changes what a later list
// returns — a fake that accepts a mark and keeps serving the same
// unread page would let a broken "clear the inbox" pass.
type phase3Notify struct {
mu sync.Mutex
reqs []phase3Req
msgs []notify.Message
prefs []notify.Preference
}
func (f *phase3Notify) record(r *http.Request) map[string]any {
var body map[string]any
if b, _ := io.ReadAll(r.Body); len(b) > 0 {
json.Unmarshal(b, &body)
}
f.reqs = append(f.reqs, phase3Req{r.Method, r.URL.Path, r.URL.RawQuery, body})
return body
}
func (f *phase3Notify) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
body := f.record(r)
switch {
case r.Method == "GET" && r.URL.Path == "/v1/messages":
var live []notify.Message
for _, m := range f.msgs {
if !m.Archived {
live = append(live, m)
}
}
if live == nil {
live = []notify.Message{}
}
unread := 0
for _, m := range live {
if !m.Read {
unread++
}
}
json.NewEncoder(w).Encode(map[string]any{
"data": live, "next": "", "unreadCount": unread, "unseenCount": unread,
})
case r.Method == "GET" && r.URL.Path == "/v1/messages/count":
n := 0
for _, m := range f.msgs {
if !m.Read && !m.Archived {
n++
}
}
json.NewEncoder(w).Encode(map[string]any{"count": n})
case r.Method == "POST" && r.URL.Path == "/v1/messages/mark":
as, _ := body["as"].(string)
all, _ := body["all"].(bool)
named := map[string]bool{}
if ids, ok := body["ids"].([]any); ok {
for _, id := range ids {
named[id.(string)] = true
}
}
for i := range f.msgs {
if !all && !named[f.msgs[i].ID] {
continue
}
switch as {
case "seen":
f.msgs[i].Seen = true
case "read":
f.msgs[i].Read, f.msgs[i].Seen = true, true
case "archived":
f.msgs[i].Archived, f.msgs[i].Read = true, true
}
}
w.WriteHeader(http.StatusOK)
case r.Method == "GET" && r.URL.Path == "/v1/integrations/webpush/key":
json.NewEncoder(w).Encode(map[string]any{"publicKey": "BFakeVapidPublicKey"})
case r.Method == "GET" && strings.HasSuffix(r.URL.Path, "/preferences"):
json.NewEncoder(w).Encode(map[string]any{"data": f.prefs})
case r.Method == "PUT" && strings.HasSuffix(r.URL.Path, "/preferences"):
f.prefs = nil
if rows, ok := body["preferences"].([]any); ok {
for _, raw := range rows {
b, _ := json.Marshal(raw)
var p notify.Preference
json.Unmarshal(b, &p)
f.prefs = append(f.prefs, p)
}
}
w.WriteHeader(http.StatusOK)
default:
// Credentials, triggers, subscribers, topics and workflows only
// need recording and a success.
w.WriteHeader(http.StatusOK)
}
}
func (f *phase3Notify) find(method, path string) []phase3Req {
f.mu.Lock()
defer f.mu.Unlock()
var out []phase3Req
for _, r := range f.reqs {
if r.method == method && r.path == path {
out = append(out, r)
}
}
return out
}
func (f *phase3Notify) seed(msgs ...notify.Message) {
f.mu.Lock()
defer f.mu.Unlock()
f.msgs = append(f.msgs, msgs...)
}
// waitForPhase3 polls until (method, path) has been called want times,
// since every notify call quarry makes off an op is best-effort async.
func waitForPhase3(t *testing.T, f *phase3Notify, method, path string, want int) {
t.Helper()
for i := 0; i < 100; i++ {
if len(f.find(method, path)) >= want {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("never saw %d %s %s", want, method, path)
}
// harnessInbox is cmd/quarry's inboxCounter, restated: the ops name
// api.InboxPlane and the client speaks notify's wire.
type harnessInbox struct{ client *notify.Client }
func (i harnessInbox) Unread(ctx context.Context, account string) (int, error) {
return i.client.UnreadCount(ctx, account)
}
func (i harnessInbox) List(ctx context.Context, account, before string, limit int) ([]api.InboxMessage, string, error) {
msgs, next, _, _, err := i.client.Messages(ctx, account, before, limit)
if err != nil {
return nil, "", err
}
out := make([]api.InboxMessage, 0, len(msgs))
for _, m := range msgs {
out = append(out, api.InboxMessage{ID: m.ID, Workflow: m.WorkflowID, Subject: m.Subject,
Body: m.Content, Link: m.Link(), At: m.CreatedAt, Read: m.Read, Seen: m.Seen})
}
return out, next, nil
}
func (i harnessInbox) Mark(ctx context.Context, account string, ids []string, all bool, as string) error {
return i.client.MarkMessages(ctx, account, ids, all, as)
}
func (i harnessInbox) Preferences(ctx context.Context, account string) (map[string]map[string]bool, error) {
rows, err := i.client.Preferences(ctx, account)
if err != nil {
return nil, err
}
out := map[string]map[string]bool{}
for _, row := range rows {
if row.Type == notify.PreferenceWorkflow && row.WorkflowID != "" {
out[row.WorkflowID] = row.Channels
}
}
return out, nil
}
func (i harnessInbox) SetPreferences(ctx context.Context, account string, prefs map[string]map[string]bool) error {
rows := make([]notify.Preference, 0, len(prefs))
for workflow, channels := range prefs {
rows = append(rows, notify.Preference{Type: notify.PreferenceWorkflow,
WorkflowID: workflow, Channels: channels})
}
return i.client.SetPreferences(ctx, account, rows)
}
func (i harnessInbox) PushKey(ctx context.Context) (string, error) {
return i.client.WebPushKey(ctx)
}
func (i harnessInbox) PushSubscribe(ctx context.Context, account, endpoint, p256dh, auth string) error {
return i.client.PutWebPush(ctx, account, notify.PushSubscription{
Endpoint: endpoint, Keys: notify.PushKeys{P256dh: p256dh, Auth: auth}})
}
func (i harnessInbox) PushUnsubscribe(ctx context.Context, account, endpoint string) error {
return i.client.DeleteWebPush(ctx, account, endpoint)
}
// harnessPushDevices adapts forge OLTP to api.PushDeviceStore.
type harnessPushDevices struct{ db *oltp.DB }
func (p harnessPushDevices) AddPushDevice(account, endpoint, ua string) error {
return p.db.AddPushDevice(account, endpoint, ua)
}
func (p harnessPushDevices) DeletePushDevice(account, endpoint string) error {
return p.db.DeletePushDevice(account, endpoint)
}
func (p harnessPushDevices) PushDevices(account string) ([]api.PushDevice, error) {
rows, err := p.db.PushDevices(account)
if err != nil {
return nil, err
}
out := make([]api.PushDevice, 0, len(rows))
for _, r := range rows {
out = append(out, api.PushDevice{EndpointHash: r.EndpointHash, UA: r.UA, At: r.At})
}
return out, nil
}
type inboxForge struct {
srv *httptest.Server
odb *oltp.DB
ev *events.Server
fake *phase3Notify
}
// buildInboxForge is a real forge with BOTH halves wired — the notify
// companion and the local attention stream — which is the only
// configuration in which the merge, the two-sided mark and the mute can
// be observed at all.
func buildInboxForge(t *testing.T) inboxForge {
t.Helper()
fake := &phase3Notify{}
nsrv := httptest.NewServer(fake)
t.Cleanup(nsrv.Close)
dataDir := t.TempDir()
ev, err := events.Start(filepath.Join(dataDir, "nats"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(ev.Close)
odb, err := oltp.Open(filepath.Join(dataDir, "oltp.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { odb.Close() })
room, err := chat.New(ev.Conn(), ev.JS())
if err != nil {
t.Fatal(err)
}
nclient := notify.New(nsrv.URL, "k")
deps := api.Deps{
Version: "0.0.0-harness",
Repos: api.NewRepos(nil),
Chat: room,
Notify: &harnessNotify{client: nclient},
Inbox: harnessInbox{client: nclient},
Attention: harnessAttention{ev: ev},
AttnRead: odb,
Prefs: odb,
PushDevices: harnessPushDevices{db: odb},
Accounts: harnessAccounts{db: odb},
Forum: odb,
}
deps.CapsFor = func(account, repo string) api.CapSet { return odb.EffectiveCaps(account, repo) }
repoPath := buildStore(t, "inboxseed")
ix, err := index.Open(filepath.Join(dataDir, "alpha.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
cli := zitcli.New(repoPath)
pipe := ingest.NewPipeline(cli, ev, ix, "alpha", "harness")
deps.Repos.Put("alpha", &api.Repo{Index: ix, Zit: cli, Sync: pipe.Sync, Code: "alpha"})
if _, err := pipe.Sync(); err != nil {
t.Fatal(err)
}
for _, name := range []string{"dan", "eve"} {
if err := odb.AddAccount(name, "pw", false); err != nil {
t.Fatal(err)
}
if err := odb.SetCaps(name, "alpha", "C3h"); err != nil {
t.Fatal(err)
}
}
verify := func(repo, user, pass string, need ops.Auth) api.Decision {
if user == "" || pass != "pw" {
return api.Denied
}
return api.Allowed
}
r := ops.NewRegistry()
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
mux, err := api.RouterWithCaps(r, "0.0.0-harness", deps.CapsFor, verify)
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return inboxForge{srv: srv, odb: odb, ev: ev, fake: fake}
}
// inboxCall performs one authenticated API call, form-encoded.
func inboxCall(t *testing.T, f inboxForge, user, method, path string, form url.Values) (int, []byte) {
t.Helper()
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, err := http.NewRequest(method, f.srv.URL+path, body)
if err != nil {
t.Fatal(err)
}
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
req.SetBasicAuth(user, "pw")
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
type inboxListBody struct {
Rows []struct {
ID string `json:"id"`
Source string `json:"source"`
Reason string `json:"reason"`
Repo string `json:"repo"`
Summary string `json:"summary"`
Link string `json:"link"`
At string `json:"at"`
Read bool `json:"read"`
Seq uint64 `json:"seq"`
} `json:"rows"`
Next string `json:"next"`
Read uint64 `json:"read"`
}
func listInbox(t *testing.T, f inboxForge, user string) inboxListBody {
t.Helper()
code, body := inboxCall(t, f, user, "GET", "/api/v1/me/inbox", nil)
if code != http.StatusOK {
t.Fatalf("inbox.list: %d %s", code, body)
}
var out inboxListBody
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("inbox.list body: %v (%s)", err, body)
}
return out
}
// One list, both sources, newest first, each row carrying the reason it
// arrived — which is the whole claim of RFC 0011 §1's `inbox.list`.
func TestInboxListMergesNotifyAndAttention(t *testing.T) {
f := buildInboxForge(t)
f.fake.seed(
notify.Message{ID: "m-old", WorkflowID: "quarry-forum-reply", Subject: "eve replied",
CreatedAt: "2026-08-01T00:00:00Z"},
notify.Message{ID: "m-new", WorkflowID: "quarry-mention", Subject: "eve mentioned you",
CreatedAt: "2026-08-03T00:00:00Z",
Extras: json.RawMessage(`{"redirect":{"url":"/r/alpha/chat"}}`)},
)
// One local attention event, stamped between the two notify ones,
// so a merge that simply concatenated would be visible as an order.
if _, err := f.ev.Attend("dan", events.AttentionEvent{
Kind: "landing", Repo: "alpha", From: "eve", Summary: "landing accepted",
Link: "/r/alpha/landings/1", At: "2026-08-02T00:00:00Z",
}); err != nil {
t.Fatal(err)
}
got := listInbox(t, f, "dan")
if len(got.Rows) != 3 {
t.Fatalf("rows = %+v", got.Rows)
}
wantIDs := []string{"m-new", "", "m-old"} // "" = the local row, id checked below
wantSources := []string{"notify", "local", "notify"}
wantReasons := []string{"mention", "landing", "reply"}
for i, row := range got.Rows {
if row.Source != wantSources[i] || row.Reason != wantReasons[i] {
t.Fatalf("row %d = %+v, want source %q reason %q", i, row, wantSources[i], wantReasons[i])
}
if wantIDs[i] != "" && row.ID != wantIDs[i] {
t.Fatalf("row %d id = %q, want %q", i, row.ID, wantIDs[i])
}
}
local := got.Rows[1]
if !strings.HasPrefix(local.ID, "local:") || local.Seq == 0 {
t.Fatalf("the local row must be addressable by sequence: %+v", local)
}
if local.Repo != "alpha" || local.Link != "/r/alpha/landings/1" {
t.Fatalf("the local row lost its repo or link: %+v", local)
}
// The notify row's link comes out of its extras redirect.
if got.Rows[0].Link != "/r/alpha/chat" {
t.Fatalf("notify row link = %q", got.Rows[0].Link)
}
}
// "Clear everything" must reach BOTH halves: notify's bulk route for
// the messages, and the forward-only attention pointer for the local
// events. Clearing one and not the other leaves a badge that will not
// go out.
func TestInboxMarkAllReachesNotifyAndThePointer(t *testing.T) {
f := buildInboxForge(t)
f.fake.seed(notify.Message{ID: "m1", WorkflowID: "quarry-mention", Subject: "hi",
CreatedAt: "2026-08-01T00:00:00Z"})
seq, err := f.ev.Attend("dan", events.AttentionEvent{
Kind: "mention", Repo: "alpha", Summary: "hey", At: "2026-08-02T00:00:00Z"})
if err != nil {
t.Fatal(err)
}
code, body := inboxCall(t, f, "dan", "POST", "/api/v1/me/inbox/mark",
url.Values{"all": {"true"}, "as": {"read"}})
if code != http.StatusOK {
t.Fatalf("inbox.mark: %d %s", code, body)
}
var out struct {
OK bool `json:"ok"`
Seq uint64 `json:"seq"`
}
json.Unmarshal(body, &out)
if !out.OK || out.Seq != seq {
t.Fatalf("mark out = %+v, want the local pointer at %d", out, seq)
}
if got := f.odb.AttentionReadSeq("dan"); got != seq {
t.Fatalf("attention pointer = %d, want %d", got, seq)
}
marks := f.fake.find("POST", "/v1/messages/mark")
if len(marks) != 1 {
t.Fatalf("notify saw %d bulk marks, want 1", len(marks))
}
if marks[0].body["all"] != true || marks[0].body["subscriberId"] != "dan" ||
marks[0].body["as"] != "read" {
t.Fatalf("mark body = %+v", marks[0].body)
}
// And the count the badge reads is now zero from notify's side.
code, body = inboxCall(t, f, "dan", "GET", "/api/v1/me/inbox/count", nil)
if code != http.StatusOK {
t.Fatalf("inbox.count: %d %s", code, body)
}
var count struct {
Unread int `json:"unread"`
}
json.Unmarshal(body, &count)
if count.Unread != 0 {
t.Fatalf("unread after mark-all = %d", count.Unread)
}
}
// A "done" mark on a local id moves only the pointer, and a notify id
// goes only to notify: the split is by id spelling, not by a second
// field the page has to remember to set.
func TestInboxMarkSplitsIDsByOrigin(t *testing.T) {
f := buildInboxForge(t)
f.fake.seed(notify.Message{ID: "m1", WorkflowID: "quarry-mention", CreatedAt: "2026-08-01T00:00:00Z"})
seq, _ := f.ev.Attend("dan", events.AttentionEvent{Kind: "mention", At: "2026-08-02T00:00:00Z"})
code, body := inboxCall(t, f, "dan", "POST", "/api/v1/me/inbox/mark",
url.Values{"ids": {"m1", "local:" + strconv.FormatUint(seq, 10)}, "as": {"done"}})
if code != http.StatusOK {
t.Fatalf("inbox.mark: %d %s", code, body)
}
marks := f.fake.find("POST", "/v1/messages/mark")
if len(marks) != 1 {
t.Fatalf("notify marks = %d", len(marks))
}
// "done" is quarry's word; notify's state is archived.
if marks[0].body["as"] != "archived" {
t.Fatalf("as = %v, want archived", marks[0].body["as"])
}
ids, _ := marks[0].body["ids"].([]any)
if len(ids) != 1 || ids[0] != "m1" {
t.Fatalf("a local id must never be forwarded to notify: %+v", ids)
}
if got := f.odb.AttentionReadSeq("dan"); got != seq {
t.Fatalf("pointer = %d, want %d", got, seq)
}
// An archived message leaves the list entirely.
if rows := listInbox(t, f, "dan").Rows; len(rows) != 1 || rows[0].Source != "local" {
t.Fatalf("rows after archive = %+v", rows)
}
}
// The browser's own PushSubscription JSON, posted verbatim, must reach
// notify's credentials route with the subscriber id — and leave quarry
// holding a device row but never the endpoint.
func TestPushSubscribeForwardsAndRecordsADevice(t *testing.T) {
f := buildInboxForge(t)
const endpoint = "https://push.example.test/abc123"
req, err := http.NewRequest("POST", f.srv.URL+"/api/v1/me/push", strings.NewReader(
`{"endpoint":"`+endpoint+`","expirationTime":null,"keys":{"p256dh":"PPP","auth":"AAA"},"ua":"Firefox"}`))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth("dan", "pw")
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("me.push.subscribe: %d %s", resp.StatusCode, body)
}
puts := f.fake.find("PUT", "/v1/subscribers/dan/credentials")
if len(puts) != 1 {
t.Fatalf("credentials PUTs = %d", len(puts))
}
if puts[0].body["providerId"] != "webpush" {
t.Fatalf("providerId = %v", puts[0].body["providerId"])
}
creds, _ := puts[0].body["credentials"].(map[string]any)
if creds["endpoint"] != endpoint {
t.Fatalf("credentials = %+v", creds)
}
keys, _ := creds["keys"].(map[string]any)
if keys["p256dh"] != "PPP" || keys["auth"] != "AAA" {
t.Fatalf("keys = %+v", keys)
}
devs, err := f.odb.PushDevices("dan")
if err != nil {
t.Fatal(err)
}
if len(devs) != 1 || devs[0].UA != "Firefox" {
t.Fatalf("devices = %+v", devs)
}
if devs[0].EndpointHash != oltp.PushEndpointHash(endpoint) {
t.Fatalf("the device row must key on a hash, not the endpoint: %+v", devs[0])
}
// Unsubscribing names the endpoint in the query and forgets the row.
code, body := inboxCall(t, f, "dan", "POST", "/api/v1/me/push/delete",
url.Values{"endpoint": {endpoint}})
if code != http.StatusOK {
t.Fatalf("me.push.unsubscribe: %d %s", code, body)
}
dels := f.fake.find("DELETE", "/v1/subscribers/dan/credentials/webpush")
if len(dels) != 1 || !strings.Contains(dels[0].query, url.QueryEscape(endpoint)) {
t.Fatalf("delete = %+v", dels)
}
if devs, _ := f.odb.PushDevices("dan"); len(devs) != 0 {
t.Fatalf("devices after unsubscribe = %+v", devs)
}
}
// The push key is relayed, never held: quarry has no VAPID private
// half and the public one comes from notify on demand.
// A person who has never saved a preference has everything on: notify
// reads an absent channel as enabled, and quarry must not disagree
// with it just because it has no row of its own.
func TestPrefsDefaultToEverythingOn(t *testing.T) {
f := buildInboxForge(t)
code, body := inboxCall(t, f, "eve", "GET", "/api/v1/me/prefs", nil)
if code != http.StatusOK {
t.Fatalf("me.prefs: %d %s", code, body)
}
var out struct {
Families map[string]map[string]bool `json:"families"`
Channels []string `json:"channels"`
Toasts bool `json:"toasts"`
Desktop bool `json:"notify_desktop"`
Muted []string `json:"muted"`
}
json.Unmarshal(body, &out)
if len(out.Families) != 4 || len(out.Channels) != 3 {
t.Fatalf("matrix = %+v / %+v", out.Families, out.Channels)
}
for family, row := range out.Families {
for _, ch := range out.Channels {
if !row[ch] {
t.Fatalf("%s.%s defaulted off", family, ch)
}
}
}
if !out.Toasts || !out.Desktop || len(out.Muted) != 0 {
t.Fatalf("forge-local defaults = %+v", out)
}
}
func TestPushKeyIsRelayed(t *testing.T) {
f := buildInboxForge(t)
code, body := inboxCall(t, f, "dan", "GET", "/api/v1/me/push/key", nil)
if code != http.StatusOK {
t.Fatalf("me.push.key: %d %s", code, body)
}
var out struct {
PublicKey string `json:"publicKey"`
}
json.Unmarshal(body, &out)
if out.PublicKey != "BFakeVapidPublicKey" {
t.Fatalf("publicKey = %q", out.PublicKey)
}
}
// A muted repository stops being SENT and does not stop being noticed:
// the attention event is still published (the person finds it in their
// own inbox when they look), and no trigger reaches notify.
func TestMutedRepoSkipsNotifyButNotAttention(t *testing.T) {
f := buildInboxForge(t)
// eve mutes alpha through the ops surface, not the store, so the
// preferences write is what the mute is proved from.
code, body := inboxCall(t, f, "eve", "POST", "/api/v1/me/prefs",
url.Values{"mute": {"alpha"}, "toasts": {"on"}})
if code != http.StatusOK {
t.Fatalf("me.prefs.set: %d %s", code, body)
}
before := len(f.fake.find("POST", "/v1/events/trigger"))
if code, body := inboxCall(t, f, "dan", "POST", "/api/v1/r/alpha/chat",
url.Values{"text": {"hey @eve"}}); code != http.StatusOK {
t.Fatalf("chat.post: %d %s", code, body)
}
// The attention event still lands...
deadline := time.Now().Add(3 * time.Second)
var rows []struct {
Source string
Reason string
}
for time.Now().Before(deadline) {
got := listInbox(t, f, "eve")
rows = rows[:0]
for _, r := range got.Rows {
if r.Source == "local" {
rows = append(rows, struct{ Source, Reason string }{r.Source, r.Reason})
}
}
if len(rows) > 0 {
break
}
time.Sleep(50 * time.Millisecond)
}
if len(rows) != 1 || rows[0].Reason != "mention" {
t.Fatalf("a mute must not silence the attention stream: %+v", rows)
}
// ...and notify heard nothing at all about it.
time.Sleep(300 * time.Millisecond) // fireNotify is async; give a skipped trigger time to appear
for _, r := range f.fake.find("POST", "/v1/events/trigger")[before:] {
to, _ := r.body["to"].(map[string]any)
if to != nil && to["subscriberId"] == "eve" {
t.Fatalf("a muted repository still triggered notify: %+v", r.body)
}
}
}
// me.prefs.set writes the whole matrix to notify as per-workflow
// preference rows — an unticked channel is stored false, not simply
// left out, because notify reads an absent channel as ENABLED.
func TestPrefsSetWritesNotifyPreferences(t *testing.T) {
f := buildInboxForge(t)
code, body := inboxCall(t, f, "dan", "POST", "/api/v1/me/prefs", url.Values{
"mention": {"inapp", "push"},
"reply": {"inapp"},
"landing": {"inapp", "push", "email"},
"activity": {},
"toasts": {"off"},
})
if code != http.StatusOK {
t.Fatalf("me.prefs.set: %d %s", code, body)
}
puts := f.fake.find("PUT", "/v1/subscribers/dan/preferences")
if len(puts) != 1 {
t.Fatalf("preference PUTs = %d", len(puts))
}
rows, _ := puts[0].body["preferences"].([]any)
byWorkflow := map[string]map[string]any{}
for _, raw := range rows {
row, _ := raw.(map[string]any)
id, _ := row["workflowId"].(string)
ch, _ := row["channels"].(map[string]any)
byWorkflow[id] = ch
}
want := map[string]map[string]bool{
"quarry-mention": {"in_app": true, "push": true, "email": false},
"quarry-forum-reply": {"in_app": true, "push": false, "email": false},
"quarry-landing": {"in_app": true, "push": true, "email": true},
"quarry-activity": {"in_app": false, "push": false, "email": false},
}
for workflow, channels := range want {
got, ok := byWorkflow[workflow]
if !ok {
t.Fatalf("no preference row for %s: %+v", workflow, byWorkflow)
}
for ch, on := range channels {
if got[ch] != on {
t.Fatalf("%s.%s = %v, want %v (row %+v)", workflow, ch, got[ch], on, got)
}
}
}
// And the round trip: me.prefs reads back what was just written,
// plus the forge-local switch nobody else knows about.
code, body = inboxCall(t, f, "dan", "GET", "/api/v1/me/prefs", nil)
if code != http.StatusOK {
t.Fatalf("me.prefs: %d %s", code, body)
}
var out struct {
Families map[string]map[string]bool `json:"families"`
Toasts bool `json:"toasts"`
Desktop bool `json:"notify_desktop"`
Push bool `json:"push"`
}
json.Unmarshal(body, &out)
if !out.Families["mention"]["push"] || out.Families["mention"]["email"] {
t.Fatalf("mention row = %+v", out.Families["mention"])
}
if out.Families["activity"]["inapp"] {
t.Fatalf("activity was turned off and read back on: %+v", out.Families["activity"])
}
if out.Toasts {
t.Fatal("toasts were turned off and read back on")
}
// An UNPOSTED switch is off, not unchanged: a checkbox form sends
// nothing for an unticked box, so the whole matrix is the message
// and a missing key is a person having turned it off.
if out.Desktop {
t.Fatal("a switch the form did not post must read back off")
}
if !out.Push {
t.Fatal("push must be configurable on a forge with notify wired")
}
}