Files
Maven/internal/store/store_test.go
T
kami 012bdcc1ae memory: count habits over self facts only, and skip retracted ones
The behaviour profile read the newest 2000 rows of the shared facts table and
then discarded everything that was not kind=self, so the length of the window
was set by the noisiest writer. mavpoll writes a wg_handshake row every time a
peer rehandshakes, about every two minutes per peer, which is enough to reduce
2000 rows to under three days. A weekday habit needs two distinct Tuesdays, so
that window can never hold one, and she answered that she knows no habits on a
store holding a year of taps.

RecentActiveFactsByKind filters kind in SQL, and also drops rows a later row
voids along with the void marker itself. The old read counted both a retracted
tap and its retraction, so a fact he explicitly took back still shaped what she
said he usually does. A correction still counts, because a correction is a value
he stands behind.

Found in review of #59.
2026-08-01 14:00:46 +04:00

473 lines
17 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"path/filepath"
"testing"
"time"
"github.com/kami/maven/internal/calendar"
)
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 TestRecurringReminder(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC)
// Create a daily recurring reminder at 9:00
fire := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
id, err := s.CreateReminder(ctx, fire, `{"text":"daily standup"}`, "0 9 * * *")
if err != nil {
t.Fatal(err)
}
// Not due yet (at 8:00, next_fire_ts = 9:00)
if due, err := s.DueReminders(ctx, now); err != nil || len(due) != 0 {
t.Fatalf("before fire: want 0 due, got %d (%v)", len(due), err)
}
// Due at 9:00
due, err := s.DueReminders(ctx, fire.Add(time.Second))
if err != nil || len(due) != 1 || due[0].ID != id {
t.Fatalf("after fire: want 1 due (%d), got %d (%v)", id, len(due), err)
}
if due[0].Cron != "0 9 * * *" {
t.Fatalf("cron: want %q, got %q", "0 9 * * *", due[0].Cron)
}
// Reschedule: next fire should be tomorrow 9:00
if err := s.RescheduleReminder(ctx, id, fire); err != nil {
t.Fatalf("RescheduleReminder: %v", err)
}
tomorrow := fire.Add(24 * time.Hour)
due, err = s.DueReminders(ctx, tomorrow.Add(time.Second))
if err != nil || len(due) != 1 || due[0].ID != id {
t.Fatalf("after reschedule: want 1 due (%d), got %d (%v)", id, len(due), err)
}
// Mark non-recurring reminder → ErrReminderState
_, err = s.CreateReminder(ctx, fire, `{"text":"one-shot"}`, "")
if err != nil {
t.Fatal(err)
}
// The last id is id+1
if err := s.RescheduleReminder(ctx, id+1, fire); !errors.Is(err, ErrReminderState) {
t.Fatalf("reschedule one-shot: 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)
}
}
func TestCalendarEvents(t *testing.T) {
store := newTestStore(t)
defer store.Close()
ctx := context.Background()
now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
// Write facts for different dates
store.WriteFact(ctx, time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC), KindSelf, "calendar_event_20260706_Morning-standup", `"Morning standup @ 10:00-10:30"`, "poll:caldav", 1.0, sql.NullInt64{})
store.WriteFact(ctx, time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC), KindSelf, "calendar_event_20260706_Lunch", `"Lunch @ 12:00-13:00"`, "poll:caldav", 1.0, sql.NullInt64{})
store.WriteFact(ctx, time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC), KindSelf, "calendar_event_20260707_Doctor", `"Doctor @ 14:00-15:00"`, "poll:caldav", 1.0, sql.NullInt64{})
store.WriteFact(ctx, time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC), KindSelf, "calendar_event_20260705_Yesterday", `"Yesterday thing"`, "poll:caldav", 1.0, sql.NullInt64{})
// Query July 6
events, err := store.CalendarEvents(ctx, now.Truncate(24*time.Hour), now.Truncate(24*time.Hour).Add(24*time.Hour))
if err != nil {
t.Fatalf("CalendarEvents: %v", err)
}
if len(events) != 2 {
t.Fatalf("expected 2 events on July 6, got %d", len(events))
}
// Query July 7
t7 := time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC)
events, err = store.CalendarEvents(ctx, t7, t7.Add(24*time.Hour))
if err != nil {
t.Fatalf("CalendarEvents: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event on July 7, got %d", len(events))
}
// Query date with no events
t8 := time.Date(2026, 7, 8, 0, 0, 0, 0, time.UTC)
events, err = store.CalendarEvents(ctx, t8, t8.Add(24*time.Hour))
if err != nil {
t.Fatalf("CalendarEvents: %v", err)
}
if len(events) != 0 {
t.Fatalf("expected 0 events on July 8, got %d", len(events))
}
}
// The work calendar arrives as relayed phone notifications, not a CalDAV read
// (Vikunja #126). Those events belong in the same day's answer, and their
// provenance has to survive the query so the caller can hedge them.
func TestCalendarEventsIncludesAmbientSource(t *testing.T) {
store := newTestStore(t)
defer store.Close()
ctx := context.Background()
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
store.WriteFact(ctx, day.Add(10*time.Hour), KindEnv, "calendar_event_20260803_Aaa-personal",
`"Aaa personal @ 10:00-10:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, "calendar_event_20260803_Bbb-work",
`"Bbb work @ 14:00-14:30"`, calendar.SourceAmbient, calendar.AmbientConfidence, sql.NullInt64{})
// A fact that merely looks like one must still be excluded by source.
store.WriteFact(ctx, day.Add(16*time.Hour), KindEnv, "calendar_event_20260803_Ccc-forged",
`"Ccc forged @ 16:00-16:30"`, "tap:voice", 1.0, sql.NullInt64{})
events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1))
if err != nil {
t.Fatalf("CalendarEvents: %v", err)
}
if len(events) != 2 {
t.Fatalf("got %d events, want the personal and the ambient one: %+v", len(events), events)
}
bySource := map[string]Fact{}
for _, e := range events {
bySource[e.Source] = e
}
if _, ok := bySource[calendar.SourcePersonal]; !ok {
t.Error("the personal CalDAV event is missing")
}
amb, ok := bySource[calendar.SourceAmbient]
if !ok {
t.Fatal("the ambient work event is missing")
}
if amb.Confidence >= 1.0 {
t.Errorf("ambient confidence = %v, must stay below a calendar read's", amb.Confidence)
}
if _, ok := bySource["tap:voice"]; ok {
t.Error("a non-calendar source must not be read as a calendar event")
}
}
// TestRecentActiveFactsByKind — the behaviour profile's window. Env rows must
// not spend it, and a retracted row must not be counted as something he did.
func TestRecentActiveFactsByKind(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Millisecond)
// A noisy env writer, the way mavpoll writes handshakes.
for i := 0; i < 50; i++ {
if _, err := s.WriteFact(ctx, now.Add(-time.Duration(i)*time.Minute), KindEnv,
"wg_handshake", "1", "poll:wireguard", 1.0, sql.NullInt64{}); err != nil {
t.Fatalf("write env fact: %v", err)
}
}
if _, err := s.WriteFact(ctx, now.Add(-2*time.Hour), KindSelf, "workout", "done", "tap:voice", 1.0, sql.NullInt64{}); err != nil {
t.Fatalf("write self fact: %v", err)
}
if _, err := s.WriteFact(ctx, now.Add(-3*time.Hour), KindSelf, "walk", "done", "tap:voice", 1.0, sql.NullInt64{}); err != nil {
t.Fatalf("write self fact: %v", err)
}
if _, _, err := s.VoidLatestFact(ctx, "walk", "tap:voice", now.Add(-time.Hour)); err != nil {
t.Fatalf("VoidLatestFact: %v", err)
}
got, err := s.RecentActiveFactsByKind(ctx, KindSelf, 10)
if err != nil {
t.Fatalf("RecentActiveFactsByKind: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d facts, want 1 (workout): %+v", len(got), got)
}
if got[0].Key != "workout" {
t.Errorf("key = %q, want workout", got[0].Key)
}
// The window is spent on self rows only: a limit smaller than the env
// traffic still returns the taps.
got, err = s.RecentActiveFactsByKind(ctx, KindSelf, 1)
if err != nil {
t.Fatalf("RecentActiveFactsByKind: %v", err)
}
if len(got) != 1 || got[0].Key != "workout" {
t.Fatalf("got %+v, want the newest self fact", got)
}
}