290 lines
10 KiB
Go
290 lines
10 KiB
Go
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)
|
|
}
|
|
} |