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) } }