Files
pad/internal/server/task3939b_push_fixture_test.go
T
xarmian dbbbefda75 feat(push): the push dispatcher core and its privacy gates, not yet wired (TASK-3939, part B1) (#2280)
* feat(store): record a delivery by id, unclaim without an attempt, re-check holds (TASK-3939 B1)

RecordNotificationsSent is token-free for pending|expired rows (r4-L4),
UnclaimNotifications hands never-tried rows back without an attempt,
HeldNotifications is the pre-send re-check. The claim error drops the row
id (Q8), notify.SendTimeout is exported (Q4), and the store doc says SENT
is the one deliberate exception to settle-under-the-claim (Q5).

Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg

* feat(server): the push dispatcher core and its privacy gates (TASK-3939 B1)

Claims settled rows, runs membership, liveness, visibility, assignment,
comment, settings, agent-activity and device gates on each (a skip carries a
recorded reason and never reaches the gateway), groups survivors per
(user, item), sends one content-free push per live device and settles per
chunk. Lease >= sendTimeout + margin is enforced by the setter, the send
deadline and a per-chunk time check. Not wired into cmd/pad.

Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg

* test(server): dispatcher gates, lease invariant and late-send recording (TASK-3939 B1)

Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg

* test(server): an unknown notification kind is skipped (TASK-3939 B1)

Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg

* fix(server): the dispatcher logs no ids, settles zero-message units, checks the comment's item and has a clock seam (TASK-3939 B1)

C1 reword the store header (the dispatcher is wired in part B2). H1 split
computeWatchAccessVisibility into a log-free core the dispatcher calls; the
handler's wrapper logs exactly as before. H2 settle a unit with no message as
a no_device skip. H4 a mention/comment/reply row's comment must be on the
row's item and workspace. Add the unexported now seam. T3 comment on the nil
user branch.

Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU

* test(store): settle primitives assert attempts, sent_at and span more than one id chunk (TASK-3939 B1)

Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU

* test(server): dispatcher fix round, clock-seam lease tests, every gate error, gate order, log leak, zero-message unit (TASK-3939 B1)

Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU
2026-10-22 00:38:26 -04:00

255 lines
7.3 KiB
Go

package server
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/notify"
"github.com/PerpetualSoftware/pad/internal/store"
)
// Fixtures for the push dispatcher tests (TASK-3939 part B1). Every test runs
// on SQLite and, under `make test-pg`, on Postgres (needsBackends).
// fakePush is the gateway: it records every Send and answers sent for every
// message unless fn says otherwise.
type fakePush struct {
mu sync.Mutex
calls int
msgs [][]notify.Message
fn func(ctx context.Context, msgs []notify.Message) ([]notify.Result, error)
}
func (p *fakePush) Send(ctx context.Context, msgs []notify.Message) ([]notify.Result, error) {
p.mu.Lock()
p.calls++
p.msgs = append(p.msgs, append([]notify.Message(nil), msgs...))
fn := p.fn
p.mu.Unlock()
if fn != nil {
return fn(ctx, msgs)
}
return allStatus(msgs, notify.StatusSent), nil
}
func (p *fakePush) callCount() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.calls
}
// all returns every message sent so far, flattened.
func (p *fakePush) all() []notify.Message {
p.mu.Lock()
defer p.mu.Unlock()
var out []notify.Message
for _, b := range p.msgs {
out = append(out, b...)
}
return out
}
func allStatus(msgs []notify.Message, status string) []notify.Result {
out := make([]notify.Result, len(msgs))
for i, m := range msgs {
out[i] = notify.Result{ID: m.ID, Status: status}
}
return out
}
var pdxSeq atomic.Int64
// pdx is a workspace with an owner (the actor), a recipient (bob) who is a
// member, assigned the item and has one live device registered long ago, a
// collection, an item, and a comment on it.
type pdx struct {
t *testing.T
srv *Server
fake *fakePush
n int64
owner *models.User
bob *models.User
ws *models.Workspace
coll *models.Collection
item *models.Item
comment *models.Comment
devID string
devSess string
}
func newPdx(t *testing.T, srv *Server) *pdx {
t.Helper()
f := &pdx{t: t, srv: srv, fake: &fakePush{}, n: pdxSeq.Add(1)}
srv.SetPushSender(f.fake)
f.owner = f.user("owner")
f.bob = f.user("bob")
ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: fmt.Sprintf("Push %d", f.n), OwnerID: f.owner.ID})
if err != nil {
t.Fatal(err)
}
f.ws = ws
must(t, srv.store.AddWorkspaceMember(ws.ID, f.owner.ID, "owner"))
must(t, srv.store.AddWorkspaceMember(ws.ID, f.bob.ID, "editor"))
f.coll, err = srv.store.CreateCollection(ws.ID, models.CollectionCreate{Name: "Tasks", Schema: nfxSchema})
if err != nil {
t.Fatal(err)
}
f.item = f.newItem("SECRET-ITEM-TITLE", &f.bob.ID)
f.comment, err = srv.store.CreateComment(ws.ID, f.item.ID, f.owner.ID, models.CommentCreate{Body: "SECRET-COMMENT", Author: "Owner", CreatedBy: "user", Source: "web"})
if err != nil {
t.Fatal(err)
}
// The device comes last, so nothing above enqueued a row for bob.
f.devID, f.devSess = f.device(f.bob, "tok-bob-"+fmt.Sprint(f.n), 5*time.Hour)
f.exec(`DELETE FROM user_notifications`)
return f
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
func (f *pdx) user(name string) *models.User {
f.t.Helper()
u, err := f.srv.store.CreateUser(models.UserCreate{
Email: fmt.Sprintf("%s%d@push.test", name, f.n), Name: name, Username: fmt.Sprintf("%s-%d", name, f.n), Password: "pw-test-12345",
})
must(f.t, err)
return u
}
func (f *pdx) newItem(title string, assignee *string) *models.Item {
f.t.Helper()
it, err := f.srv.store.CreateItem(f.ws.ID, f.coll.ID, models.ItemCreate{Title: title, Fields: `{"status":"open"}`, AssignedUserID: assignee})
must(f.t, err)
return it
}
func (f *pdx) exec(q string, args ...any) {
f.t.Helper()
if _, err := f.srv.store.DB().Exec(f.srv.store.D().Rebind(q), args...); err != nil {
f.t.Fatalf("%s: %v", q, err)
}
}
func (f *pdx) ts(ago time.Duration) string { return time.Now().UTC().Add(-ago).Format(time.RFC3339) }
func (f *pdx) pg() bool { return f.srv.store.D().Driver() == store.DriverPostgres }
// device registers a device for u on a fresh live session, created ago ago.
func (f *pdx) device(u *models.User, token string, ago time.Duration) (deviceID, sessionID string) {
f.t.Helper()
plain, err := f.srv.store.CreateSession(u.ID, "android", "", "", time.Hour)
must(f.t, err)
h := sha256.Sum256([]byte(plain))
row := f.srv.store.DB().QueryRow(f.srv.store.D().Rebind(`SELECT id FROM sessions WHERE token_hash = ?`), hex.EncodeToString(h[:]))
must(f.t, row.Scan(&sessionID))
deviceID = fmt.Sprintf("dev-%d-%d", f.n, pdxSeq.Add(1))
ts := f.ts(ago)
f.exec(`INSERT INTO push_devices (id, user_id, session_id, connection_id, platform, token, app_version, created_at, updated_at)
VALUES (?, ?, ?, ?, 'android', ?, '1', ?, ?)`, deviceID, u.ID, sessionID, "conn-"+deviceID, token, ts, ts)
return
}
type rowOpt struct {
user *models.User
item *models.Item
comment string // "" = none; "default" = f.comment
age time.Duration
agent bool
}
// row seeds a pending queue row for bob (or opts.user) about the fixture item.
func (f *pdx) row(kind string, opts ...func(*rowOpt)) string {
f.t.Helper()
o := rowOpt{user: f.bob, item: f.item, age: 2 * time.Minute}
if kind == notify.KindComment || kind == notify.KindReply || kind == notify.KindMention {
o.comment = "default"
}
for _, fn := range opts {
fn(&o)
}
cid := ""
switch o.comment {
case "":
case "default":
cid = f.comment.ID
default:
cid = o.comment
}
id := fmt.Sprintf("n-%d-%d", f.n, pdxSeq.Add(1))
f.exec(`INSERT INTO user_notifications (id, user_id, workspace_id, item_id, comment_id, kind, actor_user_id, dedupe_key, created_at)
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?)`, id, o.user.ID, f.ws.ID, o.item.ID, cid, kind, f.owner.ID, "k-"+id, f.ts(o.age))
if o.agent {
lit := "1"
if f.pg() {
lit = "TRUE"
}
f.exec(`UPDATE user_notifications SET actor_is_agent = `+lit+` WHERE id = ?`, id)
}
return id
}
type rowState struct {
State, Reason, Claim string
Attempts int
SentAt string
}
func (f *pdx) state(id string) rowState {
f.t.Helper()
var r rowState
row := f.srv.store.DB().QueryRow(f.srv.store.D().Rebind(
`SELECT push_state, skip_reason, claim_token, attempts, COALESCE(sent_at, '') FROM user_notifications WHERE id = ?`), id)
must(f.t, row.Scan(&r.State, &r.Reason, &r.Claim, &r.Attempts, &r.SentAt))
return r
}
func (f *pdx) tick() { f.srv.runPushDispatchTick(context.Background()) }
func (f *pdx) settings(mut func(*models.NotificationSettings)) {
f.t.Helper()
set := models.DefaultNotificationSettings()
mut(&set)
must(f.t, f.srv.store.PutNotificationSettings(f.bob.ID, set))
}
// pdClock is the dispatcher's clock (Server.pushDispatch.now) under a test's
// control, so lease and deadline behaviour never depends on wall-clock timing.
type pdClock struct {
mu sync.Mutex
t time.Time
start time.Time
}
func (c *pdClock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *pdClock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.t = c.t.Add(d)
}
// clock installs a fake dispatcher clock reading at.
func (f *pdx) clock(at time.Time) *pdClock {
c := &pdClock{t: at, start: at}
f.srv.pushDispatch.mu.Lock()
f.srv.pushDispatch.now = c.now
f.srv.pushDispatch.mu.Unlock()
return c
}