Files
Maven/cmd/mavend/ecosystem_test.go
T
claude 6a9d8a4dd5 mavend: name the service that is down, and never read an empty list (V-521)
Two caller-side halves of the same review.

«экосистема недоступна» named nothing. Nexus, Praxis and Hexis fail
independently, and every one of the six call sites already knew which one it was
talking to — it writes that name into the trace on the line above. So eco_down
and eco_denied now take {name}, and he hears which service refused him.

The list entries are single-variant and placeholder-only, so an empty list has
no shorter wording to fall back on: attention_list would render as its own label
and a colon. Both Praxis readers checked the response length and neither checked
what survived formatting, so an item with no title counted toward a list it
could not appear in. They skip the untitled item and fall to the _none entry
when nothing is left.

The ecosystem tests asserted the substring "выполнена", which was a literal out
of the act file that review has now reworded. Seventeen sites go through actRan,
which asks the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 16:00:17 +04:00

256 lines
9.3 KiB
Go

package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
hexisclient "github.com/kami/hexis/pkg/client"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
// stubEcosystem wires nexus+hexis clients at the given base URLs.
func stubEcosystem(nexusURL, hexisURL string) *ecosystemWiring {
return &ecosystemWiring{
nexus: newNexusClient(nexusURL),
hexis: hexisclient.New(hexisURL),
}
}
// newHexisTestHandler builds a reactiveHandler backed by fake nexus+hexis
// servers. resolveBody is returned verbatim from nexus /resolve; caps is the
// capability list; executed records whether Hexis /execute was called.
func newHexisTestHandler(t *testing.T, resolveBody string, caps string) (*reactiveHandler, *bool) {
t.Helper()
executed := new(bool)
nexus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(resolveBody))
}))
t.Cleanup(nexus.Close)
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasPrefix(r.URL.Path, "/api/v1/capabilities"):
w.Write([]byte(caps))
case r.URL.Path == "/api/v1/execute":
*executed = true
w.Write([]byte(`{"id":"exec_1","status":"succeeded"}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(hexis.Close)
st := newTestStore(t)
now := time.Now()
return &reactiveHandler{
api: ipc.NewStoreAPI(st),
dataStore: st,
now: func() time.Time { return now },
ecosystem: stubEcosystem(nexus.URL, hexis.URL),
}, executed
}
// actDec builds an act decision about subject. The verb is always "restart":
// the argument is the utterance the entity is resolved from, never the verb,
// so actDec("restart") reads as a verb and is not one.
func actDec(subject string) router.Decision {
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: subject, Fn: "restart", HasFn: true}}
}
func TestHexisMutatingRequiresConfirm(t *testing.T) {
ctx := context.Background()
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
caps := `[{"id":"cap_restart","name":"restart","read_only":false,"risk":"high"}]`
h, executed := newHexisTestHandler(t, resolved, caps)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !strings.Contains(reply, "да") {
t.Fatalf("mutating cap should ask to confirm, got %q", reply)
}
if *executed {
t.Fatal("mutating cap must NOT execute before confirmation")
}
if h.pendingHexis == nil || h.pendingHexis.capabilityID != "cap_restart" {
t.Fatalf("expected pending hexis bound to cap_restart, got %+v", h.pendingHexis)
}
// The follow-up "да" turn executes exactly the parked capability.
confirmReply, handled := h.resolveConfirm(ctx, "да")
if !handled || !actRan(confirmReply) {
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
}
if !*executed {
t.Fatal("confirmed mutating cap should have executed")
}
if h.pendingHexis != nil {
t.Fatal("pending should be cleared after confirm")
}
}
func TestHexisConfirmNoDoesNotExecute(t *testing.T) {
ctx := context.Background()
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
caps := `[{"id":"cap_restart","name":"restart","read_only":false}]`
h, executed := newHexisTestHandler(t, resolved, caps)
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
reply, handled := h.resolveConfirm(ctx, "нет")
if !handled || !strings.Contains(reply, "отменила") {
t.Fatalf("no should cancel, got handled=%v reply=%q", handled, reply)
}
if *executed {
t.Fatal("declined cap must not execute")
}
}
func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
ctx := context.Background()
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
caps := `[{"id":"cap_status","name":"restart","read_only":true}]`
h, executed := newHexisTestHandler(t, resolved, caps)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !*executed {
t.Fatal("read-only cap should execute without confirmation")
}
if h.pendingHexis != nil {
t.Fatal("read-only cap should not park a confirmation")
}
if !actRan(reply) {
t.Fatalf("unexpected reply %q", reply)
}
}
func TestHexisAmbiguousAsksClarification(t *testing.T) {
ctx := context.Background()
ambiguous := `{"status":"ambiguous","candidates":[{"entity_id":"ent_muzick","display_name":"Muzick indexer"},{"entity_id":"ent_manga","display_name":"Manga indexer"}]}`
h, executed := newHexisTestHandler(t, ambiguous, `[]`)
reply := h.handleHexisAct(ctx, actDec("the indexer"))
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Manga indexer") {
t.Fatalf("ambiguous should list candidates, got %q", reply)
}
if *executed {
t.Fatal("ambiguous target must never execute")
}
}
// TestHexisResolveFlatShapeAccepted covers ECOSYSTEM-SPEC.md §1.5's documented
// flat resolve response (entity_id/entity_type/display_name at the top level,
// no nested entity object) alongside the nested shape Maven already decodes.
func TestHexisResolveFlatShapeAccepted(t *testing.T) {
ctx := context.Background()
flat := `{"status":"resolved","entity_id":"ent_muzick","entity_type":"service","display_name":"Muzick indexer"}`
caps := `[{"id":"cap_status","name":"restart","read_only":true}]`
h, executed := newHexisTestHandler(t, flat, caps)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !*executed {
t.Fatalf("flat-shaped resolved entity should still execute, got reply %q", reply)
}
}
// TestHexisNexusErrorFailsClosed covers the P0 audit finding: a genuine Nexus
// dependency failure must stop the ecosystem action and report degradation,
// never silently fall through to the local system command executor.
func TestHexisNexusErrorFailsClosed(t *testing.T) {
ctx := context.Background()
nexus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
t.Cleanup(nexus.Close)
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("hexis must not be contacted when nexus resolve fails")
}))
t.Cleanup(hexis.Close)
st := newTestStore(t)
now := time.Now()
h := &reactiveHandler{
api: ipc.NewStoreAPI(st),
dataStore: st,
now: func() time.Time { return now },
ecosystem: stubEcosystem(nexus.URL, hexis.URL),
}
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if reply == "" {
t.Fatal("nexus dependency failure must not fall through with an empty reply")
}
if actRan(reply) {
t.Fatalf("nexus dependency failure must not report success, got %q", reply)
}
}
// TestHexisUnavailableFailsClosed covers the same invariant for a resolved
// entity whose Hexis capability discovery then fails.
func TestHexisUnavailableFailsClosed(t *testing.T) {
ctx := context.Background()
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
nexus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(resolved))
}))
t.Cleanup(nexus.Close)
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
t.Cleanup(hexis.Close)
st := newTestStore(t)
now := time.Now()
h := &reactiveHandler{
api: ipc.NewStoreAPI(st),
dataStore: st,
now: func() time.Time { return now },
ecosystem: stubEcosystem(nexus.URL, hexis.URL),
}
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if reply == "" {
t.Fatal("hexis dependency failure must not fall through with an empty reply")
}
if actRan(reply) {
t.Fatalf("hexis dependency failure must not report success, got %q", reply)
}
}
// TestHexisNotFoundStillFallsThrough ensures the fail-closed fix above is
// scoped to genuine dependency errors: a resolved-but-empty ("not_found")
// Nexus response — meaning the text simply isn't a known entity, not that
// Nexus is broken — must still fall through to the local command executor.
func TestHexisNotFoundStillFallsThrough(t *testing.T) {
ctx := context.Background()
notFound := `{"status":"not_found"}`
h, executed := newHexisTestHandler(t, notFound, `[]`)
reply := h.handleHexisAct(ctx, actDec("turn off the lights"))
if reply != "" {
t.Fatalf("not_found resolution should fall through with empty reply, got %q", reply)
}
if *executed {
t.Fatal("not_found resolution must never execute a hexis capability")
}
}
// actRan — the reply is the line she says when a capability ran against an
// entity. The tests used to look for the substring "выполнена", which was a
// literal out of the act file: the review reworded that line to "готово: {name}"
// and seventeen assertions went with it (Vikunja #521).
func actRan(reply string) bool {
return phraser.IsA(phraser.ActDoneEntity, map[string]string{"name": muzickIndexer}, reply)
}
// muzickIndexer — the display name every ecosystem fixture resolves to.
const muzickIndexer = "Muzick indexer"