Files
Maven/internal/store/store_test.go
T
kami 49f089d8a6 Read the work calendar as a notification signal, not a mailbox (#126)
Maven does not get a work credential. A corp mail or calendar session living on
the homelab ties the box's blast radius to the employer's data, which is the
thing this task exists to refuse. What she reads instead is the signal: an
Android notification-listener on the phone relays meeting notifications over
wg/LAN to POST /api/ambient, and the ones that clearly describe a meeting become
calendar events at source=ambient:notif, confidence 0.6.

The provenance is the point. A notification is evidence about a meeting, not a
reading of a calendar, so it is never indistinguishable from one: it is stored
below full confidence, store.CalendarEvents keeps the source and confidence on
every row it returns, and the query path hedges — "похоже, Планёрка @ 14:00" for
a relayed event, plain text for a CalDAV read.

The parse is deliberately conservative (internal/calendar/ambient.go). It needs
a real clock reading and a summary that is not just that clock reading;
otherwise it stores nothing at all. A bare hour is not a time, an unread count
is not a time, and "срок 2026.08.15" does not offer 08:15 as a meeting — loose
digits in a notification are far more often a badge or a date, and a mailbox of
noise rendered as invented meetings is worse than a gap.

The ingest is off unless configured: no -ambient-token, no route registered. The
token is a shared secret compared in constant time, because the poster is a
background Android service and WebAuthn has no answer for one. The endpoint is
write-only, accepts one shape of write, and cannot read anything back out.
Reposts of the same notification dedupe against the latest fact for that
key+source, the same append-only discipline cmd/mavcaldav follows.

Not shipped: the Android relay app itself, which is a separate artifact and a
device, not Go in this repo.
2026-08-01 02:04:06 +04:00

427 lines
15 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")
}
}