test: make the ecosystem fault suite fail when the feature is deleted

Several assertions passed against code with the behaviour removed. The
independent-outage test shared no state to begin with, the capability
fixture used to prove read-only filtering was already mutating, and
route-level faults were simulated with a separate fake instead of the
shared one. The harness now takes per-route faults and a ticking clock,
so durations are measurable and one dead endpoint can be shown not to
mute a whole service. New cases cover a resolved reference with no
entity, a rejected credential, a malformed Praxis body, foreign items
in a scoped response, named truncation, traces staying out of facts,
and enrichment making progress while its oldest batch is backed off.

Found in review of #82.
This commit is contained in:
kami
2026-08-01 14:14:19 +04:00
parent 802d5961ac
commit 617476772e
5 changed files with 409 additions and 45 deletions
+147 -2
View File
@@ -28,7 +28,7 @@ func entityAttentionDec(subject string) router.Decision {
func TestEntityAttention_ScopesPraxisByCanonicalID(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
praxis := newFakePraxis(t, fixturePraxisAttentionScoped("ent_muzick",
map[string]any{"id": "item_1", "title": "indexer queue is backing up", "importance": 3.0},
))
h := ecoHandler(t, nexus, praxis, nil)
@@ -76,6 +76,76 @@ func TestEntityAttention_FoldsInLocalFactsForSameEntity(t *testing.T) {
}
}
// TestEntityAttention_UnscopedPraxisResponseIsRefused: a Praxis old enough to
// ignore the entity_id parameter answers the scoped question with the whole
// unscoped list. Relabelling those items "по «X»" is the same fabrication the
// canonical ref exists to prevent, arriving through a different door.
func TestEntityAttention_UnscopedPraxisResponseIsRefused(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
))
h := ecoHandler(t, nexus, praxis, nil)
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
if strings.Contains(reply, "disk almost full") {
t.Fatalf("an unscoped response must not be read back as entity-scoped, got %q", reply)
}
if reply == "" {
t.Fatal("refusing the answer must still say something")
}
if praxis.Count("POST", "/api/v1/tools/surface") != 0 {
t.Error("items that were never spoken must not be surfaced")
}
}
// TestEntityAttention_ForeignItemsAreDropped: items tagged with another entity
// are dropped rather than spoken under this entity's name.
func TestEntityAttention_ForeignItemsAreDropped(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
mixed := []map[string]any{
{"id": "item_1", "title": "indexer queue is backing up", "importance": 3.0, "entity_id": "ent_muzick"},
{"id": "item_2", "title": "the kettle is descaling", "importance": 1.0, "entity_id": "ent_kettle"},
}
praxis := newFakePraxis(t, mustJSON(mixed))
h := ecoHandler(t, nexus, praxis, nil)
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
if !strings.Contains(reply, "indexer queue is backing up") {
t.Fatalf("the matching item must be spoken, got %q", reply)
}
if strings.Contains(reply, "kettle") {
t.Fatalf("another entity's item must not be spoken here, got %q", reply)
}
}
// TestEntityAttention_TruncationIsNamed: reading three of many remembered
// facts must not be presented as everything she knows.
func TestEntityAttention_TruncationIsNamed(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
h := ecoHandler(t, nexus, praxis, nil)
for i := 0; i < 5; i++ {
id, err := h.dataStore.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv,
"note", "the espresso machine", "факт "+string(rune('а'+i)), "infer:pref", 0.8, sql.NullInt64{})
if err != nil {
t.Fatalf("WriteFactAboutSubject: %v", err)
}
if err := h.dataStore.ResolveFactEntity(ctx, id, "ent_espresso", store.ResolutionResolved); err != nil {
t.Fatalf("ResolveFactEntity: %v", err)
}
}
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"))
if !strings.Contains(reply, "и это не всё") {
t.Fatalf("a truncated recall must say it is truncated, got %q", reply)
}
}
// TestEntityAttention_AmbiguousAsksInsteadOfGuessing.
func TestEntityAttention_AmbiguousAsksInsteadOfGuessing(t *testing.T) {
ctx := context.Background()
@@ -145,7 +215,7 @@ func TestEntityAttention_WithoutNexusSaysSo(t *testing.T) {
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
if strings.Contains(reply, "disk almost full") {
t.Fatalf("unscoped items must not be passed off as entity-scoped, got %q", reply)
t.Fatalf("without nexus, items must not be passed off as entity-scoped, got %q", reply)
}
if praxis.Count("GET", "/api/v1/tools/attention") != 0 {
t.Fatal("no canonical ref means no scoped query at all")
@@ -211,3 +281,78 @@ func TestEnrichmentBackoff_GrowsAndIsCapped(t *testing.T) {
t.Fatalf("backoff must cap at an hour, got %v", enrichmentBackoff(50))
}
}
// TestEnrichment_BackedOffFactsDoNotStallTheQueue: the pending queue is ordered
// by id, so the oldest facts are pulled first whether or not they are eligible.
// A batch of facts in backoff at the head must not hold every slot and stop
// enrichment for everything younger.
func TestEnrichment_BackedOffFactsDoNotStallTheQueue(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
total := 5
for i := 0; i < total; i++ {
if _, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes",
"subject-"+string(rune('a'+i)), `"true"`, "infer:pref", 0.8, sql.NullInt64{}); err != nil {
t.Fatalf("WriteFactAboutSubject: %v", err)
}
}
nexus := newFakeNexus(t, fixtureNexusResolved("ent_x", "X", "service"))
clock := newFakeClock(time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC))
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
w.now = clock.Now
// A batch smaller than the queue, so with no scan the last fact never
// reaches the head while the first ones are backed off.
w.batch = total - 1
nexus.SetFault(503)
w.tick(ctx)
if got := nexus.Count("POST", "/api/v1/resolve"); got != total-1 {
t.Fatalf("expected the first batch attempted, got %d calls", got)
}
// Second tick with Nexus healthy: the backed-off head must be skipped and
// the fact behind it resolved, not the same batch pulled and dropped.
nexus.SetFault(0)
w.tick(ctx)
facts, err := st.FactsByEntity(ctx, "ent_x", 10)
if err != nil {
t.Fatalf("FactsByEntity: %v", err)
}
if len(facts) == 0 {
t.Fatal("a due fact behind a backed-off batch must still be resolved")
}
}
// TestEnrichment_StoreWriteFailureBacksOffToo: the one failure mode where the
// resolve worked and the write did not must be paced like any other, not
// retried at full rate forever.
func TestEnrichment_StoreWriteFailureBacksOffToo(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
st := newTestStore(t)
if _, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes",
"the espresso machine", `"true"`, "infer:pref", 0.8, sql.NullInt64{}); err != nil {
t.Fatalf("WriteFactAboutSubject: %v", err)
}
pending, err := st.PendingFactResolutions(ctx, 10)
if err != nil || len(pending) != 1 {
t.Fatalf("setup: pending = %+v, %v", pending, err)
}
clock := newFakeClock(time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC))
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
w.now = clock.Now
// Closing the store makes the resolution write fail while the Nexus call
// still succeeds — the split this path gets wrong.
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
if w.resolveOne(ctx, pending[0]) {
t.Fatal("a failed store write must not report success")
}
if w.due(pending[0].ID) {
t.Fatal("a failed store write must back the fact off like a failed resolve")
}
}