mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
dbbbefda75
* 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
158 lines
6.2 KiB
Go
158 lines
6.2 KiB
Go
package store
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TASK-3939 part B1: the dispatcher's settle primitives. SQLite, and Postgres
|
|
// under `make test-pg`.
|
|
|
|
// Kills: token-guarding RecordNotificationsSent (the reclaimed leg goes red);
|
|
// dropping 'expired' from its state set (the expired leg goes red); letting it
|
|
// move a skipped row (the skipped leg goes red).
|
|
func TestRecordNotificationsSent_IsByIDForPendingAndExpiredOnly(t *testing.T) {
|
|
t.Parallel()
|
|
f := newNotifyFixture(t)
|
|
u := notifyUser(t, f.s, "rec@example.com")
|
|
settle := at(10 * time.Second)
|
|
|
|
reclaimed := f.seedNotif(t, u.ID, ago(time.Minute))
|
|
xc, _ := f.s.ClaimPendingNotifications("X", 10, settle, at(time.Minute))
|
|
if len(xc.Rows) != 1 {
|
|
t.Fatal("X claimed nothing")
|
|
}
|
|
zc, _ := f.s.ClaimPendingNotifications("Z", 10, settle, at(-time.Hour)) // X's lease lapsed
|
|
if len(zc.Rows) != 1 {
|
|
t.Fatal("Z did not reclaim")
|
|
}
|
|
expired := f.seedNotif(t, u.ID, ago(2*time.Hour))
|
|
f.s.mustExec(t, `UPDATE user_notifications SET push_state = 'expired' WHERE id = ?`, expired)
|
|
skipped := f.seedNotif(t, u.ID, ago(time.Minute))
|
|
f.s.mustExec(t, `UPDATE user_notifications SET push_state = 'skipped', skip_reason = 'not_member' WHERE id = ?`, skipped)
|
|
|
|
n, err := f.s.RecordNotificationsSent([]string{reclaimed, expired, skipped})
|
|
if err != nil || n != 2 {
|
|
t.Fatalf("RecordNotificationsSent = %d, %v; want 2 (the reclaimed and the expired row)", n, err)
|
|
}
|
|
for _, c := range []struct{ id, state string }{{reclaimed, "sent"}, {expired, "sent"}, {skipped, "skipped"}} {
|
|
state, _, tok, attempts := f.notifRow(t, c.id)
|
|
if state != c.state || (c.state == "sent" && tok != "") {
|
|
t.Fatalf("row %s = %s/%q, want %s with the claim cleared", c.id, state, tok, c.state)
|
|
}
|
|
if attempts != 0 {
|
|
t.Fatalf("row %s has %d attempts: a delivery is not a failed attempt", c.id, attempts)
|
|
}
|
|
var sentAt string
|
|
if err := f.s.db.QueryRow(f.s.q(`SELECT COALESCE(sent_at, '') FROM user_notifications WHERE id = ?`), c.id).Scan(&sentAt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if (sentAt != "") != (c.state == "sent") {
|
|
t.Fatalf("row %s (%s) has sent_at %q", c.id, c.state, sentAt)
|
|
}
|
|
}
|
|
// The reclaimer's own token-guarded settle is now a no-op.
|
|
if n, _ := f.s.MarkNotificationsSkipped(zc.Token, []string{reclaimed}, "late"); n != 0 {
|
|
t.Fatal("Z's settle changed a row that was recorded sent")
|
|
}
|
|
if n, err := f.s.RecordNotificationsSent(nil); err != nil || n != 0 {
|
|
t.Fatalf("empty ids: %d %v", n, err)
|
|
}
|
|
}
|
|
|
|
// Kills: UnclaimNotifications burning an attempt; clearing nothing; ignoring
|
|
// the token guard.
|
|
func TestUnclaimNotifications_NoAttemptAndTokenGuarded(t *testing.T) {
|
|
t.Parallel()
|
|
f := newNotifyFixture(t)
|
|
u := notifyUser(t, f.s, "unc@example.com")
|
|
id := f.seedNotif(t, u.ID, ago(time.Minute))
|
|
c, _ := f.s.ClaimPendingNotifications("X", 10, at(10*time.Second), at(time.Minute))
|
|
if len(c.Rows) != 1 {
|
|
t.Fatal("no claim")
|
|
}
|
|
if n, _ := f.s.UnclaimNotifications("X.not-the-nonce", []string{id}); n != 0 {
|
|
t.Fatal("a foreign token unclaimed the row")
|
|
}
|
|
if n, err := f.s.UnclaimNotifications(c.Token, []string{id}); err != nil || n != 1 {
|
|
t.Fatalf("unclaim = %d, %v", n, err)
|
|
}
|
|
state, _, tok, attempts := f.notifRow(t, id)
|
|
if state != "pending" || tok != "" || attempts != 0 {
|
|
t.Fatalf("row = %s/%q/%d, want pending, unclaimed, 0 attempts", state, tok, attempts)
|
|
}
|
|
if again, _ := f.s.ClaimPendingNotifications("Y", 10, at(10*time.Second), at(time.Minute)); len(again.Rows) != 1 {
|
|
t.Fatal("an unclaimed row is claimable at once")
|
|
}
|
|
if _, err := f.s.UnclaimNotifications("", []string{id}); err == nil {
|
|
t.Fatal("empty token accepted")
|
|
}
|
|
}
|
|
|
|
// Kills: HeldNotifications ignoring the token, or the pending state.
|
|
func TestHeldNotifications(t *testing.T) {
|
|
t.Parallel()
|
|
f := newNotifyFixture(t)
|
|
u := notifyUser(t, f.s, "held@example.com")
|
|
a, b, c := f.seedNotif(t, u.ID, ago(time.Minute)), f.seedNotif(t, u.ID, ago(time.Minute)), f.seedNotif(t, u.ID, ago(time.Minute))
|
|
cl, _ := f.s.ClaimPendingNotifications("X", 10, at(10*time.Second), at(time.Minute))
|
|
if len(cl.Rows) != 3 {
|
|
t.Fatal("claimed", len(cl.Rows))
|
|
}
|
|
f.s.mustExec(t, `UPDATE user_notifications SET push_state = 'sent' WHERE id = ?`, b)
|
|
f.s.mustExec(t, `DELETE FROM user_notifications WHERE id = ?`, c)
|
|
got, err := f.s.HeldNotifications(cl.Token, []string{a, b, c})
|
|
if err != nil || !slices.Equal(got, []string{a}) {
|
|
t.Fatalf("held = %v, %v; want only %s", got, err, a)
|
|
}
|
|
if got, _ := f.s.HeldNotifications("X.other", []string{a}); len(got) != 0 {
|
|
t.Fatalf("another token holds %v", got)
|
|
}
|
|
if _, err := f.s.HeldNotifications("", []string{a}); err == nil {
|
|
t.Fatal("empty token accepted")
|
|
}
|
|
}
|
|
|
|
// Kills: putting a row id back into the claim error text (Q8).
|
|
func TestClaimNotificationErrorCarriesNoRowID(t *testing.T) {
|
|
t.Parallel()
|
|
f := newNotifyFixture(t)
|
|
u := notifyUser(t, f.s, "errtxt@example.com")
|
|
id := f.seedNotif(t, u.ID, ago(time.Minute))
|
|
f.s.mustExec(t, `ALTER TABLE user_notifications RENAME TO user_notifications_x`)
|
|
defer f.s.mustExec(t, `ALTER TABLE user_notifications_x RENAME TO user_notifications`)
|
|
_, err := f.s.claimNotificationIDs("X", []string{id}, cutoffTS(at(10*time.Second)), cutoffTS(at(time.Minute)), now())
|
|
if err == nil {
|
|
t.Fatal("expected an error with the table renamed")
|
|
}
|
|
if strings.Contains(err.Error(), id) {
|
|
t.Fatalf("claim error carries the row id: %v", err)
|
|
}
|
|
}
|
|
|
|
// Kills: RecordNotificationsSent or HeldNotifications handling only the first
|
|
// notificationIDChunk ids of a longer list.
|
|
func TestNotificationSettles_CoverMoreIDsThanOneChunk(t *testing.T) {
|
|
t.Parallel()
|
|
f := newNotifyFixture(t)
|
|
u := notifyUser(t, f.s, "chunk@example.com")
|
|
ids := make([]string, notificationIDChunk*2+7)
|
|
for i := range ids {
|
|
ids[i] = f.seedNotif(t, u.ID, ago(time.Minute))
|
|
}
|
|
f.s.mustExec(t, `UPDATE user_notifications SET claim_token = 'L.n', claimed_at = ? WHERE user_id = ?`, ago(time.Second), u.ID)
|
|
held, err := f.s.HeldNotifications("L.n", ids)
|
|
if err != nil || len(held) != len(ids) {
|
|
t.Fatalf("held %d of %d ids, %v", len(held), len(ids), err)
|
|
}
|
|
if n, err := f.s.RecordNotificationsSent(ids); err != nil || n != int64(len(ids)) {
|
|
t.Fatalf("recorded %d of %d ids, %v", n, len(ids), err)
|
|
}
|
|
last := ids[len(ids)-1]
|
|
if state, _, _, _ := f.notifRow(t, last); state != "sent" {
|
|
t.Fatalf("the last id is %s, want sent", state)
|
|
}
|
|
}
|