4f012e350c
parseDT stamped a zoned or floating DTSTART as UTC while every window around it is built in local time, so the two sides of every comparison were in different frames. On a +03 box a 22:00 local event parsed as 22:00Z, past the end of the local day, and the whole evening dropped out of the busy gate and the day plan. A 13:00 Moscow meeting read on a +04 box was recited at 17:00 next to its own printed 13:00. DTSTART now resolves three ways: a Z suffix is UTC, a TZID is loaded from the zone database, and a floating value is read in the caller's location. tzdata is embedded because the deploy image carries none, and a silent fallback to the box offset is the bug being fixed. FactKey and FactValue stamp the owner's clock, so the key date the store range-scans is the same day the plan asks for. FactSummary drops the time tail for callers that print the hour themselves. Found in review of #56 and #58.
227 lines
7.0 KiB
Go
227 lines
7.0 KiB
Go
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",
|
|
// Local, like a phone relaying from the box's own timezone: the fact
|
|
// key and value are stamped on the owner's clock, so a UTC reading
|
|
// here would only be testing the offset of the test machine.
|
|
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local),
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|