package main import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/ipc" ) const ambientTestToken = "s3cret" // ambientCore adds provenance-scoped reads to fakeCore, which the dedupe path // needs. type ambientCore struct { fakeCore latest map[string]ipc.Fact // "key|source" → fact readErr error } func (c *ambientCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) { if c.readErr != nil { return ipc.Fact{}, c.readErr } f, ok := c.latest[key+"|"+source] if !ok { return ipc.Fact{}, ipc.ErrNoFact } return f, nil } func postAmbient(t *testing.T, core ipc.CoreAPI, token string, n calendar.Notification) (*httptest.ResponseRecorder, ambientResp) { t.Helper() body, err := json.Marshal(n) if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(string(body))) req.Header.Set("Authorization", "Bearer "+ambientTestToken) rr := httptest.NewRecorder() handleAmbient(rr, req, core, token) var resp ambientResp json.Unmarshal(rr.Body.Bytes(), &resp) return rr, resp } func meetingNotification() calendar.Notification { return calendar.Notification{ Package: "com.google.android.gm", Title: "Планёрка", Text: "10:00-10:30", Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.UTC), } } func TestHandleAmbientStoresMeeting(t *testing.T) { core := &ambientCore{} rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification()) if rr.Code != http.StatusCreated { t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body) } if !resp.Stored { t.Errorf("resp = %+v, want stored", resp) } if len(core.writeLog) != 1 { t.Fatalf("expected 1 fact write, got %d", len(core.writeLog)) } got := core.writeLog[0] if got.Source != calendar.SourceAmbient { t.Errorf("source = %q, want %q", got.Source, calendar.SourceAmbient) } if got.Confidence >= 1.0 { t.Errorf("confidence = %v — a notification is not a calendar read", got.Confidence) } if got.Confidence != calendar.AmbientConfidence { t.Errorf("confidence = %v, want %v", got.Confidence, calendar.AmbientConfidence) } if got.Kind != "env" { t.Errorf("kind = %q — a passive signal never writes a self-fact", got.Kind) } if want := "calendar_event_20260803_"; !strings.HasPrefix(got.Key, want) { t.Errorf("key = %q, want prefix %q", got.Key, want) } if got.Value != "Планёрка @ 10:00-10:30" { t.Errorf("value = %q", got.Value) } } // A phone reposts the same notification many times. Each repost is the same // event, and the append-only log must not fill with duplicates. func TestHandleAmbientDedupesReposts(t *testing.T) { core := &ambientCore{} postAmbient(t, core, ambientTestToken, meetingNotification()) if len(core.writeLog) != 1 { t.Fatalf("first post did not write") } w := core.writeLog[0] core.latest = map[string]ipc.Fact{w.Key + "|" + w.Source: {Value: w.Value}} rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification()) if rr.Code != http.StatusOK { t.Errorf("status = %d, want 200 for an unchanged repost", rr.Code) } if resp.Stored { t.Error("a repost must not be stored again") } if len(core.writeLog) != 1 { t.Errorf("wrote %d facts, want 1", len(core.writeLog)) } } // The conservative half: noise stores nothing at all. func TestHandleAmbientIgnoresNonMeetings(t *testing.T) { core := &ambientCore{} rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{ Package: "com.google.android.gm", Title: "3 новых письма", Posted: time.Now(), }) if rr.Code != http.StatusAccepted { t.Errorf("status = %d, want 202 (accepted, nothing to store — the relay must not retry)", rr.Code) } if resp.Stored { t.Error("a notification with no meeting time must store nothing") } if len(core.writeLog) != 0 { t.Fatalf("wrote %d facts for a non-meeting", len(core.writeLog)) } } func TestHandleAmbientAuth(t *testing.T) { body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}` newReq := func(hdr, val string) *http.Request { r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body)) if hdr != "" { r.Header.Set(hdr, val) } return r } t.Run("no token rejected", func(t *testing.T) { core := &ambientCore{} rr := httptest.NewRecorder() handleAmbient(rr, newReq("", ""), core, ambientTestToken) if rr.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", rr.Code) } if len(core.writeLog) != 0 { t.Error("an unauthorized post must not write") } }) t.Run("wrong token rejected", func(t *testing.T) { rr := httptest.NewRecorder() handleAmbient(rr, newReq("Authorization", "Bearer nope"), &ambientCore{}, ambientTestToken) if rr.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", rr.Code) } }) t.Run("X-Maven-Token accepted", func(t *testing.T) { rr := httptest.NewRecorder() handleAmbient(rr, newReq("X-Maven-Token", ambientTestToken), &ambientCore{}, ambientTestToken) if rr.Code != http.StatusCreated { t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body) } }) t.Run("capability off", func(t *testing.T) { rr := httptest.NewRecorder() handleAmbient(rr, newReq("Authorization", "Bearer "+ambientTestToken), &ambientCore{}, "") if rr.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503 when no token is configured", rr.Code) } }) t.Run("GET rejected", func(t *testing.T) { rr := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/api/ambient", nil) handleAmbient(rr, r, &ambientCore{}, ambientTestToken) if rr.Code != http.StatusMethodNotAllowed { t.Errorf("status = %d, want 405 — the ingest is write-only", rr.Code) } }) } func TestHandleAmbientBadInput(t *testing.T) { t.Run("bad json", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader("{nope")) req.Header.Set("X-Maven-Token", ambientTestToken) rr := httptest.NewRecorder() handleAmbient(rr, req, &ambientCore{}, ambientTestToken) if rr.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rr.Code) } }) t.Run("missing posted_at", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(`{"title":"Планёрка 10:00"}`)) req.Header.Set("X-Maven-Token", ambientTestToken) rr := httptest.NewRecorder() handleAmbient(rr, req, &ambientCore{}, ambientTestToken) if rr.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rr.Code) } }) t.Run("read error surfaces", func(t *testing.T) { core := &ambientCore{readErr: fmt.Errorf("socket closed")} rr, _ := postAmbient(t, core, ambientTestToken, meetingNotification()) if rr.Code != http.StatusBadGateway { t.Errorf("status = %d, want 502", rr.Code) } }) }