Files
Maven/internal/store/store_test.go
T
kami 89afe4ca99 Merge branch 'fix/g04' into fix/integrated
# Conflicts:
#	cmd/mavend/actions_query.go
#	cmd/mavend/dayplan_test.go
2026-08-01 14:19:15 +04:00

533 lines
19 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))
}
}
// A rescheduled meeting keeps its key and appends a row. The query must return
// the current value, not the history: reciting both told the owner he had two
// standups when one had been moved.
func TestCalendarEventsReturnsOneRowPerEvent(t *testing.T) {
store := newTestStore(t)
defer store.Close()
ctx := context.Background()
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
const key = "calendar_event_20260803_Standup"
store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, key,
`"Standup @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
store.WriteFact(ctx, day.Add(16*time.Hour), KindEnv, key,
`"Standup @ 16:00-16:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
// The notification relay guessed at the same meeting. A calendar read is
// better evidence, so the hedged row must not displace it.
store.WriteFact(ctx, day.Add(17*time.Hour), KindEnv, key,
`"Standup @ 17:00-17:30"`, calendar.SourceAmbient, calendar.AmbientConfidence, sql.NullInt64{})
events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1))
if err != nil {
t.Fatalf("CalendarEvents: %v", err)
}
if len(events) != 1 {
t.Fatalf("got %d rows, want the current one only: %+v", len(events), events)
}
if events[0].Value != `"Standup @ 16:00-16:30"` {
t.Errorf("value = %q, want the latest calendar read", events[0].Value)
}
}
// A voided calendar fact is gone, not history to recite.
func TestCalendarEventsSkipsVoidedRows(t *testing.T) {
store := newTestStore(t)
defer store.Close()
ctx := context.Background()
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
id, err := store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, "calendar_event_20260803_Cancelled",
`"Cancelled @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
if err != nil {
t.Fatalf("WriteFact: %v", err)
}
if _, err := store.WriteFact(ctx, day.Add(15*time.Hour), KindEnv, "calendar_event_20260803_Cancelled",
`"Cancelled @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{Int64: id, Valid: true}); err != nil {
t.Fatalf("WriteFact void: %v", err)
}
events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1))
if err != nil {
t.Fatalf("CalendarEvents: %v", err)
}
for _, e := range events {
if e.ID == id {
t.Fatalf("voided row %d came back: %+v", id, e)
}
}
}
// 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)
}
}