1524991adc
ECOSYSTEM-SPEC §2.6 requires list_attention to distinguish "nothing needs attention" from "I cannot currently tell", and to say so when a source is failed or stale. Maven said the first one unconditionally: ListAttention decoded into []map[string]any, the word degraded appeared nowhere, and an empty list answered "ничего не требует внимания". A Praxis with every source dead read as calm. Two halves, because the spec's mechanism does not exist server-side yet. The deployed Praxis answers /api/v1/tools/attention with a bare array and no envelope, so praxisAttention now decodes either shape and believes a degraded array when one arrives. Until one does, an empty list triggers one read of /api/v1/sources, and anything that is not reporting health "ok" is named instead of the all-clear. Zero sources is the same answer: a Praxis that polls nothing knows nothing, which is the state of this box today. A sources read that fails is deliberately not a hedge. The attention call succeeded, and not being able to ask about health is not evidence of a fault. Both hedges also cover the entity-scoped digest, where a per-entity all-clear is the more convincing of the two. New keys attention_degraded and attention_no_sources, in acts_ru_v1.json and the floor. The fake Praxis serves one healthy source by default, so the existing attention tests still assert an all-clear on purpose rather than by omission.
362 lines
12 KiB
Go
362 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// capturedRequest is a recorded inbound request against a fake ecosystem
|
|
// server, kept minimal (method/path/body) since that's what tests assert on.
|
|
type capturedRequest struct {
|
|
Method string
|
|
Path string
|
|
Query string
|
|
Body []byte
|
|
Header http.Header
|
|
}
|
|
|
|
// fakeServer is the common shell behind fakeNexus/fakePraxis/fakeHexis: an
|
|
// httptest.Server whose responses are driven by a caller-supplied route
|
|
// table, with every inbound request captured for later assertions and an
|
|
// optional fault (status code) that overrides all routing while active —
|
|
// the degraded-mode lever for these tests.
|
|
type fakeServer struct {
|
|
*httptest.Server
|
|
|
|
mu sync.Mutex
|
|
requests []capturedRequest
|
|
fault int // non-zero: every request gets this HTTP status instead of routing
|
|
routeFaults map[string]int // path prefix → status, for one endpoint failing alone
|
|
garbage string // non-empty: returned 200 verbatim instead of routing (malformed-contract lever)
|
|
delay time.Duration
|
|
}
|
|
|
|
// newFakeServer starts a server dispatching to routes keyed by "METHOD
|
|
// PATH_PREFIX" (matched with HasPrefix on path), falling back to notFound
|
|
// then to a 404 if no route matches. Route handlers are plain
|
|
// http.HandlerFuncs so tests can write arbitrary fixture responses.
|
|
func newFakeServer(t *testing.T, routes map[string]http.HandlerFunc) *fakeServer {
|
|
t.Helper()
|
|
fs := &fakeServer{}
|
|
fs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body := make([]byte, 0)
|
|
if r.Body != nil {
|
|
buf := make([]byte, r.ContentLength)
|
|
if r.ContentLength > 0 {
|
|
n, _ := r.Body.Read(buf)
|
|
body = buf[:n]
|
|
}
|
|
}
|
|
fs.mu.Lock()
|
|
fs.requests = append(fs.requests, capturedRequest{
|
|
Method: r.Method,
|
|
Path: r.URL.Path,
|
|
Query: r.URL.RawQuery,
|
|
Body: body,
|
|
Header: r.Header.Clone(),
|
|
})
|
|
fault := fs.fault
|
|
if fault == 0 {
|
|
for prefix, status := range fs.routeFaults {
|
|
if hasPrefix(r.URL.Path, prefix) {
|
|
fault = status
|
|
break
|
|
}
|
|
}
|
|
}
|
|
garbage := fs.garbage
|
|
delay := fs.delay
|
|
fs.mu.Unlock()
|
|
|
|
if delay > 0 {
|
|
select {
|
|
case <-time.After(delay):
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
if fault != 0 {
|
|
http.Error(w, "injected fault", fault)
|
|
return
|
|
}
|
|
if garbage != "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(garbage))
|
|
return
|
|
}
|
|
|
|
for key, handler := range routes {
|
|
method, prefix := splitRouteKey(key)
|
|
if r.Method == method && hasPrefix(r.URL.Path, prefix) {
|
|
handler(w, r)
|
|
return
|
|
}
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
t.Cleanup(fs.Server.Close)
|
|
return fs
|
|
}
|
|
|
|
func splitRouteKey(key string) (method, prefix string) {
|
|
for i := 0; i < len(key); i++ {
|
|
if key[i] == ' ' {
|
|
return key[:i], key[i+1:]
|
|
}
|
|
}
|
|
return key, ""
|
|
}
|
|
|
|
func hasPrefix(s, prefix string) bool {
|
|
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
|
}
|
|
|
|
// SetFault makes every subsequent request fail closed with the given HTTP
|
|
// status, simulating the dependency being down. Pass 0 to clear it.
|
|
func (fs *fakeServer) SetFault(status int) {
|
|
fs.mu.Lock()
|
|
defer fs.mu.Unlock()
|
|
fs.fault = status
|
|
}
|
|
|
|
// SetRouteFault fails one endpoint while the rest of the server stays healthy,
|
|
// which is the shape most real outages take: attention answers and pin is
|
|
// down. Pass 0 to clear that route. A server-wide SetFault still wins.
|
|
func (fs *fakeServer) SetRouteFault(pathPrefix string, status int) {
|
|
fs.mu.Lock()
|
|
defer fs.mu.Unlock()
|
|
if fs.routeFaults == nil {
|
|
fs.routeFaults = map[string]int{}
|
|
}
|
|
if status == 0 {
|
|
delete(fs.routeFaults, pathPrefix)
|
|
return
|
|
}
|
|
fs.routeFaults[pathPrefix] = status
|
|
}
|
|
|
|
// SetBody makes every subsequent request answer 200 with the given body,
|
|
// bypassing the route table. Used to serve a malformed or contract-violating
|
|
// payload where the transport itself is healthy. Pass "" to clear it.
|
|
func (fs *fakeServer) SetBody(body string) {
|
|
fs.mu.Lock()
|
|
defer fs.mu.Unlock()
|
|
fs.garbage = body
|
|
}
|
|
|
|
// SetDelay stalls every subsequent request for d before answering, so callers
|
|
// can drive client timeouts and context cancellation deterministically. The
|
|
// delay is abandoned as soon as the client hangs up.
|
|
func (fs *fakeServer) SetDelay(d time.Duration) {
|
|
fs.mu.Lock()
|
|
defer fs.mu.Unlock()
|
|
fs.delay = d
|
|
}
|
|
|
|
// Count returns how many captured requests used the given method and path
|
|
// prefix. "" matches any method.
|
|
func (fs *fakeServer) Count(method, prefix string) int {
|
|
n := 0
|
|
for _, r := range fs.Requests() {
|
|
if (method == "" || r.Method == method) && hasPrefix(r.Path, prefix) {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Requests returns a snapshot of captured requests, in arrival order.
|
|
func (fs *fakeServer) Requests() []capturedRequest {
|
|
fs.mu.Lock()
|
|
defer fs.mu.Unlock()
|
|
out := make([]capturedRequest, len(fs.requests))
|
|
copy(out, fs.requests)
|
|
return out
|
|
}
|
|
|
|
func jsonHandler(status int, body string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
w.Write([]byte(body))
|
|
}
|
|
}
|
|
|
|
// --- Protocol fixtures ---
|
|
// Canned response bodies matching each service's documented contract shape,
|
|
// so individual tests don't hand-roll JSON literals for the common cases.
|
|
|
|
func fixtureNexusResolved(entityID, displayName, entityType string) string {
|
|
return mustJSON(map[string]any{
|
|
"status": "resolved",
|
|
"entity": map[string]any{"id": entityID, "display_name": displayName, "type": entityType},
|
|
})
|
|
}
|
|
|
|
// fixtureNexusResolvedFlat is the flat resolve shape documented in
|
|
// ECOSYSTEM-SPEC.md §1.5 (entity_id/entity_type/display_name at the top
|
|
// level) rather than the nested "entity" object — the older of the two
|
|
// wire shapes Maven must keep accepting.
|
|
func fixtureNexusResolvedFlat(entityID, displayName, entityType string) string {
|
|
return mustJSON(map[string]any{
|
|
"status": "resolved",
|
|
"entity_id": entityID,
|
|
"entity_type": entityType,
|
|
"display_name": displayName,
|
|
})
|
|
}
|
|
|
|
// fixtureNexusResolvedFuture is a resolved response from a hypothetical newer
|
|
// Nexus: same required fields plus unknown ones. Decoding must ignore the
|
|
// extras, not fail — forward compatibility is what lets the ecosystem be
|
|
// upgraded one service at a time.
|
|
func fixtureNexusResolvedFuture(entityID, displayName, entityType string) string {
|
|
return mustJSON(map[string]any{
|
|
"status": "resolved",
|
|
"entity": map[string]any{"id": entityID, "display_name": displayName, "type": entityType, "tenant": "home"},
|
|
"provenance": map[string]any{"resolver": "v3", "graph_epoch": 42},
|
|
"score_breakdown": []any{map[string]any{"signal": "alias", "weight": 0.9}},
|
|
})
|
|
}
|
|
|
|
// fixtureNexusResolvedEmpty is the contract violation that decodes cleanly:
|
|
// Nexus claims a resolve and delivers no entity. It must not read as "no such
|
|
// entity", which would let the caller fall through to local execution with the
|
|
// user's verb intact.
|
|
func fixtureNexusResolvedEmpty() string {
|
|
return `{"status":"resolved"}`
|
|
}
|
|
|
|
func fixtureNexusNotFound() string {
|
|
return `{"status":"not_found"}`
|
|
}
|
|
|
|
func fixtureNexusAmbiguous(candidates ...map[string]string) string {
|
|
cs := make([]map[string]any, len(candidates))
|
|
for i, c := range candidates {
|
|
cs[i] = map[string]any{"entity_id": c["entity_id"], "display_name": c["display_name"]}
|
|
}
|
|
return mustJSON(map[string]any{"status": "ambiguous", "candidates": cs})
|
|
}
|
|
|
|
func fixtureHexisCapabilities(caps ...map[string]any) string {
|
|
return mustJSON(caps)
|
|
}
|
|
|
|
func fixtureHexisExecuted(id, status string) string {
|
|
return mustJSON(map[string]any{"id": id, "status": status})
|
|
}
|
|
|
|
// fixtureHexisExecutionFailed is a well-formed Hexis response reporting that
|
|
// the command itself failed: the call succeeded, the execution did not. Maven
|
|
// must distinguish this from a transport failure and from success.
|
|
func fixtureHexisExecutionFailed(id, message string) string {
|
|
return mustJSON(map[string]any{"id": id, "status": "failed", "error": message})
|
|
}
|
|
|
|
// fixturePraxisAttentionScoped tags each item with an entity_id, which is what
|
|
// a Praxis that understands the entity_id query parameter returns. A Praxis
|
|
// that ignores it answers with untagged items from every entity.
|
|
func fixturePraxisAttentionScoped(entityID string, items ...map[string]any) string {
|
|
for _, item := range items {
|
|
item["entity_id"] = entityID
|
|
}
|
|
return mustJSON(items)
|
|
}
|
|
|
|
func fixturePraxisAttentionItems(items ...map[string]any) string {
|
|
return mustJSON(items)
|
|
}
|
|
|
|
func mustJSON(v any) string {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// --- Fake clock ---
|
|
// A settable time source satisfying the `func() time.Time` shape reactiveHandler
|
|
// takes as `now`, so tests can control "now" independently of wall-clock time
|
|
// (e.g. asserting age-based digest ordering without sleeping).
|
|
|
|
type fakeClock struct {
|
|
mu sync.Mutex
|
|
t time.Time
|
|
step time.Duration // advanced on every read, so elapsed time is measurable
|
|
}
|
|
|
|
func newFakeClock(start time.Time) *fakeClock {
|
|
return &fakeClock{t: start}
|
|
}
|
|
|
|
// newTickingClock advances by step on every read. Durations measured across
|
|
// hops are then non-zero without sleeping, which is what lets a test tell a
|
|
// trace that measured something from one that measured nothing.
|
|
func newTickingClock(start time.Time, step time.Duration) *fakeClock {
|
|
return &fakeClock{t: start, step: step}
|
|
}
|
|
|
|
func (c *fakeClock) Now() time.Time {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
now := c.t
|
|
c.t = c.t.Add(c.step)
|
|
return now
|
|
}
|
|
|
|
func (c *fakeClock) Advance(d time.Duration) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.t = c.t.Add(d)
|
|
}
|
|
|
|
// --- Convenience constructors for the three ecosystem services ---
|
|
|
|
// newFakeNexus starts a fake Nexus exposing /api/v1/resolve. resolveBody is
|
|
// returned verbatim (200) for every resolve call unless a fault is injected.
|
|
func newFakeNexus(t *testing.T, resolveBody string) *fakeServer {
|
|
return newFakeServer(t, map[string]http.HandlerFunc{
|
|
"POST /api/v1/resolve": jsonHandler(http.StatusOK, resolveBody),
|
|
})
|
|
}
|
|
|
|
// newFakePraxis starts a fake Praxis exposing the /api/v1/tools/* surface
|
|
// Maven's praxisClient calls. Every route returns its fixed body until a
|
|
// fault is injected via SetFault.
|
|
func newFakePraxis(t *testing.T, attentionBody string) *fakeServer {
|
|
// One healthy source by default: an empty attention list only means
|
|
// all-clear when something is actually polling (Vikunja #540), and the
|
|
// other tests here are about attention rather than about source health.
|
|
return newFakePraxisWithSources(t, attentionBody, `[{"source_id":"src_ntfy","health":"ok"}]`)
|
|
}
|
|
|
|
// newFakePraxisWithSources is newFakePraxis with the /api/v1/sources body
|
|
// under the test's control, for the degraded and no-sources hedges.
|
|
func newFakePraxisWithSources(t *testing.T, attentionBody, sourcesBody string) *fakeServer {
|
|
return newFakeServer(t, map[string]http.HandlerFunc{
|
|
"GET /api/v1/sources": jsonHandler(http.StatusOK, sourcesBody),
|
|
"GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody),
|
|
"GET /api/v1/tools/changes": jsonHandler(http.StatusOK, `[]`),
|
|
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
|
|
"POST /api/v1/tools/acknowledge": jsonHandler(http.StatusOK, `{}`),
|
|
"POST /api/v1/tools/resolve": jsonHandler(http.StatusOK, `{}`),
|
|
"POST /api/v1/tools/ignore": jsonHandler(http.StatusOK, `{}`),
|
|
"POST /api/v1/tools/pin": jsonHandler(http.StatusOK, `{}`),
|
|
})
|
|
}
|
|
|
|
// newFakeHexis starts a fake Hexis exposing /api/v1/capabilities and
|
|
// /api/v1/execute.
|
|
func newFakeHexis(t *testing.T, capsBody, executeBody string) *fakeServer {
|
|
return newFakeServer(t, map[string]http.HandlerFunc{
|
|
"GET /api/v1/capabilities": jsonHandler(http.StatusOK, capsBody),
|
|
"POST /api/v1/execute": jsonHandler(http.StatusOK, executeBody),
|
|
})
|
|
}
|