From c932cd8677218b428b9eaeba93bee7e2f2118e3a Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 20 Jul 2026 11:33:47 +0400 Subject: [PATCH] Add reusable fake-ecosystem test harness with fault injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds fakeecosystem_test.go: a shared fakeServer wrapping httptest.Server with request capture, a runtime-toggleable fault (SetFault) that makes a running fake Nexus/Praxis/Hexis fail closed like a real outage without tearing the server down, protocol fixtures for each service's documented response shapes, and a settable fakeClock for time-dependent assertions. Uses it in ecosystem_harness_test.go to cover a gap the existing ad-hoc per-test httptest servers didn't reach — handlePraxisAct had zero test coverage — plus a fault-then-recovery test showing the same fake flapping mid-session, the shape the earlier fail-closed fixes (#272/#273) need regression coverage against. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018ghELqYhZNLub2TXGMazqA --- cmd/mavend/ecosystem_harness_test.go | 110 ++++++++++++++ cmd/mavend/fakeecosystem_test.go | 206 +++++++++++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 cmd/mavend/ecosystem_harness_test.go create mode 100644 cmd/mavend/fakeecosystem_test.go diff --git a/cmd/mavend/ecosystem_harness_test.go b/cmd/mavend/ecosystem_harness_test.go new file mode 100644 index 0000000..1c6e375 --- /dev/null +++ b/cmd/mavend/ecosystem_harness_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" +) + +// These tests exercise the fake ecosystem harness (fakeecosystem_test.go) +// directly, covering paths the ad-hoc httptest servers in ecosystem_test.go +// don't: Praxis attention (happy + degraded) and fault injection against a +// reusable fake rather than a one-off inline handler. + +func praxisActDec(fn string) router.Decision { + return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true}} +} + +func newPraxisTestHandler(t *testing.T, praxis *fakeServer) *reactiveHandler { + t.Helper() + st := newTestStore(t) + clock := newFakeClock(time.Now()) + return &reactiveHandler{ + api: ipc.NewStoreAPI(st), + dataStore: st, + now: clock.Now, + ecosystem: &ecosystemWiring{praxis: newPraxisClient(praxis.URL)}, + } +} + +func TestPraxisAttention_HappyPathSurfacesItems(t *testing.T) { + ctx := context.Background() + items := fixturePraxisAttentionItems(map[string]any{ + "id": "item_1", "title": "disk almost full", "importance": 3.0, "rule": "low_disk", + }) + praxis := newFakePraxis(t, items) + h := newPraxisTestHandler(t, praxis) + + reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")) + if !strings.Contains(reply, "disk almost full") { + t.Fatalf("expected attention digest to mention the item, got %q", reply) + } + + var sawAttention, sawSurface bool + for _, r := range praxis.Requests() { + if r.Method == "GET" && strings.HasPrefix(r.Path, "/api/v1/tools/attention") { + sawAttention = true + } + if r.Method == "POST" && r.Path == "/api/v1/tools/surface" { + sawSurface = true + } + } + if !sawAttention { + t.Error("expected a GET to /api/v1/tools/attention") + } + if !sawSurface { + t.Error("expected surfaced item to POST /api/v1/tools/surface (surfaced != acknowledged)") + } +} + +// TestPraxisAttention_DegradedFailsClosedNotEmpty covers the degraded-mode +// contract: when Praxis is down, Maven must say so rather than silently +// returning nothing or panicking. +func TestPraxisAttention_DegradedFailsClosedNotEmpty(t *testing.T) { + ctx := context.Background() + praxis := newFakePraxis(t, fixturePraxisAttentionItems()) + praxis.SetFault(500) + h := newPraxisTestHandler(t, praxis) + + reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")) + if reply == "" { + t.Fatal("praxis outage must not produce an empty reply") + } + if strings.Contains(reply, "disk almost full") { + t.Fatal("degraded reply must not fabricate item content") + } +} + +// TestFakeNexus_FaultInjectionThenRecovery demonstrates the shared harness's +// fault toggle affecting the same running server, matching the shape of a +// real dependency flapping and recovering mid-session. +func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) { + ctx := context.Background() + nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service")) + caps := fixtureHexisCapabilities(map[string]any{"id": "cap_status", "name": "restart", "read_only": true}) + hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded")) + + st := newTestStore(t) + h := &reactiveHandler{ + api: ipc.NewStoreAPI(st), + dataStore: st, + now: time.Now, + ecosystem: stubEcosystem(nexus.URL, hexis.URL), + } + + nexus.SetFault(503) + reply := h.handleHexisAct(ctx, actDec("muzick indexer")) + if strings.Contains(reply, "выполнена") { + t.Fatalf("nexus outage must not report success, got %q", reply) + } + + nexus.SetFault(0) + reply = h.handleHexisAct(ctx, actDec("muzick indexer")) + if !strings.Contains(reply, "выполнена") { + t.Fatalf("expected success once nexus recovers, got %q", reply) + } +} diff --git a/cmd/mavend/fakeecosystem_test.go b/cmd/mavend/fakeecosystem_test.go new file mode 100644 index 0000000..ff08eb6 --- /dev/null +++ b/cmd/mavend/fakeecosystem_test.go @@ -0,0 +1,206 @@ +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 + Body []byte +} + +// 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 +} + +// 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, Body: body}) + fault := fs.fault + fs.mu.Unlock() + + if fault != 0 { + http.Error(w, "injected fault", fault) + 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 +} + +// 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}, + }) +} + +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}) +} + +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 +} + +func newFakeClock(start time.Time) *fakeClock { + return &fakeClock{t: start} +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +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 { + return newFakeServer(t, map[string]http.HandlerFunc{ + "GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody), + "POST /api/v1/tools/surface": 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), + }) +}