Files
meshcore-analyzer/cmd/server/notify_eval_test.go
T
efitenandClaude Opus 5.5 b65a75c88d feat(observer-logins): offline mails for an owner's own observers
New event observer.offline.own, open to every role and off by default.
An owner gets the observer offline/online mails for observers bound to
their own login; an admin with observer.offline too gets only the
all-observers mails. Creating a login adds the event to the user's
preferences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:10:00 +01:00

613 lines
27 KiB
Go

package main
import (
"fmt"
"strings"
"testing"
"time"
"github.com/meshcore-analyzer/users"
)
var evNow = time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
const (
evUser = int64(1)
evAdmin = int64(2)
evAdmin2 = int64(3)
)
var (
evPkA = strings.Repeat("a", 64)
evPkB = strings.Repeat("b", 64)
evPkF = strings.Repeat("f", 64)
evPkG = strings.Repeat("e", 64)
)
func evAgo(d time.Duration) string { return evNow.Add(-d).Format(time.RFC3339) }
func evMv(v int) *int { return &v }
// evInput: one user watching evPkA with the default node events, two
// admins without prefs, no stored states.
func evInput() notifyInput {
return notifyInput{
Now: evNow, Health: (&Config{}).GetHealthThresholds(), LowMv: 3300,
Accounts: map[int64]users.User{
evUser: {ID: evUser, Role: users.RoleUser, Status: users.StatusActive},
evAdmin: {ID: evAdmin, Role: users.RoleAdmin, Status: users.StatusActive},
evAdmin2: {ID: evAdmin2, Role: users.RoleAdmin, Status: users.StatusActive},
},
Prefs: []users.NotifyPrefs{{UserID: evUser, Enabled: true, Events: []string{users.NotifyNodeOffline, users.NotifyNodeBattery}}},
Watches: []users.NotifyWatch{{UserID: evUser, Pubkey: evPkA}},
Nodes: map[string]notifyNode{},
Heard: map[string]time.Time{},
}
}
func evStateRow(uid int64, event, subject, state string) users.NotifyState {
return users.NotifyState{NotifyKey: users.NotifyKey{UserID: uid, Event: event, Subject: subject}, State: state, ChangedAt: evNow.Add(-time.Hour)}
}
func evState(res notifyResult, uid int64, event, subject string) (string, bool) {
for _, s := range res.States {
if s.UserID == uid && s.Event == event && s.Subject == subject {
return s.State, true
}
}
return "", false
}
func TestEvaluateFirstEvaluationStoresWithoutChanges(t *testing.T) {
in := evInput()
in.Watches = append(in.Watches, users.NotifyWatch{UserID: evUser, Pubkey: evPkB})
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour)}
in.Nodes[evPkB] = notifyNode{Pubkey: evPkB, Name: "Bravo", Role: "companion", LastSeen: evAgo(time.Hour), BatteryMv: evMv(3100)}
res := evaluateNotifications(in)
if len(res.Changes) != 0 {
t.Fatalf("changes on the first evaluation: %+v", res.Changes)
}
for _, c := range []struct{ event, subject, want string }{
{users.NotifyNodeOffline, evPkA, users.NotifyBad},
{users.NotifyNodeOffline, evPkB, users.NotifyGood},
{users.NotifyNodeBattery, evPkB, users.NotifyBad},
} {
if got, ok := evState(res, evUser, c.event, c.subject); !ok || got != c.want {
t.Errorf("%s %s = %q (stored %v); want %q", c.event, c.subject[:4], got, ok, c.want)
}
}
if _, ok := evState(res, evUser, users.NotifyNodeBattery, evPkA); ok {
t.Error("battery state stored for a node without telemetry")
}
for _, s := range res.States {
if !s.ChangedAt.Equal(evNow) {
t.Errorf("ChangedAt = %v; want the input's Now", s.ChangedAt)
}
}
}
func TestEvaluateOfflineAndBack(t *testing.T) {
in := evInput()
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(24 * time.Hour)}
in.States = []users.NotifyState{evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood)}
res := evaluateNotifications(in)
ch := res.Changes[evUser]
if len(ch) != 1 || ch[0].Event != users.NotifyNodeOffline || ch[0].Subject != evPkA || ch[0].To != users.NotifyBad ||
ch[0].Name != "Alpha" || !ch[0].At.Equal(evNow) {
t.Fatalf("changes = %+v; want one offline change for Alpha", ch)
}
// One minute inside the 24 h companion window: still online, nothing to write.
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(24*time.Hour - time.Minute)}
if res := evaluateNotifications(in); len(res.Changes) != 0 || len(res.States) != 0 {
t.Fatalf("unchanged online node produced %+v", res)
}
// Stored bad, advert 30 h old, but the packet store heard it an hour ago.
in.States = []users.NotifyState{evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyBad)}
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(30 * time.Hour)}
in.Heard[evPkA] = evNow.Add(-time.Hour)
if ch := evaluateNotifications(in).Changes[evUser]; len(ch) != 1 || ch[0].To != users.NotifyGood {
t.Fatalf("changes = %+v; want one back-online change", ch)
}
}
// Liveness is last_seen or the store's certain last heard (#2260): a
// repeater whose advert is days old but that certainly relayed an hour ago is
// online; one with nothing certain inside the silent window is offline. There
// is no separate relay input any more: last_relayed also counts 1-byte prefix
// matches of other repeaters and kept a silent repeater online for days.
func TestEvaluateCertainHeard(t *testing.T) {
cases := []struct {
name, role, lastSeen string
heard time.Duration
want string
}{
{"repeater certainly relaying", "repeater", evAgo(80 * time.Hour), time.Hour, users.NotifyGood},
{"room certainly relaying, role case", "Room", evAgo(80 * time.Hour), time.Hour, users.NotifyGood},
{"repeater silent exactly 72 h", "repeater", evAgo(72 * time.Hour), 0, users.NotifyBad},
{"repeater 71 h", "repeater", evAgo(71 * time.Hour), 0, users.NotifyGood},
{"repeater last certain packet 73 h ago", "repeater", evAgo(80 * time.Hour), 73 * time.Hour, users.NotifyBad},
{"no timestamp at all", "repeater", "", 0, users.NotifyBad},
}
for _, c := range cases {
in := evInput()
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Role: c.role, LastSeen: c.lastSeen}
if c.heard > 0 {
in.Heard[evPkA] = evNow.Add(-c.heard)
}
if got, _ := evState(evaluateNotifications(in), evUser, users.NotifyNodeOffline, evPkA); got != c.want {
t.Errorf("%s: %q; want %q", c.name, got, c.want)
}
}
}
func TestEvaluateBatteryHysteresis(t *testing.T) {
cases := []struct {
prev string
mv int
want string
change bool
}{
{"", 3299, users.NotifyBad, false},
{"", 3300, users.NotifyGood, false},
{"", 3350, users.NotifyGood, false},
{users.NotifyGood, 3299, users.NotifyBad, true},
{users.NotifyGood, 3300, users.NotifyGood, false},
{users.NotifyBad, 3399, users.NotifyBad, false},
{users.NotifyBad, 3400, users.NotifyGood, true},
}
for _, c := range cases {
in := evInput()
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(time.Hour), BatteryMv: evMv(c.mv)}
in.States = []users.NotifyState{evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood)}
if c.prev != "" {
in.States = append(in.States, evStateRow(evUser, users.NotifyNodeBattery, evPkA, c.prev))
}
res := evaluateNotifications(in)
got, stored := evState(res, evUser, users.NotifyNodeBattery, evPkA)
if !stored {
got = c.prev
}
ch := res.Changes[evUser]
if got != c.want || (len(ch) == 1) != c.change {
t.Errorf("prev %q, %d mV: state %q, changes %+v; want %q, change %v", c.prev, c.mv, got, ch, c.want, c.change)
continue
}
if c.change && (ch[0].Event != users.NotifyNodeBattery || ch[0].BatteryMv == nil || *ch[0].BatteryMv != c.mv) {
t.Errorf("prev %q, %d mV: change = %+v", c.prev, c.mv, ch[0])
}
}
}
func TestEvaluateAbsentNodeIsOfflineOnce(t *testing.T) {
in := evInput() // evPkA is watched but not in the analyzer database
in.States = []users.NotifyState{
evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood),
evStateRow(evUser, users.NotifyNodeBattery, evPkA, users.NotifyBad),
}
ch := evaluateNotifications(in).Changes[evUser]
if len(ch) != 1 || ch[0].Event != users.NotifyNodeOffline || ch[0].To != users.NotifyBad || ch[0].Name != evPkA[:8] {
t.Fatalf("changes = %+v; want one offline change named by the key prefix", ch)
}
in.States[0] = evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyBad)
if res := evaluateNotifications(in); len(res.Changes) != 0 || len(res.States) != 0 {
t.Fatalf("absent node evaluated again: %+v", res)
}
}
func TestEvaluateForeignOncePerAdmin(t *testing.T) {
in := evInput()
in.Prefs[0].Events = append(in.Prefs[0].Events, users.NotifyForeignNew) // a non-admin with an admin event
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyForeignNew}})
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true, LastSeen: evAgo(time.Hour)}
res := evaluateNotifications(in)
if len(res.Changes[evAdmin]) != 0 {
t.Fatalf("first evaluation mailed: %+v", res.Changes[evAdmin])
}
if s, ok := evState(res, evAdmin, users.NotifyForeignNew, foreignBaselineSubject); !ok || s != users.NotifyGood {
t.Fatalf("baseline row = %q, %v", s, ok)
}
if s, ok := evState(res, evAdmin, users.NotifyForeignNew, evPkF); !ok || s != users.NotifyTold {
t.Fatalf("existing foreign node = %q, %v; want told", s, ok)
}
if _, ok := evState(res, evUser, users.NotifyForeignNew, evPkF); ok {
t.Fatal("non-admin evaluated for foreign.new")
}
// Next tick: a second foreign node appears; a second admin opts in now.
in.States = res.States
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin2, Enabled: true, Events: []string{users.NotifyForeignNew}})
in.Nodes[evPkG] = notifyNode{Pubkey: evPkG, Name: "Golf", Foreign: true}
res = evaluateNotifications(in)
ch := res.Changes[evAdmin]
if len(ch) != 1 || ch[0].Subject != evPkG || ch[0].To != users.NotifyTold || ch[0].Name != "Golf" {
t.Fatalf("admin changes = %+v; want Golf once", ch)
}
if len(res.Changes[evAdmin2]) != 0 {
t.Fatalf("second admin's first evaluation mailed: %+v", res.Changes[evAdmin2])
}
in.States = append(in.States, res.States...)
if res := evaluateNotifications(in); len(res.Changes) != 0 {
t.Fatalf("foreign nodes mailed twice: %+v", res.Changes)
}
}
func TestEvaluateObserverOfflineAndBack(t *testing.T) {
cases := []struct {
prev string
age time.Duration
want string
change bool
}{
{"", 2 * time.Hour, users.NotifyGood, false},
{"", 25 * time.Hour, users.NotifyBad, false},
{users.NotifyGood, 24 * time.Hour, users.NotifyGood, false},
{users.NotifyGood, 24*time.Hour + time.Minute, users.NotifyBad, true},
{users.NotifyBad, 2 * time.Hour, users.NotifyBad, false},
{users.NotifyBad, 60 * time.Minute, users.NotifyGood, true},
}
for _, c := range cases {
in := evInput()
in.Prefs = []users.NotifyPrefs{{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyObserverOffline}}}
in.Watches = nil
in.Observers = []notifyObserver{{ID: "OBS1", Name: "Roof", LastSeen: evAgo(c.age)}}
if c.prev != "" {
in.States = []users.NotifyState{evStateRow(evAdmin, users.NotifyObserverOffline, "OBS1", c.prev)}
}
res := evaluateNotifications(in)
got, stored := evState(res, evAdmin, users.NotifyObserverOffline, "OBS1")
if !stored {
got = c.prev
}
ch := res.Changes[evAdmin]
if got != c.want || (len(ch) == 1) != c.change || (c.change && ch[0].Name != "Roof") {
t.Errorf("prev %q, age %v: state %q, changes %+v; want %q, change %v", c.prev, c.age, got, ch, c.want, c.change)
}
}
in := evInput()
in.Prefs = []users.NotifyPrefs{{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyObserverOffline}}}
in.Observers = []notifyObserver{{ID: "OBS2"}}
if s, _ := evState(evaluateNotifications(in), evAdmin, users.NotifyObserverOffline, "OBS2"); s != users.NotifyBad {
t.Fatalf("observer without last_seen = %q; want bad", s)
}
}
func TestEvaluateOnlyChosenEventsAndKnownAccounts(t *testing.T) {
in := evInput()
in.Prefs[0].Events = []string{users.NotifyNodeBattery, users.NotifyObserverOffline} // observer.offline: the user is no admin
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Role: "companion", LastSeen: evAgo(48 * time.Hour), BatteryMv: evMv(4000)}
in.Observers = []notifyObserver{{ID: "OBS1", LastSeen: evAgo(time.Hour)}}
res := evaluateNotifications(in)
if len(res.States) != 1 || res.States[0].Event != users.NotifyNodeBattery {
t.Fatalf("states = %+v; want only node.battery", res.States)
}
in.Accounts = map[int64]users.User{} // the user was deleted after the read
if res := evaluateNotifications(in); len(res.States) != 0 {
t.Fatalf("deleted user evaluated: %+v", res.States)
}
}
func TestEvaluateChangesAreOrderedPerUser(t *testing.T) {
in := evInput()
in.Watches = []users.NotifyWatch{{UserID: evUser, Pubkey: evPkB}, {UserID: evUser, Pubkey: evPkA}}
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour), BatteryMv: evMv(3000)}
in.Nodes[evPkB] = notifyNode{Pubkey: evPkB, Name: "Bravo", Role: "companion", LastSeen: evAgo(48 * time.Hour)}
in.States = []users.NotifyState{
evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood),
evStateRow(evUser, users.NotifyNodeOffline, evPkB, users.NotifyGood),
evStateRow(evUser, users.NotifyNodeBattery, evPkA, users.NotifyGood),
}
var got []string
for _, c := range evaluateNotifications(in).Changes[evUser] {
got = append(got, c.Event+" "+c.Name)
}
if want := "node.offline Alpha|node.offline Bravo|node.battery Alpha"; strings.Join(got, "|") != want {
t.Fatalf("order = %q; want %q", strings.Join(got, "|"), want)
}
}
// 100 users x 50 watches over 2,000 nodes: the spec's Performance figure
// (5,000 state rows per event type). Run with -bench and quote the result
// in the PR.
func BenchmarkEvaluateNotifications(b *testing.B) {
in := notifyInput{Now: evNow, Health: (&Config{}).GetHealthThresholds(), LowMv: 3300,
Accounts: map[int64]users.User{}, Nodes: map[string]notifyNode{}, Heard: map[string]time.Time{}}
for i := 0; i < 2000; i++ {
pk := fmt.Sprintf("%064x", i)
in.Nodes[pk] = notifyNode{Pubkey: pk, Role: "repeater", LastSeen: evAgo(time.Duration(i) * time.Minute), BatteryMv: evMv(3200 + i%300)}
}
for u := int64(1); u <= 100; u++ {
in.Accounts[u] = users.User{ID: u, Role: users.RoleUser, Status: users.StatusActive}
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: u, Enabled: true, Events: users.NodeNotifyEvents})
for w := 0; w < 50; w++ {
in.Watches = append(in.Watches, users.NotifyWatch{UserID: u, Pubkey: fmt.Sprintf("%064x", (int(u)*37+w*11)%2000)})
}
}
in.States = evaluateNotifications(in).States
b.ResetTimer()
for i := 0; i < b.N; i++ {
evaluateNotifications(in)
}
}
// P7: an admin who loses the admin role loses their admin-event states, like
// opting out of those events, so a re-promotion starts a fresh baseline.
func TestEvaluateDemotedAdminDropsAdminEventStates(t *testing.T) {
in := evInput()
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: true,
Events: []string{users.NotifyNodeOffline, users.NotifyForeignNew, users.NotifyObserverOffline}})
in.Watches = append(in.Watches, users.NotifyWatch{UserID: evAdmin, Pubkey: evPkA})
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(time.Hour)}
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true}
in.Observers = []notifyObserver{{ID: "OBS1", Name: "Roof", LastSeen: evAgo(time.Hour)}}
in.States = evaluateNotifications(in).States
if s, ok := evState(notifyResult{States: in.States}, evAdmin, users.NotifyForeignNew, foreignBaselineSubject); !ok || s != users.NotifyGood {
t.Fatalf("baseline row = %q, %v", s, ok)
}
// Demoted: the admin-event rows are dropped, the node rows stay, nothing mailed.
admin := in.Accounts[evAdmin]
admin.Role = users.RoleUser
in.Accounts[evAdmin] = admin
in.Nodes[evPkG] = notifyNode{Pubkey: evPkG, Name: "Golf", Foreign: true}
in.Observers[0].LastSeen = evAgo(48 * time.Hour)
res := evaluateNotifications(in)
if len(res.Changes) != 0 || len(res.States) != 0 {
t.Fatalf("demoted admin evaluated: %+v", res)
}
want := map[users.NotifyKey]bool{
{UserID: evAdmin, Event: users.NotifyForeignNew, Subject: foreignBaselineSubject}: true,
{UserID: evAdmin, Event: users.NotifyForeignNew, Subject: evPkF}: true,
{UserID: evAdmin, Event: users.NotifyObserverOffline, Subject: "OBS1"}: true,
}
if len(res.Drop) != len(want) {
t.Fatalf("drop = %+v; want %d admin-event keys", res.Drop, len(want))
}
for _, k := range res.Drop {
if !want[k] {
t.Errorf("dropped %+v", k)
}
}
dropped := map[users.NotifyKey]bool{}
for _, k := range res.Drop {
dropped[k] = true
}
var kept []users.NotifyState
for _, s := range in.States {
if !dropped[s.NotifyKey] {
kept = append(kept, s)
}
}
in.States = kept
if res := evaluateNotifications(in); len(res.Drop) != 0 {
t.Fatalf("dropped again: %+v", res.Drop)
}
// Re-promoted: Golf appeared and the observer went offline meanwhile; the
// first evaluation stores a new baseline and mails nothing.
admin.Role = users.RoleAdmin
in.Accounts[evAdmin] = admin
res = evaluateNotifications(in)
if len(res.Changes) != 0 || len(res.Drop) != 0 {
t.Fatalf("re-promoted admin: changes %+v, drop %+v", res.Changes, res.Drop)
}
for _, c := range []struct{ event, subject, want string }{
{users.NotifyForeignNew, foreignBaselineSubject, users.NotifyGood},
{users.NotifyForeignNew, evPkG, users.NotifyTold},
{users.NotifyObserverOffline, "OBS1", users.NotifyBad},
} {
if got, ok := evState(res, evAdmin, c.event, c.subject); !ok || got != c.want {
t.Errorf("%s %s = %q (stored %v); want %q", c.event, c.subject, got, ok, c.want)
}
}
}
// A user who watches nodes but has no preferences row (never opened the
// settings, or created before the notification schema) gets the defaults:
// enabled, node events on, admin events off.
func TestEvaluateWatcherWithoutPrefsGetsDefaults(t *testing.T) {
in := evInput()
in.Prefs = nil
in.Watches = []users.NotifyWatch{{UserID: evAdmin, Pubkey: evPkA}}
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour), BatteryMv: evMv(3000)}
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true}
in.Observers = []notifyObserver{{ID: "OBS1", LastSeen: evAgo(time.Hour)}}
in.States = []users.NotifyState{
evStateRow(evAdmin, users.NotifyNodeOffline, evPkA, users.NotifyGood),
evStateRow(evAdmin, users.NotifyNodeBattery, evPkA, users.NotifyGood),
}
res := evaluateNotifications(in)
var got []string
for _, c := range res.Changes[evAdmin] {
got = append(got, c.Event+" "+c.To)
}
if want := "node.offline bad|node.battery bad"; strings.Join(got, "|") != want {
t.Fatalf("changes = %q; want %q", strings.Join(got, "|"), want)
}
for _, s := range res.States {
if users.IsAdminNotifyEvent(s.Event) {
t.Errorf("admin event evaluated without opting in: %+v", s)
}
}
in.Accounts = map[int64]users.User{}
if res := evaluateNotifications(in); len(res.States) != 0 {
t.Fatalf("watcher without an account evaluated: %+v", res.States)
}
}
// A disabled user keeps being evaluated: states follow the node, nothing is
// mailed, and re-enabling does not mail what changed while off.
func TestEvaluateDisabledUserUpdatesStatesSilently(t *testing.T) {
in := evInput()
in.Prefs[0].Enabled = false
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: false, Events: []string{users.NotifyForeignNew}})
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(time.Hour)}
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true}
in.States = evaluateNotifications(in).States
// While off: Alpha goes offline and Golf appears.
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour)}
in.Nodes[evPkG] = notifyNode{Pubkey: evPkG, Name: "Golf", Foreign: true}
res := evaluateNotifications(in)
if len(res.Changes) != 0 {
t.Fatalf("disabled users mailed: %+v", res.Changes)
}
if s, ok := evState(res, evUser, users.NotifyNodeOffline, evPkA); !ok || s != users.NotifyBad {
t.Fatalf("disabled user's offline state = %q, %v; want bad stored", s, ok)
}
if s, ok := evState(res, evAdmin, users.NotifyForeignNew, evPkG); !ok || s != users.NotifyTold {
t.Fatalf("disabled admin's foreign state = %q, %v; want told stored", s, ok)
}
in.States = append(in.States, res.States...)
// Re-enabled: the next evaluation has nothing to send.
in.Prefs[0].Enabled = true
in.Prefs[1].Enabled = true
if res := evaluateNotifications(in); len(res.Changes) != 0 || len(res.States) != 0 {
t.Fatalf("re-enabling mailed old changes: %+v", res)
}
}
// While ingest is stale nobody can tell a silent node from a silent
// MQTT feed: offline comparisons pause with their states unchanged;
// battery and foreign nodes go on.
func TestEvaluateIngestStalePausesOfflineChecks(t *testing.T) {
in := evInput()
in.IngestStale = true
in.Watches = append(in.Watches, users.NotifyWatch{UserID: evUser, Pubkey: evPkB})
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyForeignNew, users.NotifyObserverOffline}})
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour), BatteryMv: evMv(3100)}
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true}
in.Observers = []notifyObserver{{ID: "OBS1", Name: "Roof", LastSeen: evAgo(48 * time.Hour)}}
in.States = []users.NotifyState{
evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood),
evStateRow(evUser, users.NotifyNodeBattery, evPkA, users.NotifyGood),
evStateRow(evAdmin, users.NotifyObserverOffline, "OBS1", users.NotifyGood),
evStateRow(evAdmin, users.NotifyForeignNew, foreignBaselineSubject, users.NotifyGood),
}
res := evaluateNotifications(in)
for _, k := range []struct {
uid int64
event, subject string
}{{evUser, users.NotifyNodeOffline, evPkA}, {evUser, users.NotifyNodeOffline, evPkB}, {evAdmin, users.NotifyObserverOffline, "OBS1"}} {
if s, ok := evState(res, k.uid, k.event, k.subject); ok {
t.Errorf("%s %s written as %q while ingest is stale", k.event, k.subject, s)
}
}
if ch := res.Changes[evUser]; len(ch) != 1 || ch[0].Event != users.NotifyNodeBattery || ch[0].To != users.NotifyBad {
t.Fatalf("user changes = %+v; want only the battery", ch)
}
if ch := res.Changes[evAdmin]; len(ch) != 1 || ch[0].Event != users.NotifyForeignNew || ch[0].Subject != evPkF {
t.Fatalf("admin changes = %+v; want only the foreign node", ch)
}
in.IngestStale = false
res = evaluateNotifications(in)
if s, _ := evState(res, evUser, users.NotifyNodeOffline, evPkA); s != users.NotifyBad {
t.Fatalf("offline check after ingest resumed = %q; want bad", s)
}
if s, _ := evState(res, evAdmin, users.NotifyObserverOffline, "OBS1"); s != users.NotifyBad {
t.Fatalf("observer check after ingest resumed = %q; want bad", s)
}
}
// After a feed outage, evidence older than the recovery counts as heard at
// the recovery: no node or observer goes offline until a full silent window
// has passed since then. A state that is already bad is not lifted by it.
func TestEvaluateGraceAfterIngestResumes(t *testing.T) {
in := evInput()
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyObserverOffline}})
in.Watches = append(in.Watches, users.NotifyWatch{UserID: evUser, Pubkey: evPkB})
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(25 * time.Hour)}
in.Nodes[evPkB] = notifyNode{Pubkey: evPkB, Name: "Bravo", Role: "companion", LastSeen: evAgo(48 * time.Hour)}
in.Observers = []notifyObserver{{ID: "OBS1", Name: "Roof", LastSeen: evAgo(25 * time.Hour)}, {ID: "OBS2", Name: "Mast", LastSeen: evAgo(48 * time.Hour)}}
in.States = []users.NotifyState{
evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood),
evStateRow(evUser, users.NotifyNodeOffline, evPkB, users.NotifyBad),
evStateRow(evAdmin, users.NotifyObserverOffline, "OBS1", users.NotifyGood),
evStateRow(evAdmin, users.NotifyObserverOffline, "OBS2", users.NotifyBad),
}
in.IngestResumedAt = evNow.Add(-10 * time.Minute)
if res := evaluateNotifications(in); len(res.Changes) != 0 || len(res.States) != 0 {
t.Fatalf("10 minutes after recovery: changes %+v, states %+v; want none", res.Changes, res.States)
}
// One silent window (24 h companions, observerStaleMinutes 1440) plus a
// minute after recovery, still no new evidence: offline.
in.Now = in.IngestResumedAt.Add(24*time.Hour + time.Minute)
res := evaluateNotifications(in)
if ch := res.Changes[evUser]; len(ch) != 1 || ch[0].Subject != evPkA || ch[0].To != users.NotifyBad {
t.Fatalf("node changes after the window = %+v; want Alpha offline", ch)
}
if ch := res.Changes[evAdmin]; len(ch) != 1 || ch[0].Subject != "OBS1" || ch[0].To != users.NotifyBad {
t.Fatalf("observer changes after the window = %+v; want Roof offline", ch)
}
// A node heard after the recovery behaves as without the grace.
in.Now = evNow
in.Nodes[evPkB] = notifyNode{Pubkey: evPkB, Name: "Bravo", Role: "companion", LastSeen: evAgo(5 * time.Minute)}
in.Observers[1].LastSeen = evAgo(5 * time.Minute)
res = evaluateNotifications(in)
if ch := res.Changes[evUser]; len(ch) != 1 || ch[0].Subject != evPkB || ch[0].To != users.NotifyGood {
t.Fatalf("node heard after recovery = %+v; want Bravo back online", ch)
}
if ch := res.Changes[evAdmin]; len(ch) != 1 || ch[0].Subject != "OBS2" || ch[0].To != users.NotifyGood {
t.Fatalf("observer heard after recovery = %+v; want Mast back online", ch)
}
}
func ownInput(events ...string) notifyInput {
in := evInput()
in.Watches = nil
in.Prefs = []users.NotifyPrefs{{UserID: evUser, Enabled: true, Events: events}}
in.Observers = []notifyObserver{
{ID: "obs1", Name: "Roof", LastSeen: evAgo(25 * time.Hour)},
{ID: "OBS2", Name: "Other", LastSeen: evAgo(25 * time.Hour)},
}
in.ObserverOwners = map[string]int64{"OBS1": evUser, "OBS2": evAdmin2}
in.States = []users.NotifyState{
evStateRow(evUser, users.NotifyObserverOfflineOwn, "obs1", users.NotifyGood),
evStateRow(evUser, users.NotifyObserverOfflineOwn, "OBS2", users.NotifyGood),
}
return in
}
func TestEvalOwnObserverOffline(t *testing.T) {
res := evaluateNotifications(ownInput(users.NotifyObserverOfflineOwn))
ch := res.Changes[evUser]
if len(ch) != 1 || ch[0].Subject != "obs1" || ch[0].Event != users.NotifyObserverOfflineOwn || ch[0].To != users.NotifyBad {
t.Fatalf("changes = %+v; want one bad change for obs1", ch)
}
if _, ok := evState(res, evUser, users.NotifyObserverOfflineOwn, "OBS2"); ok {
t.Fatal("someone else's observer was evaluated")
}
}
func TestEvalAdminWithBothGetsOneMail(t *testing.T) {
in := ownInput(users.NotifyObserverOffline, users.NotifyObserverOfflineOwn)
in.Prefs[0].UserID = evAdmin
in.ObserverOwners = map[string]int64{"OBS1": evAdmin}
in.States = []users.NotifyState{evStateRow(evAdmin, users.NotifyObserverOffline, "obs1", users.NotifyGood)}
res := evaluateNotifications(in)
n := 0
for _, c := range res.Changes[evAdmin] {
if c.Subject == "obs1" {
n++
if c.Event != users.NotifyObserverOffline {
t.Errorf("event = %q; want observer.offline", c.Event)
}
}
}
if n != 1 {
t.Fatalf("%d changes for obs1, want 1: %+v", n, res.Changes[evAdmin])
}
}
func TestEvalOwnObserverIngestStaleNoChange(t *testing.T) {
in := ownInput(users.NotifyObserverOfflineOwn)
in.IngestStale = true
res := evaluateNotifications(in)
if len(res.Changes[evUser]) != 0 {
t.Fatalf("changes during stale ingest: %+v", res.Changes[evUser])
}
}