a2081d8227
Neither utterance carries a question mark or an interrogative, so nothing at stage 0 claimed them and the model called both facts. The write is contained — actions_fact refuses a question-shaped fact and re-runs the turn as a query — but every one of these paid a full model round trip to reach a decision two regexes can make, and the fixture scored the routing as wrong. rest-of-day-query joins the agenda grammars: the predicate for the utterance already existed as IsRestOfDayQuery, one layer down in the query chain, and this is what gets the turn there. NarrativeQueryGrammar reads the same narrativeRequests lexicon IsQuestionShaped reads, and declines the topics that are chat rather than world questions — a joke, a bedtime story, herself. It is wired last, so an explicit capture marker still wins. Fixture: ru-query-024 and ru-query-025, both passing. Classifier + ONNX baseline 56/80 (70.0%) → 58/82 (70.7%), no case regressed and no new false clarify. The LLM arm is unmeasured here — no llama-server in this run. The mavweb auth test posted its instant as "Z", which the #482 fix now reads in the daemon's zone, making the clock inside the text stale by the test box's own offset. It carries the local offset now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
254 lines
8.3 KiB
Go
254 lines
8.3 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) {
|
|
// posted_at carries the local offset, and the clock reading inside the text
|
|
// sits twenty minutes after it. A bare "Z" here would make the reading
|
|
// stale by the test machine's own offset and the handler would answer 202
|
|
// no-meeting, which says nothing about the auth this test is checking
|
|
// (Vikunja #482).
|
|
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local)
|
|
body := fmt.Sprintf(`{"title":"Планёрка 10:00","posted_at":%q}`, posted.Format(time.RFC3339))
|
|
|
|
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)
|
|
}
|
|
})
|
|
|
|
// RFC 7235 says the scheme is case-insensitive. A phone sending
|
|
// "bearer <tok>" used to fall through to the X-Maven-Token branch and get a
|
|
// 401 that looked, from the phone's side, like a wrong token.
|
|
t.Run("lowercase bearer scheme accepted", func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
handleAmbient(rr, newReq("Authorization", "bearer "+ambientTestToken), &ambientCore{}, ambientTestToken)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body)
|
|
}
|
|
})
|
|
|
|
// A bare token with no scheme is not a bearer header. Accepting it made the
|
|
// Authorization branch a second, undocumented X-Maven-Token.
|
|
t.Run("bare token in Authorization rejected", func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
handleAmbient(rr, newReq("Authorization", ambientTestToken), &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)
|
|
}
|
|
})
|
|
}
|