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)) } }