package api
import (
"context"
"encoding/base64"
"fmt"
"strconv"
"strings"
"testing"
"craftdesign.group/quarry/internal/ops"
)
// The page cursor addresses BOTH sources. A cursor that carried only
// notify's position is the bug this replaces: rows the limit truncated
// off the end of page one were never served on page two, because the
// notify cursor had already moved past them.
func TestInboxCursorRoundTrips(t *testing.T) {
for _, c := range []inboxCursor{
{},
{Notify: "n-abc", Skip: 3, Local: 42, LocalSet: true},
{NotifyDone: true, LocalDone: true},
// A notify cursor is opaque and may hold the separators the
// encoding uses; it must survive them.
{Notify: "a|b:c=d e", Skip: 1},
} {
got := decodeInboxCursor(c.encode())
if got != c {
t.Fatalf("round trip: %+v -> %q -> %+v", c, c.encode(), got)
}
}
// The empty cursor is the empty string: a first page carries no
// `before`, and it must not encode to a token that looks like one.
if s := (inboxCursor{}).encode(); s != "" {
t.Fatalf("empty cursor = %q", s)
}
// Garbage is a first page, not an error: a stale bookmark should
// show somebody their inbox, not a failure.
if got := decodeInboxCursor("not-base64!!"); got != (inboxCursor{}) {
t.Fatalf("garbage cursor = %+v", got)
}
// And the token is opaque — no readable sequence numbers in a URL.
if strings.Contains((inboxCursor{Local: 42, LocalSet: true}).encode(), "42") {
t.Fatal("the cursor must be opaque")
}
}
// An absent switch means "unchanged", never "off". The destructive
// reading — a form that posts `toasts` and not `notify_desktop` turning
// desktop notifications off — is what this replaces. Only the three
// values the field's oneof rule admits can reach it.
func TestSwitchValueDistinguishesAbsentFromOff(t *testing.T) {
for in, want := range map[string]string{
"": "", "on": "on", "ON": "on", " on ": "on", "off": "off", "OFF": "off",
} {
if got := switchValue(in); got != want {
t.Errorf("switchValue(%q) = %q, want %q", in, got, want)
}
}
}
// A cursor's in-page offset is arithmetic on a number that arrived from
// outside, and it is added to the limit handed to notify. A crafted one
// must never reach the companion.
func TestInboxCursorClampsAHostileSkip(t *testing.T) {
hostile := base64.RawURLEncoding.EncodeToString([]byte("n:|o:2000000000"))
if got := decodeInboxCursor(hostile); got != (inboxCursor{}) {
t.Fatalf("a two-billion-row offset must not decode: %+v", got)
}
if got := decodeInboxCursor((inboxCursor{Skip: inboxMaxSkip}).encode()); got.Skip != inboxMaxSkip {
t.Fatalf("the ceiling itself must still decode: %+v", got)
}
if got := decodeInboxCursor((inboxCursor{Skip: inboxMaxSkip + 1}).encode()); got != (inboxCursor{}) {
t.Fatalf("one past the ceiling must not: %+v", got)
}
}
// The muted set is stored as a JSON array, so a repository name is a
// whole element rather than whatever survives a split on spaces. The
// old space-separated spelling is still read, because an existing forge
// has rows in it.
func TestMuteListEncodingRoundTripsAndReadsTheOldSpelling(t *testing.T) {
got := decodeMuteList(encodeMuteList([]string{"beta", "alpha"}))
if len(got) != 2 || got[0] != "alpha" || got[1] != "beta" {
t.Fatalf("round trip = %+v (sorted)", got)
}
if got := decodeMuteList(""); len(got) != 0 {
t.Fatalf("empty = %+v", got)
}
if got := decodeMuteList("alpha beta"); len(got) != 2 || got[0] != "alpha" {
t.Fatalf("legacy space-separated = %+v", got)
}
}
// fixedPageInbox is an InboxPlane whose companion serves a FIXED page
// size and ignores the limit it is asked for — which real notify does
// today (internal/inboxapi returns the whole list). It records every
// limit it was handed, so a test can prove nothing unbounded reaches it.
type fixedPageInbox struct {
// windows is what the companion reports as effective; setWindows
// records what a save asked it to change.
windows map[string]DigestWindow
setWindows map[string]*DigestWindow
// setPrefs is the matrix a save sent. Which workflows are PRESENT
// is the assertion: notify merges what it is given, so a workflow
// the map never mentions is one the save left alone.
setPrefs map[string]map[string]bool
msgs []InboxMessage
pageSize int
limits []int
// unread is what the companion answers for the badge's half.
unread int
// marked records the ids Mark was told to move, so a test can
// prove a deduped row reached notify as notify's own id.
marked []string
// honourLimit makes it serve exactly what it was asked for, which
// is the shape notify#301 promises and the one an offset that never
// rebases grows without bound against.
honourLimit bool
}
func (f *fixedPageInbox) List(ctx context.Context, account, before string, limit int) ([]InboxMessage, string, error) {
f.limits = append(f.limits, limit)
from := 0
if before != "" {
n, err := strconv.Atoi(strings.TrimPrefix(before, "p"))
if err != nil {
return nil, "", fmt.Errorf("bad cursor %q", before)
}
from = n
}
if from > len(f.msgs) {
from = len(f.msgs)
}
page := f.msgs[from:]
size := f.pageSize
if f.honourLimit && limit > 0 {
size = limit
}
if len(page) > size {
page = page[:size]
}
next := ""
if from+len(page) < len(f.msgs) {
next = "p" + strconv.Itoa(from+len(page))
}
return page, next, nil
}
func (f *fixedPageInbox) Unread(context.Context, string) (int, error) { return f.unread, nil }
func (f *fixedPageInbox) Mark(_ context.Context, _ string, ids []string, _ bool, _ string) error {
f.marked = append(f.marked, ids...)
return nil
}
func (f *fixedPageInbox) Preferences(context.Context, string) (map[string]map[string]bool, bool, error) {
return nil, true, nil
}
func (f *fixedPageInbox) DigestWindows(context.Context, string) (map[string]DigestWindow, error) {
return f.windows, nil
}
func (f *fixedPageInbox) SetPreferences(_ context.Context, _ string, prefs map[string]map[string]bool,
digests map[string]*DigestWindow) error {
f.setWindows = digests
f.setPrefs = prefs
return nil
}
func (f *fixedPageInbox) PushKey(context.Context) (string, error) { return "", nil }
func (f *fixedPageInbox) PushSubscribe(context.Context, string, string, string, string) error {
return nil
}
func (f *fixedPageInbox) PushUnsubscribe(context.Context, string, string) error { return nil }
// The cursor must REBASE onto the companion's own next page rather than
// growing an offset against a page it has already left behind. Without
// it the offset climbs every page, each fetch asks for limit+offset
// rows, and a long read gets quadratically more expensive while the
// page-boundary paths never run at all.
func TestNotifyWindowRebasesOntoTheNextPage(t *testing.T) {
var msgs []InboxMessage
for i := 0; i < 9; i++ {
msgs = append(msgs, InboxMessage{ID: "m" + strconv.Itoa(i), At: "2026-08-01T00:00:00Z"})
}
fake := &fixedPageInbox{msgs: msgs, pageSize: 2}
d := Deps{Inbox: fake}
var seen []string
cur := inboxCursor{}
for page := 0; page < 10; page++ {
win, err := buildNotifyWindow(context.Background(), d, "dan", cur, 3)
if err != nil {
t.Fatal(err)
}
if len(win.rows) == 0 {
break
}
for _, m := range win.rows {
seen = append(seen, m.ID)
}
cur = win.after[len(win.after)-1]
if cur.Skip > fake.pageSize {
t.Fatalf("page %d left the offset at %d, past a whole page — it never rebased", page, cur.Skip)
}
if cur.NotifyDone {
break
}
}
if len(seen) != len(msgs) {
t.Fatalf("walked %d rows (%v), want %d", len(seen), seen, len(msgs))
}
for i, id := range seen {
if id != msgs[i].ID {
t.Fatalf("row %d = %q, want %q (%v)", i, id, msgs[i].ID, seen)
}
}
// And nothing unbounded ever reached the companion.
for _, l := range fake.limits {
if l > 3+inboxMaxSkip {
t.Fatalf("asked the companion for %d rows", l)
}
}
}
// A crafted cursor is refused before it can become a `?limit=`.
func TestHostileCursorNeverReachesTheCompanion(t *testing.T) {
fake := &fixedPageInbox{msgs: []InboxMessage{{ID: "m0"}}, pageSize: 2}
d := Deps{Inbox: fake}
hostile := base64.RawURLEncoding.EncodeToString([]byte("n:|o:2000000000"))
if _, err := buildNotifyWindow(context.Background(), d, "dan", decodeInboxCursor(hostile), 3); err != nil {
t.Fatal(err)
}
if len(fake.limits) != 1 || fake.limits[0] != 3 {
t.Fatalf("limits handed to the companion = %v, want just the page limit", fake.limits)
}
}
// The same walk against a companion that HONOURS the limit. This is
// where a non-rebasing offset shows itself: every page would ask for
// limit+offset rows with the offset climbing, so a long read gets
// quadratically more expensive against a cursor it has already passed.
// Rebased, every request is the page limit exactly.
func TestNotifyWindowDoesNotGrowItsRequests(t *testing.T) {
var msgs []InboxMessage
for i := 0; i < 12; i++ {
msgs = append(msgs, InboxMessage{ID: "m" + strconv.Itoa(i), At: "2026-08-01T00:00:00Z"})
}
fake := &fixedPageInbox{msgs: msgs, pageSize: 3, honourLimit: true}
d := Deps{Inbox: fake}
var seen []string
cur := inboxCursor{}
for page := 0; page < 10; page++ {
win, err := buildNotifyWindow(context.Background(), d, "dan", cur, 3)
if err != nil {
t.Fatal(err)
}
if len(win.rows) == 0 {
break
}
for _, m := range win.rows {
seen = append(seen, m.ID)
}
cur = win.after[len(win.after)-1]
if cur.NotifyDone {
break
}
}
if len(seen) != len(msgs) {
t.Fatalf("walked %d rows (%v), want %d", len(seen), seen, len(msgs))
}
for _, l := range fake.limits {
if l != 3 {
t.Fatalf("requests = %v: an offset that never rebases makes them grow", fake.limits)
}
}
}
// inboxRegistry wires the inbox ops (and the attention ops the count
// and the read pointer live in) over one set of fakes.
func inboxRegistry(t *testing.T, d Deps) *ops.Registry {
t.Helper()
r := ops.NewRegistry()
if err := registerInbox(r, d); err != nil {
t.Fatal(err)
}
if err := registerAttention(r, d); err != nil {
t.Fatal(err)
}
return r
}
// A person-addressed event exists in BOTH halves whenever notify is
// configured: quarry writes the attention event and triggers notify
// with the same idempotency key. Listing both is listing one mention
// twice. The key is what pairs them, and the notify row is the one
// kept — it carries notify's own id and read state.
func TestInboxListDedupesTheNotifyTwin(t *testing.T) {
fa, store := newFakeAttention(), newMemAttnRead()
twin := "mention:chat:alpha:7:alice"
if _, err := fa.Attend("alice", AttentionEvent{
Kind: "mention", Summary: "seen twice", At: "2026-08-01T00:00:02Z", TxnID: twin,
}); err != nil {
t.Fatal(err)
}
if _, err := fa.Attend("alice", AttentionEvent{
Kind: "mention", Summary: "local only", At: "2026-08-01T00:00:01Z",
TxnID: "mention:chat:alpha:8:alice",
}); err != nil {
t.Fatal(err)
}
inbox := &fixedPageInbox{pageSize: 10, msgs: []InboxMessage{{
ID: "01M18B", Workflow: "quarry-mention", Subject: "seen twice",
At: "2026-08-01T00:00:02Z", TxnID: twin,
}}}
r := inboxRegistry(t, Deps{Inbox: inbox, Attention: fa, AttnRead: store})
ctx := ops.WithUser(context.Background(), "alice")
out, err := r.Invoke(ctx, "inbox.list", inboxListIn{Limit: 20})
if err != nil {
t.Fatal(err)
}
rows := out.(inboxListOut).Rows
if len(rows) != 2 {
t.Fatalf("listed %d rows, want 2 (the twin renders once): %+v", len(rows), rows)
}
// The twin is first (newest) and it is notify's row, carrying the
// local sequence so a mark can still move the pointer.
if rows[0].Source != "notify" {
t.Errorf("the deduped row came from %q, want notify", rows[0].Source)
}
if !strings.Contains(rows[0].ID, "01M18B") {
t.Errorf("the deduped row's id is %q, want notify's own id in it", rows[0].ID)
}
if rows[0].Seq != 1 {
t.Errorf("the deduped row carries seq %d, want the local event's 1", rows[0].Seq)
}
// The local-only row is untouched: notify was down or unconfigured
// when it was raised, and it is the only record of that mention.
if rows[1].Source != "local" || rows[1].Seq != 2 {
t.Errorf("the local-only row reads %+v", rows[1])
}
}
// The badge counts what the list shows: a mention in both halves is
// one waiting thing, not two.
func TestInboxCountCountsADedupedRowOnce(t *testing.T) {
fa, store := newFakeAttention(), newMemAttnRead()
twin := "mention:chat:alpha:7:alice"
for _, txn := range []string{twin, "mention:chat:alpha:8:alice"} {
if _, err := fa.Attend("alice", AttentionEvent{
Kind: "mention", At: "2026-08-01T00:00:00Z", TxnID: txn,
}); err != nil {
t.Fatal(err)
}
}
inbox := &fixedPageInbox{pageSize: 10, unread: 1, msgs: []InboxMessage{{
ID: "01M18B", Workflow: "quarry-mention", At: "2026-08-01T00:00:00Z", TxnID: twin,
}}}
r := inboxRegistry(t, Deps{Inbox: inbox, Attention: fa, AttnRead: store})
ctx := ops.WithUser(context.Background(), "alice")
out, err := r.Invoke(ctx, "inbox.count", inboxCountIn{})
if err != nil {
t.Fatal(err)
}
// One notify row + one local row with no twin. The twinned local
// event is already counted by notify's own unread.
if got := out.(inboxCountOut).Unread; got != 2 {
t.Fatalf("unread is %d, want 2 (the twin counted once)", got)
}
}
// Marking a deduped row has to reach both halves: notify holds the
// read state the row rendered, and the local pointer is what the badge
// is measured from. One id, both marks.
func TestInboxMarkOnADedupedRowMarksBoth(t *testing.T) {
fa, store := newFakeAttention(), newMemAttnRead()
twin := "mention:chat:alpha:7:alice"
if _, err := fa.Attend("alice", AttentionEvent{
Kind: "mention", At: "2026-08-01T00:00:00Z", TxnID: twin,
}); err != nil {
t.Fatal(err)
}
inbox := &fixedPageInbox{pageSize: 10, msgs: []InboxMessage{{
ID: "01M18B", Workflow: "quarry-mention", At: "2026-08-01T00:00:00Z", TxnID: twin,
}}}
r := inboxRegistry(t, Deps{Inbox: inbox, Attention: fa, AttnRead: store})
ctx := ops.WithUser(context.Background(), "alice")
out, err := r.Invoke(ctx, "inbox.list", inboxListIn{Limit: 20})
if err != nil {
t.Fatal(err)
}
rows := out.(inboxListOut).Rows
if len(rows) != 1 {
t.Fatalf("listed %d rows, want 1: %+v", len(rows), rows)
}
mark, err := r.Invoke(ctx, "inbox.mark", inboxMarkIn{IDs: []string{rows[0].ID}, As: "read"})
if err != nil {
t.Fatal(err)
}
if got := mark.(inboxMarkOut).Seq; got != 1 {
t.Fatalf("the local pointer is at %d after the mark, want 1", got)
}
if len(inbox.marked) != 1 || inbox.marked[0] != "01M18B" {
t.Fatalf("notify was told to mark %v, want just its own id", inbox.marked)
}
}
// notify does not render a step's redirect against the payload
// (notify#328), so its half of a paired row usually has no destination
// while quarry's own half — raised from the same happening, link
// already resolved — does. The row people click is notify's, so it
// takes the twin's link.
func TestPairedRowTakesTheLocalLinkWhenNotifyHasNone(t *testing.T) {
fa, store := newFakeAttention(), newMemAttnRead()
twin := "mention:chat:alpha:9:alice"
if _, err := fa.Attend("alice", AttentionEvent{
Kind: "mention", Summary: "come and look", At: "2026-08-01T00:00:02Z",
Link: "https://forge.example/r/alpha/chat", TxnID: twin,
}); err != nil {
t.Fatal(err)
}
inbox := &fixedPageInbox{pageSize: 10, msgs: []InboxMessage{{
ID: "01M19C", Workflow: "quarry-mention", Subject: "come and look",
At: "2026-08-01T00:00:02Z", TxnID: twin,
}}}
r := inboxRegistry(t, Deps{Inbox: inbox, Attention: fa, AttnRead: store})
rows := mustList(t, r, "alice")
if len(rows) != 1 || rows[0].Source != "notify" {
t.Fatalf("FAIL: wanted one notify row, got %+v", rows)
}
if rows[0].Link != "https://forge.example/r/alpha/chat" {
t.Errorf("FAIL: the row leads to %q, want the twin's link", rows[0].Link)
}
t.Log("ok: a paired row without notify's own link takes its twin's")
}
// notify's own link still wins when it has one: it is the row's real
// destination and may differ from the local half's.
func TestPairedRowKeepsNotifysOwnLink(t *testing.T) {
fa, store := newFakeAttention(), newMemAttnRead()
twin := "mention:chat:alpha:10:alice"
if _, err := fa.Attend("alice", AttentionEvent{
Kind: "mention", Summary: "two links", At: "2026-08-01T00:00:02Z",
Link: "https://forge.example/local", TxnID: twin,
}); err != nil {
t.Fatal(err)
}
inbox := &fixedPageInbox{pageSize: 10, msgs: []InboxMessage{{
ID: "01M19D", Workflow: "quarry-mention", Subject: "two links",
Link: "https://forge.example/notify", At: "2026-08-01T00:00:02Z", TxnID: twin,
}}}
r := inboxRegistry(t, Deps{Inbox: inbox, Attention: fa, AttnRead: store})
rows := mustList(t, r, "alice")
if len(rows) != 1 || rows[0].Link != "https://forge.example/notify" {
t.Errorf("FAIL: the row leads to %q, want notify's own link", rows[0].Link)
}
t.Log("ok: notify's own link is not overwritten")
}
func mustList(t *testing.T, r *ops.Registry, user string) []inboxRow {
t.Helper()
out, err := r.Invoke(ops.WithUser(context.Background(), user), "inbox.list", inboxListIn{Limit: 20})
if err != nil {
t.Fatal(err)
}
return out.(inboxListOut).Rows
}
// Writing one family leaves the other three alone.
//
// me.prefs.set rebuilt all four families from whatever arrived and
// posted the lot, so a caller that named one family turned the other
// three off — every channel, silently, answering 200. jva hit it on the
// first partial save (2b8b96ae, Severe).
//
// The assertion is which workflows are PRESENT in what reaches notify,
// not what their rows say. notify merges the map it is given, so a
// workflow the map never mentions keeps what it had; one that appears
// with an all-false row is one quarry has just switched off.
func TestPrefsSetWritesOnlyTheFamiliesItWasGiven(t *testing.T) {
inbox := &fixedPageInbox{}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Inbox: inbox,
Repos: NewRepos(map[string]*Repo{})}); err != nil {
t.Fatal(err)
}
ctx := ops.WithUser(context.Background(), "dan")
if _, err := r.Invoke(ctx, "me.prefs.set",
mePrefsSetIn{Mention: []string{"inapp"}}); err != nil {
t.Fatal(err)
}
got := inbox.setPrefs
if _, ok := got["quarry-mention"]; !ok {
t.Fatalf("the family that was named never reached notify: %+v", got)
}
for _, wf := range []string{"quarry-forum-reply", "quarry-landing", "quarry-activity"} {
if row, ok := got[wf]; ok {
t.Errorf("%s was written as %+v by a save that named only mention: a family "+
"nobody mentioned is a family nobody asked to change", wf, row)
}
}
if len(got) != 1 {
t.Errorf("want exactly the one named family, got %d: %+v", len(got), got)
}
}
// Naming the families explicitly writes exactly those, including a
// family being cleared to nothing — which is the case the page needs
// and which the channels alone cannot express, since a family with
// every box unticked posts what a family nobody drew posts.
func TestPrefsSetHonoursAnExplicitFamilyList(t *testing.T) {
inbox := &fixedPageInbox{}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Inbox: inbox,
Repos: NewRepos(map[string]*Repo{})}); err != nil {
t.Fatal(err)
}
ctx := ops.WithUser(context.Background(), "dan")
// "reply" is named but carries no channels: switch it off.
if _, err := r.Invoke(ctx, "me.prefs.set", mePrefsSetIn{
Families: []string{"mention", "reply"},
Mention: []string{"inapp"},
}); err != nil {
t.Fatal(err)
}
got := inbox.setPrefs
if len(got) != 2 {
t.Fatalf("want the two named families, got %d: %+v", len(got), got)
}
row, ok := got["quarry-forum-reply"]
if !ok {
t.Fatal("a family named with no channels was skipped, so it can never be cleared")
}
for ch, on := range row {
if on {
t.Errorf("reply/%s stayed on though the family was named with no channels", ch)
}
}
if !got["quarry-mention"]["in_app"] {
t.Errorf("the named family's channel did not take: %+v", got["quarry-mention"])
}
}
// A PAIRED row keeps quarry's own text, not notify's template.
//
// jva fired twelve mentions in eight seconds and got twelve identical
// inbox rows — "jva mentioned you in dantest-sandbox" — while the
// stream showed each message in full. Both halves of every one of those
// events existed; the merge kept the notify half, whose Subject is a
// template rendering, and discarded quarry's own half, whose Summary is
// the message. The durable surface was poorer than the ephemeral one
// (dc2221a57748).
//
// Twelve rows here rather than one, because the defect is not visible
// in a single row: one generic sentence looks fine. It is only when
// they stand next to each other that the inbox becomes untriageable,
// which is why the measurement that found it was a load test.
func TestPairedInboxRowsKeepTheMessageAndStayDistinct(t *testing.T) {
fa, store := newFakeAttention(), newMemAttnRead()
var msgs []InboxMessage
for i := 0; i < 12; i++ {
txn := "mention:chat:alpha:" + strconv.Itoa(i) + ":alice"
at := "2026-08-01T00:00:" + fmt.Sprintf("%02d", i) + "Z"
if _, err := fa.Attend("alice", AttentionEvent{
Kind: "mention", Repo: "dantest-sandbox", From: "jva",
Summary: "probe " + strconv.Itoa(i) + ": the text that tells them apart",
Link: "/r/dantest-sandbox/chat#" + strconv.Itoa(i),
At: at, TxnID: txn,
}); err != nil {
t.Fatal(err)
}
msgs = append(msgs, InboxMessage{
ID: "01M18B" + strconv.Itoa(i), Workflow: "quarry-mention",
Subject: "jva mentioned you in dantest-sandbox", At: at, TxnID: txn,
})
}
inbox := &fixedPageInbox{pageSize: 20, msgs: msgs}
r := inboxRegistry(t, Deps{Inbox: inbox, Attention: fa, AttnRead: store})
ctx := ops.WithUser(context.Background(), "alice")
out, err := r.Invoke(ctx, "inbox.list", inboxListIn{Limit: 30})
if err != nil {
t.Fatal(err)
}
rows := out.(inboxListOut).Rows
if len(rows) != 12 {
t.Fatalf("listed %d rows, want 12 (each twin renders once)", len(rows))
}
seen := map[string]bool{}
for _, row := range rows {
if row.Source != "notify" {
t.Errorf("FAIL: row %s came from %q — the notify half must still win on identity",
row.ID, row.Source)
}
if strings.Contains(row.Summary, "mentioned you in") {
t.Errorf("FAIL: row %s kept notify's template %q instead of the message",
row.ID, row.Summary)
}
if row.Repo != "dantest-sandbox" || row.From != "jva" {
t.Errorf("FAIL: row %s says repo=%q from=%q — a paired row cannot say who or where",
row.ID, row.Repo, row.From)
}
seen[row.Summary] = true
}
if len(seen) != 12 {
t.Fatalf("FAIL: 12 rows carry %d distinct summaries — the inbox cannot be triaged", len(seen))
}
t.Logf("ok: 12 paired rows, 12 distinct summaries, e.g. %q", rows[0].Summary)
}
// An UNPAIRED notify row is untouched: notify was down or the twin is
// on another page, and its subject is then the only text there is.
func TestAnUnpairedNotifyRowKeepsItsSubject(t *testing.T) {
fa, store := newFakeAttention(), newMemAttnRead()
inbox := &fixedPageInbox{pageSize: 10, msgs: []InboxMessage{{
ID: "01M18Z", Workflow: "quarry-mention", Subject: "jva mentioned you in alpha",
At: "2026-08-01T00:00:05Z", TxnID: "no-local-half",
}}}
r := inboxRegistry(t, Deps{Inbox: inbox, Attention: fa, AttnRead: store})
ctx := ops.WithUser(context.Background(), "alice")
out, err := r.Invoke(ctx, "inbox.list", inboxListIn{Limit: 20})
if err != nil {
t.Fatal(err)
}
rows := out.(inboxListOut).Rows
if len(rows) != 1 || rows[0].Summary != "jva mentioned you in alpha" {
t.Fatalf("FAIL: an unpaired notify row lost its subject: %+v", rows)
}
t.Logf("ok: unpaired notify row keeps %q", rows[0].Summary)
}