203 lines
7.7 KiB
Go
203 lines
7.7 KiB
Go
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}}
|
|
}
|
|
|
|
// routeCandidate builds an ActionCandidate matching a route-resolved Decision.
|
|
func routeCandidate(fn string) router.ActionCandidate {
|
|
return router.ActionCandidate{Fn: fn, Source: router.ActionSourceRoute}
|
|
}
|
|
|
|
// praxisItemDec is praxisActDec for the lifecycle verbs, which need an item id
|
|
// in the value slot. Without one they answer "which item?" and never reach
|
|
// Praxis at all, which makes them useless for testing a Praxis outage.
|
|
func praxisItemDec(fn, itemID string) router.Decision {
|
|
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true, Value: itemID}}
|
|
}
|
|
|
|
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"), routeCandidate("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"), routeCandidate("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"), routeCandidate("restart"))
|
|
if actRan(reply) {
|
|
t.Fatalf("nexus outage must not report success, got %q", reply)
|
|
}
|
|
|
|
nexus.SetFault(0)
|
|
reply = h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
|
if !actRan(reply) {
|
|
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
|
}
|
|
}
|
|
|
|
// TestPraxisEntityAttention_RemembersWhatItReadOut: the scoped digest is a list
|
|
// she read out, so a positional follow-up must land on one of ITS items. It
|
|
// surfaced them and remembered none, which left the previous digest live and
|
|
// sent "отметь второй" at somebody else's item.
|
|
func TestPraxisEntityAttention_RemembersWhatItReadOut(t *testing.T) {
|
|
ctx := context.Background()
|
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
|
scoped := fixturePraxisAttentionScoped("ent_muzick",
|
|
map[string]any{"id": "item_scoped_1", "title": "indexer wedged"})
|
|
praxis := newFakePraxis(t, scoped)
|
|
h := ecoHandler(t, nexus, praxis, nil)
|
|
|
|
// A digest from an earlier turn, still the positional memory.
|
|
h.rememberSurfaced([]string{"item_stale"})
|
|
|
|
reply := h.handlePraxisAct(ctx, router.Decision{
|
|
Intent: router.IntentAct,
|
|
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: "muzick indexer"},
|
|
}, routeCandidate("entity_attention"))
|
|
if !strings.Contains(reply, "indexer wedged") {
|
|
t.Fatalf("expected the scoped item to be read out, got %q", reply)
|
|
}
|
|
|
|
h.mu.Lock()
|
|
surfaced := append([]string(nil), h.surfacedItems...)
|
|
h.mu.Unlock()
|
|
if len(surfaced) != 1 || surfaced[0] != "item_scoped_1" {
|
|
t.Fatalf("scoped digest must replace the positional memory, got %v", surfaced)
|
|
}
|
|
|
|
// The follow-up resolves against what he just heard, not the stale list.
|
|
if reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "last"), routeCandidate("resolve_item")); reply == "" {
|
|
t.Fatal("positional follow-up should have been claimed by praxis")
|
|
}
|
|
var body string
|
|
for _, r := range praxis.Requests() {
|
|
if r.Method == "POST" && r.Path == "/api/v1/tools/resolve" {
|
|
body = string(r.Body)
|
|
}
|
|
}
|
|
if !strings.Contains(body, "item_scoped_1") {
|
|
t.Fatalf("resolve must transition the item she read out, posted %q", body)
|
|
}
|
|
if strings.Contains(body, "item_stale") {
|
|
t.Fatal("resolve transitioned an item from a previous digest")
|
|
}
|
|
}
|
|
|
|
// TestHexisConfirm_KeepsOneCorrelationIDPerAction: the confirm arrives on a
|
|
// later turn with a context of its own. The contract mints one id per action,
|
|
// so the execution it authorises must still be joinable to the resolve and the
|
|
// discovery that proposed it — it recorded a fresh id and no causation at all.
|
|
func TestHexisConfirm_KeepsOneCorrelationIDPerAction(t *testing.T) {
|
|
ctx := context.Background()
|
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
|
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
|
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
|
h := ecoHandler(t, nexus, nil, hexis)
|
|
|
|
if reply := h.handleHexisAct(ctx, actDec("restart"), routeCandidate("restart")); !strings.Contains(reply, "да") {
|
|
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
|
}
|
|
resolve := findTrace(t, h, "nexus", "resolve")
|
|
if resolve == nil || resolve.CorrelationID == "" {
|
|
t.Fatalf("expected a nexus resolve trace carrying a correlation id, got %+v", resolve)
|
|
}
|
|
|
|
if _, handled := h.resolveConfirm(ctx, "да"); !handled {
|
|
t.Fatal("confirm should have been claimed")
|
|
}
|
|
exec := findTrace(t, h, "hexis", "execute")
|
|
if exec == nil {
|
|
t.Fatal("expected a hexis execute trace")
|
|
}
|
|
if exec.CausationID != resolve.CorrelationID {
|
|
t.Fatalf("confirmed execution must cite the action that proposed it: causation %q, action %q",
|
|
exec.CausationID, resolve.CorrelationID)
|
|
}
|
|
}
|