Add push reminders for sleep, pee, poo and meals
A closed PWA has no timers, so reminders are evaluated on the server: the event log is already there (clients sync on every mutation), and a ticker re-checks each enabled rule once a minute and pushes the ones that are due. Two rule shapes. "sleep" measures from the last sleep-end and fires only while the puppy is awake. "pee"/"poo"/"eat" measure from the newest event of that type and stay quiet while the puppy is asleep — otherwise they nag all night, and suppressing them means an overdue rule instead fires promptly on waking, which is when it actually matters. Sleep state is derived exactly the way currentSleepState() does in app.js, tie-break included, so both sides always agree. Rules read the event's own timestamp rather than when it synced, so a pee logged offline at 03:10 cancels the reminder retroactively. Every push carries a tag, so a repeat replaces the previous notification instead of stacking another one on the lock screen. last_fired is server-owned and not writable by a client, so a stale device can't force a re-fire. Web Push is implemented directly rather than pulled in as a dependency: RFC 8291 encryption in the RFC 8188 aes128gcm coding with an RFC 8292 VAPID token, stdlib only, checked against the RFC 8291 test vector. The key is generated into vapid.json beside the DB or supplied via -vapid-key; without one the server logs a warning, skips registering the routes, and the client hides the UI. Subscriptions a push service reports as 404/410 are dropped. PNG icons are added because iOS gates push on a Home Screen install and rejects SVG for apple-touch-icon, and Android has no notification icon without them.
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const minute = int64(60 * 1000)
|
||||
|
||||
func testScheduler(t *testing.T) *Scheduler {
|
||||
t.Helper()
|
||||
db, err := openDB(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
key, err := newVAPIDKey()
|
||||
if err != nil {
|
||||
t.Fatalf("vapid: %v", err)
|
||||
}
|
||||
return newScheduler(db, newSubscriptionStore(db), newReminderStore(db), key)
|
||||
}
|
||||
|
||||
func addEvent(t *testing.T, sch *Scheduler, userID, typ string, at int64) {
|
||||
t.Helper()
|
||||
_, err := sch.db.Exec(
|
||||
`INSERT INTO events (id, type, at, updated, user_id) VALUES (?, ?, ?, ?, ?)`,
|
||||
typ+"-"+time.Now().Format("150405.000000000")+"-"+string(rune('a'+at%26)), typ, at, at, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert %s: %v", typ, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueSleepRuleFiresOnlyWhileAwake(t *testing.T) {
|
||||
sch := testScheduler(t)
|
||||
now := int64(1_700_000_000_000)
|
||||
rule := Reminder{Kind: "sleep", Enabled: true, IntervalMin: 45}
|
||||
|
||||
// Awake for an hour: past the 45 minute rule.
|
||||
addEvent(t, sch, "u1", "sleep-end", now-60*minute)
|
||||
note, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "awake", since: now - 60*minute})
|
||||
if err != nil {
|
||||
t.Fatalf("due: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected the sleep rule to fire after an hour awake")
|
||||
}
|
||||
if note.Body != "Awake for 1 h" {
|
||||
t.Errorf("body = %q, want %q", note.Body, "Awake for 1 h")
|
||||
}
|
||||
if note.Tag != "reminder:sleep" {
|
||||
t.Errorf("tag = %q, want reminder:sleep", note.Tag)
|
||||
}
|
||||
|
||||
// Same elapsed time, but the puppy is now asleep — the rule is satisfied.
|
||||
if _, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "asleep", since: now - 60*minute}); err != nil || ok {
|
||||
t.Errorf("asleep: fired = %v (err %v), want no fire", ok, err)
|
||||
}
|
||||
|
||||
// Nothing ever logged: no baseline to measure from.
|
||||
if _, ok, err := sch.due("u1", rule, 0, now, sleepState{}); err != nil || ok {
|
||||
t.Errorf("no sleep history: fired = %v (err %v), want no fire", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueEventRuleSuppressedWhileAsleep(t *testing.T) {
|
||||
sch := testScheduler(t)
|
||||
now := int64(1_700_000_000_000)
|
||||
rule := Reminder{Kind: "pee", Enabled: true, IntervalMin: 60}
|
||||
|
||||
addEvent(t, sch, "u1", "pee", now-90*minute)
|
||||
|
||||
// Asleep: silent even though the pee clock is well past the interval. This is
|
||||
// what keeps the rule from nagging all night.
|
||||
if _, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "asleep", since: now - 30*minute}); err != nil || ok {
|
||||
t.Errorf("asleep: fired = %v (err %v), want no fire", ok, err)
|
||||
}
|
||||
|
||||
// Awake with the same history: fires immediately, which is what happens the
|
||||
// moment a sleep-end lands on an already-overdue rule.
|
||||
note, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "awake", since: now - 1*minute})
|
||||
if err != nil {
|
||||
t.Fatalf("due: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected the pee rule to fire while awake and overdue")
|
||||
}
|
||||
if note.Body != "No pee logged for 1 h 30 min" {
|
||||
t.Errorf("body = %q", note.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueEventRuleNeedsAnEventToMeasureFrom(t *testing.T) {
|
||||
sch := testScheduler(t)
|
||||
now := int64(1_700_000_000_000)
|
||||
rule := Reminder{Kind: "poo", Enabled: true, IntervalMin: 180}
|
||||
|
||||
// No poo has ever been logged, so there is no clock running yet.
|
||||
if _, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "awake", since: now}); err != nil || ok {
|
||||
t.Errorf("no history: fired = %v (err %v), want no fire", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueRepeatsAtIntervalNotEveryTick(t *testing.T) {
|
||||
sch := testScheduler(t)
|
||||
now := int64(1_700_000_000_000)
|
||||
rule := Reminder{Kind: "eat", Enabled: true, IntervalMin: 240}
|
||||
awake := sleepState{state: "awake", since: now - 300*minute}
|
||||
|
||||
addEvent(t, sch, "u1", "eat", now-300*minute)
|
||||
|
||||
// Fired a minute ago: stay quiet rather than re-alerting on every tick.
|
||||
if _, ok, err := sch.due("u1", rule, now-1*minute, now, awake); err != nil || ok {
|
||||
t.Errorf("just fired: fired = %v (err %v), want no fire", ok, err)
|
||||
}
|
||||
// A full interval later it repeats.
|
||||
if _, ok, err := sch.due("u1", rule, now-240*minute, now, awake); err != nil || !ok {
|
||||
t.Errorf("interval elapsed: fired = %v (err %v), want fire", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A late-syncing device must be able to cancel a reminder retroactively: the
|
||||
// clock runs on the event's own timestamp, not on when the server heard about it.
|
||||
func TestDueUsesEventTimeNotSyncTime(t *testing.T) {
|
||||
sch := testScheduler(t)
|
||||
now := int64(1_700_000_000_000)
|
||||
rule := Reminder{Kind: "pee", Enabled: true, IntervalMin: 60}
|
||||
awake := sleepState{state: "awake", since: now - 300*minute}
|
||||
|
||||
// Logged 10 minutes ago on a phone that was offline, synced just now.
|
||||
_, err := sch.db.Exec(
|
||||
`INSERT INTO events (id, type, at, updated, user_id) VALUES ('late', 'pee', ?, ?, 'u1')`,
|
||||
now-10*minute, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if _, ok, err := sch.due("u1", rule, 0, now, awake); err != nil || ok {
|
||||
t.Errorf("late-synced pee: fired = %v (err %v), want no fire", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedEventsDoNotResetTheClock(t *testing.T) {
|
||||
sch := testScheduler(t)
|
||||
now := int64(1_700_000_000_000)
|
||||
rule := Reminder{Kind: "pee", Enabled: true, IntervalMin: 60}
|
||||
awake := sleepState{state: "awake", since: now - 300*minute}
|
||||
|
||||
addEvent(t, sch, "u1", "pee", now-90*minute)
|
||||
// A mistyped pee, logged a minute ago and then deleted: its tombstone must
|
||||
// not count as the most recent pee.
|
||||
if _, err := sch.db.Exec(
|
||||
`INSERT INTO events (id, type, at, updated, deleted, user_id) VALUES ('gone', 'pee', ?, ?, 1, 'u1')`,
|
||||
now-1*minute, now); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if _, ok, err := sch.due("u1", rule, 0, now, awake); err != nil || !ok {
|
||||
t.Errorf("deleted pee: fired = %v (err %v), want fire", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSleepStateMatchesLatestBoundary(t *testing.T) {
|
||||
sch := testScheduler(t)
|
||||
now := int64(1_700_000_000_000)
|
||||
|
||||
if s, err := sch.sleepStateFor("u1"); err != nil || s.state != "" {
|
||||
t.Errorf("empty log: state = %q (err %v), want empty", s.state, err)
|
||||
}
|
||||
|
||||
addEvent(t, sch, "u1", "sleep-start", now-120*minute)
|
||||
addEvent(t, sch, "u1", "sleep-end", now-30*minute)
|
||||
s, err := sch.sleepStateFor("u1")
|
||||
if err != nil {
|
||||
t.Fatalf("sleepStateFor: %v", err)
|
||||
}
|
||||
if s.state != "awake" || s.since != now-30*minute {
|
||||
t.Errorf("state = %q since = %d, want awake since %d", s.state, s.since, now-30*minute)
|
||||
}
|
||||
|
||||
// A later sleep-start flips it back.
|
||||
addEvent(t, sch, "u1", "sleep-start", now-5*minute)
|
||||
if s, err := sch.sleepStateFor("u1"); err != nil || s.state != "asleep" {
|
||||
t.Errorf("state = %q (err %v), want asleep", s.state, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemindersStoreDefaultsAndClamping(t *testing.T) {
|
||||
sch := testScheduler(t)
|
||||
|
||||
list, err := sch.rules.get("u1")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if len(list) != len(reminderKinds) {
|
||||
t.Fatalf("got %d rules, want %d", len(list), len(reminderKinds))
|
||||
}
|
||||
for _, r := range list {
|
||||
if r.Enabled {
|
||||
t.Errorf("%s enabled by default", r.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// Out-of-range intervals are clamped, and unknown kinds ignored entirely.
|
||||
if err := sch.rules.put("u1", []Reminder{
|
||||
{Kind: "pee", Enabled: true, IntervalMin: 1},
|
||||
{Kind: "sleep", Enabled: true, IntervalMin: 99999},
|
||||
{Kind: "bark", Enabled: true, IntervalMin: 30},
|
||||
}); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
got := map[string]Reminder{}
|
||||
list, _ = sch.rules.get("u1")
|
||||
for _, r := range list {
|
||||
got[r.Kind] = r
|
||||
}
|
||||
if got["pee"].IntervalMin != 5 {
|
||||
t.Errorf("pee interval = %d, want clamped to 5", got["pee"].IntervalMin)
|
||||
}
|
||||
if got["sleep"].IntervalMin != 24*60 {
|
||||
t.Errorf("sleep interval = %d, want clamped to %d", got["sleep"].IntervalMin, 24*60)
|
||||
}
|
||||
if _, ok := got["bark"]; ok {
|
||||
t.Error("unknown kind 'bark' was stored")
|
||||
}
|
||||
}
|
||||
|
||||
// Enabling a rule must clear last_fired, so switching it on alerts right away
|
||||
// when it is already overdue instead of waiting out a stale interval.
|
||||
func TestEnablingClearsLastFired(t *testing.T) {
|
||||
sch := testScheduler(t)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := sch.rules.put("u1", []Reminder{{Kind: "pee", Enabled: true, IntervalMin: 60}}); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
if _, err := sch.db.Exec(
|
||||
`UPDATE reminders SET last_fired = ? WHERE user_id = 'u1' AND kind = 'pee'`, now); err != nil {
|
||||
t.Fatalf("stamp: %v", err)
|
||||
}
|
||||
// Off, then on again.
|
||||
if err := sch.rules.put("u1", []Reminder{{Kind: "pee", Enabled: false, IntervalMin: 60}}); err != nil {
|
||||
t.Fatalf("put off: %v", err)
|
||||
}
|
||||
if err := sch.rules.put("u1", []Reminder{{Kind: "pee", Enabled: true, IntervalMin: 60}}); err != nil {
|
||||
t.Fatalf("put on: %v", err)
|
||||
}
|
||||
var lastFired int64
|
||||
if err := sch.db.QueryRow(
|
||||
`SELECT last_fired FROM reminders WHERE user_id = 'u1' AND kind = 'pee'`).Scan(&lastFired); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if lastFired != 0 {
|
||||
t.Errorf("last_fired = %d, want 0 after re-enabling", lastFired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanDuration(t *testing.T) {
|
||||
cases := []struct {
|
||||
ms int64
|
||||
want string
|
||||
}{
|
||||
{0, "0 min"},
|
||||
{45 * minute, "45 min"},
|
||||
{60 * minute, "1 h"},
|
||||
{72 * minute, "1 h 12 min"},
|
||||
{-5, "0 min"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := humanDuration(c.ms); got != c.want {
|
||||
t.Errorf("humanDuration(%d) = %q, want %q", c.ms, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidSubscription(t *testing.T) {
|
||||
good := Subscription{Endpoint: "https://push.example.net/x"}
|
||||
good.Keys.P256dh = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"
|
||||
good.Keys.Auth = "BTBZMqHH6r4Tts7J_aSIgg"
|
||||
if err := validSubscription(good); err != nil {
|
||||
t.Fatalf("valid subscription rejected: %v", err)
|
||||
}
|
||||
|
||||
insecure := good
|
||||
insecure.Endpoint = "http://push.example.net/x"
|
||||
if err := validSubscription(insecure); err == nil {
|
||||
t.Error("http endpoint accepted")
|
||||
}
|
||||
|
||||
shortAuth := good
|
||||
shortAuth.Keys.Auth = "AAAA"
|
||||
if err := validSubscription(shortAuth); err == nil {
|
||||
t.Error("short auth secret accepted")
|
||||
}
|
||||
|
||||
badKey := good
|
||||
badKey.Keys.P256dh = "AAAA"
|
||||
if err := validSubscription(badKey); err == nil {
|
||||
t.Error("malformed p256dh accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end through tick: a due rule reaches a push service with the right
|
||||
// headers and an encrypted body, gets stamped so it will not immediately repeat,
|
||||
// and a subscription the service reports as gone is dropped.
|
||||
func TestTickSendsStampsAndPrunes(t *testing.T) {
|
||||
sch := testScheduler(t)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
type received struct {
|
||||
encoding string
|
||||
auth string
|
||||
body int
|
||||
}
|
||||
var got []received
|
||||
gone := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
got = append(got, received{
|
||||
encoding: r.Header.Get("Content-Encoding"),
|
||||
auth: r.Header.Get("Authorization"),
|
||||
body: len(body),
|
||||
})
|
||||
if gone {
|
||||
w.WriteHeader(http.StatusGone)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sub := Subscription{Endpoint: srv.URL + "/push/abc"}
|
||||
sub.Keys.P256dh = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"
|
||||
sub.Keys.Auth = "BTBZMqHH6r4Tts7J_aSIgg"
|
||||
if err := sch.subs.save("u1", sub); err != nil {
|
||||
t.Fatalf("save subscription: %v", err)
|
||||
}
|
||||
if err := sch.rules.put("u1", []Reminder{{Kind: "sleep", Enabled: true, IntervalMin: 45}}); err != nil {
|
||||
t.Fatalf("put rule: %v", err)
|
||||
}
|
||||
addEvent(t, sch, "u1", "sleep-end", now-60*minute)
|
||||
|
||||
if err := sch.tick(now); err != nil {
|
||||
t.Fatalf("tick: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("push service saw %d requests, want 1", len(got))
|
||||
}
|
||||
if got[0].encoding != "aes128gcm" {
|
||||
t.Errorf("Content-Encoding = %q, want aes128gcm", got[0].encoding)
|
||||
}
|
||||
if !strings.HasPrefix(got[0].auth, "vapid t=") {
|
||||
t.Errorf("Authorization = %q, want a vapid token", got[0].auth)
|
||||
}
|
||||
// Header (16 salt + 4 length + 1 + 65 key) plus a non-empty GCM record.
|
||||
if got[0].body <= 86 {
|
||||
t.Errorf("body was %d bytes, want an encrypted record", got[0].body)
|
||||
}
|
||||
|
||||
// Immediately re-ticking must not re-send: last_fired was stamped.
|
||||
if err := sch.tick(now + 1000); err != nil {
|
||||
t.Fatalf("tick: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("re-tick sent again (%d requests total)", len(got))
|
||||
}
|
||||
|
||||
// A full interval later it repeats — and this time the service says the
|
||||
// subscription is gone, so it must be pruned.
|
||||
gone = true
|
||||
if err := sch.tick(now + 46*minute); err != nil {
|
||||
t.Fatalf("tick: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected a repeat after the interval, got %d requests", len(got))
|
||||
}
|
||||
left, err := sch.subs.forUser("u1")
|
||||
if err != nil {
|
||||
t.Fatalf("forUser: %v", err)
|
||||
}
|
||||
if len(left) != 0 {
|
||||
t.Errorf("dead subscription was kept: %d remain", len(left))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user