package store import ( "context" "database/sql" "encoding/json" "errors" "path/filepath" "testing" "time" ) func newTestStore(t *testing.T) *Store { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "maven_test.db") s, err := Open(context.Background(), path) if err != nil { t.Fatalf("Open: %v", err) } t.Cleanup(func() { _ = s.Close() }) return s } func TestWriteAndLatestFact(t *testing.T) { s := newTestStore(t) ctx := context.Background() // ts is stored as unix millis; round to ms to match the roundtrip. now := time.Now().UTC().Truncate(time.Millisecond) if _, err := s.SetValue(ctx, KindSelf, "water", "tap:water", map[string]int{"ml": 250}, now); err != nil { t.Fatalf("SetValue: %v", err) } f, err := s.LatestFact(ctx, "water") if err != nil { t.Fatalf("LatestFact: %v", err) } if f.Key != "water" || f.Source != "tap:water" || f.Confidence != 1.0 { t.Fatalf("got %+v", f) } var v map[string]int if err := json.Unmarshal([]byte(f.Value), &v); err != nil || v["ml"] != 250 { t.Fatalf("value roundtrip: %v (%s)", err, f.Value) } if !f.Ts.Equal(now) { t.Fatalf("ts roundtrip: want %s got %s", now, f.Ts) } } func TestAppendOnlySupersedeNotOverwrite(t *testing.T) { // Two facts for the same key: LatestFact returns the newer one, the older // row is still there (append-only audit trail). s := newTestStore(t) ctx := context.Background() t1 := time.Now().UTC().Add(-5 * time.Minute) t2 := time.Now().UTC() if _, err := s.SetValue(ctx, KindSelf, "meal", "tap:meal", "pasta", t1); err != nil { t.Fatal(err) } if _, err := s.SetValue(ctx, KindSelf, "meal", "tap:meal", "salad", t2); err != nil { t.Fatal(err) } f, err := s.LatestFact(ctx, "meal") if err != nil { t.Fatal(err) } if f.Value != `"salad"` { t.Fatalf("latest value: want salad, got %s", f.Value) } // audit trail still has both rows var n int if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM facts WHERE key = 'meal'").Scan(&n); err != nil { t.Fatal(err) } if n != 2 { t.Fatalf("append-only: want 2 rows, got %d", n) } } func TestCorrectValueVoidsAndSupersedes(t *testing.T) { // User corrects a bad fact → latest row voids the previous; LatestFact // now returns the corrected one; voids_id points back at the old row. s := newTestStore(t) ctx := context.Background() old := time.Now().UTC().Add(-2 * time.Minute) if _, err := s.SetValue(ctx, KindSelf, "sleep", "tap:sleep", "8h", old); err != nil { t.Fatal(err) } now := time.Now().UTC() newID, err := s.CorrectValue(ctx, "sleep", "feedback", "6h", now) if err != nil { t.Fatalf("CorrectValue: %v", err) } f, err := s.LatestFact(ctx, "sleep") if err != nil { t.Fatal(err) } if f.ID != newID { t.Fatalf("latest should be corrected row: want id=%d got=%d", newID, f.ID) } if !f.VoidsID.Valid || f.VoidsID.Int64 == 0 { t.Fatalf("corrected row should void the old one: %+v", f.VoidsID) } // old row should NOT come back from LatestFact if f.Value != `"6h"` { t.Fatalf("value: want 6h got %s", f.Value) } // audit trail: 2 rows; one of them voids the other var voidedCount int if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM facts WHERE key='sleep' AND voids_id IS NOT NULL").Scan(&voidedCount); err != nil { t.Fatal(err) } if voidedCount != 1 { t.Fatalf("exactly one voiding row, got %d", voidedCount) } } func TestSinceNoFactReturnsErrNoFact(t *testing.T) { // silence on no-data = "shuts up when uncertain" s := newTestStore(t) ctx := context.Background() if _, err := s.Since(ctx, "never_observed", time.Now().UTC()); !errors.Is(err, ErrNoFact) { t.Fatalf("Since on missing key: want ErrNoFact, got %v", err) } } func TestConfidenceBounds(t *testing.T) { s := newTestStore(t) ctx := context.Background() for _, c := range []float64{0.0, -0.1, 1.5} { if _, err := s.WriteFact(ctx, time.Now().UTC(), KindSelf, "x", "v", "tap", c, sql.NullInt64{}); !errors.Is(err, ErrConfidence) { t.Fatalf("confidence %f: want ErrConfidence, got %v", c, err) } } } func TestProvenanceScopedLookup(t *testing.T) { // A compensating fact from a non-authoritative source should NOT override the // authoritative one when the rule uses LatestFactBySource. s := newTestStore(t) ctx := context.Background() now := time.Now().UTC() if _, err := s.SetValue(ctx, KindEnv, "service_nginx", "poll:healthcheck", "down", now); err != nil { t.Fatal(err) } if _, err := s.SetValue(ctx, KindEnv, "service_nginx", "ambient", "down", now.Add(time.Second)); err != nil { t.Fatal(err) } // unscoped latest = ambient (newer) if f, _ := s.LatestFact(ctx, "service_nginx"); f.Source != "ambient" { t.Fatalf("LatestFact: want ambient, got %s", f.Source) } // source-scoped = poll:healthcheck f, err := s.LatestFactBySource(ctx, "service_nginx", "poll:healthcheck") if err != nil || f.Source != "poll:healthcheck" { t.Fatalf("LatestFactBySource: want poll:healthcheck, got %+v / %v", f, err) } // missing source → ErrNoFact (a compromised poller can't forge a trigger) if _, err := s.LatestFactBySource(ctx, "service_nginx", "poll:bogus"); !errors.Is(err, ErrNoFact) { t.Fatalf("bogus source: want ErrNoFact, got %v", err) } } func TestRemindersRelativeResolvedAtCapture(t *testing.T) { s := newTestStore(t) ctx := context.Background() // capture path (router) converts "in 4h" → absolute. store just takes fire_ts. fire := time.Now().UTC().Add(4 * time.Hour) id, err := s.CreateReminder(ctx, fire, `{"text":"wake me"}`) if err != nil { t.Fatal(err) } // not due yet if due, err := s.DueReminders(ctx, time.Now().UTC()); err != nil || len(due) != 0 { t.Fatalf("before fire: want 0 due, got %d (%v)", len(due), err) } // due once past fire_ts if due, err := s.DueReminders(ctx, fire.Add(time.Second)); err != nil || len(due) != 1 || due[0].ID != id { t.Fatalf("after fire: want 1 due (%d), got %d (%v)", id, len(due), err) } // mark fired → not due again (fires once) if err := s.MarkReminder(ctx, id, "fired"); err != nil { t.Fatalf("MarkReminder: %v", err) } if due, err := s.DueReminders(ctx, fire.Add(2*time.Second)); err != nil || len(due) != 0 { t.Fatalf("after fired: want 0 due, got %d", len(due)) } // can't fire again if err := s.MarkReminder(ctx, id, "fired"); !errors.Is(err, ErrReminderState) { t.Fatalf("re-fire: want ErrReminderState, got %v", err) } } func TestNudgeOnceAndFeedbackOutcomes(t *testing.T) { s := newTestStore(t) ctx := context.Background() now := time.Now().UTC() id, err := s.RecordNudge(ctx, "water", "voice", "drink some water", now) if err != nil { t.Fatal(err) } // pending state: not yet in outcomes if os, _ := s.RecentOutcomes(ctx, "water", 5); len(os) != 0 { t.Fatalf("pending should not count as outcome: got %v", os) } // resolve once → ok if err := s.ResolveNudge(ctx, id, "acted", now.Add(time.Minute)); err != nil { t.Fatalf("ResolveNudge: %v", err) } // re-resolve rejected — feedback signal must not be silently corruptable if err := s.ResolveNudge(ctx, id, "ignored", now.Add(2*time.Minute)); !errors.Is(err, ErrNudgeOutcome) { t.Fatalf("re-resolve: want ErrNudgeOutcome, got %v", err) } // outcomes feed back: 1 acted in last N if os, _ := s.RecentOutcomes(ctx, "water", 5); len(os) != 1 || os[0] != "acted" { t.Fatalf("outcomes: want [acted], got %v", os) } } func TestUnackedTelegramRules(t *testing.T) { // the dispatcher's RepeatUnacked reads this to know which sev4 telegram // sends are still un-acked. telegram is the sev4-away channel by routing // construction, so channel+outcome is the full filter. s := newTestStore(t) ctx := context.Background() now := time.Now().UTC() // none → empty (not nil-iff-not-set is fine; empty slice is the contract) if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 0 { t.Fatalf("cold: want [] err=nil, got %v %v", got, err) } // a pending telegram nudge → its rule appears. if _, err := s.RecordNudge(ctx, "service_down", "telegram", "homesrv down", now); err != nil { t.Fatal(err) } got, err := s.UnackedTelegramRules(ctx) if err != nil || len(got) != 1 || got[0] != "service_down" { t.Fatalf("after send: want [service_down], got %v %v", got, err) } // a pending nudge on a different channel (voice) must NOT appear — the // repeat-til-ack path is telegram-only. if _, err := s.RecordNudge(ctx, "water", "voice", "drink water", now); err != nil { t.Fatal(err) } if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 1 || got[0] != "service_down" { t.Fatalf("voice must not appear: want [service_down], got %v %v", got, err) } // a second pending telegram nudge for a different rule → both appear, // sorted by rule name (deterministic for the daemon). if _, err := s.RecordNudge(ctx, "disk_full", "telegram", "disk 99%", now); err != nil { t.Fatal(err) } if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 2 || got[0] != "disk_full" || got[1] != "service_down" { t.Fatalf("two rules: want [disk_full service_down], got %v %v", got, err) } // resolving one (the user acked disk_full) → only the other remains. // RecordNudge returned disk_full's id; re-query to get it here. id, err := s.LastNudge(ctx, "disk_full") if err != nil { t.Fatalf("LastNudge disk_full: %v", err) } if err := s.ResolveNudge(ctx, id.ID, "acted", now.Add(time.Minute)); err != nil { t.Fatalf("ResolveNudge: %v", err) } if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 1 || got[0] != "service_down" { t.Fatalf("after ack disk_full: want [service_down], got %v %v", got, err) } } func TestPresenceStateSingletonRoundtrip(t *testing.T) { s := newTestStore(t) ctx := context.Background() // cold start → away, 0 b, score, _, err := s.LoadPresenceState(ctx) if err != nil || b != Away || score != 0 { t.Fatalf("cold: want Away/0, got %s/%f (%v)", b, score, err) } // save → reload now := time.Now().UTC() if err := s.SavePresenceState(ctx, Present, 0.83, now); err != nil { t.Fatal(err) } if b, score, _, err := s.LoadPresenceState(ctx); err != nil || b != Present || score != 0.83 { t.Fatalf("after save: want Present/0.83, got %s/%f (%v)", b, score, err) } }