c932cd8677
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ghELqYhZNLub2TXGMazqA
207 lines
5.9 KiB
Go
207 lines
5.9 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
|
|
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),
|
|
})
|
|
}
|