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:
@@ -27,11 +27,12 @@ type capturedRequest struct {
|
||||
type fakeServer struct {
|
||||
*httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
requests []capturedRequest
|
||||
fault int // non-zero: every request gets this HTTP status instead of routing
|
||||
garbage string // non-empty: returned 200 verbatim instead of routing (malformed-contract lever)
|
||||
delay time.Duration
|
||||
mu sync.Mutex
|
||||
requests []capturedRequest
|
||||
fault int // non-zero: every request gets this HTTP status instead of routing
|
||||
routeFaults map[string]int // path prefix → status, for one endpoint failing alone
|
||||
garbage string // non-empty: returned 200 verbatim instead of routing (malformed-contract lever)
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
// newFakeServer starts a server dispatching to routes keyed by "METHOD
|
||||
@@ -59,6 +60,14 @@ func newFakeServer(t *testing.T, routes map[string]http.HandlerFunc) *fakeServer
|
||||
Header: r.Header.Clone(),
|
||||
})
|
||||
fault := fs.fault
|
||||
if fault == 0 {
|
||||
for prefix, status := range fs.routeFaults {
|
||||
if hasPrefix(r.URL.Path, prefix) {
|
||||
fault = status
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
garbage := fs.garbage
|
||||
delay := fs.delay
|
||||
fs.mu.Unlock()
|
||||
@@ -114,6 +123,22 @@ func (fs *fakeServer) SetFault(status int) {
|
||||
fs.fault = status
|
||||
}
|
||||
|
||||
// SetRouteFault fails one endpoint while the rest of the server stays healthy,
|
||||
// which is the shape most real outages take: attention answers and pin is
|
||||
// down. Pass 0 to clear that route. A server-wide SetFault still wins.
|
||||
func (fs *fakeServer) SetRouteFault(pathPrefix string, status int) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
if fs.routeFaults == nil {
|
||||
fs.routeFaults = map[string]int{}
|
||||
}
|
||||
if status == 0 {
|
||||
delete(fs.routeFaults, pathPrefix)
|
||||
return
|
||||
}
|
||||
fs.routeFaults[pathPrefix] = status
|
||||
}
|
||||
|
||||
// SetBody makes every subsequent request answer 200 with the given body,
|
||||
// bypassing the route table. Used to serve a malformed or contract-violating
|
||||
// payload where the transport itself is healthy. Pass "" to clear it.
|
||||
@@ -198,6 +223,14 @@ func fixtureNexusResolvedFuture(entityID, displayName, entityType string) string
|
||||
})
|
||||
}
|
||||
|
||||
// fixtureNexusResolvedEmpty is the contract violation that decodes cleanly:
|
||||
// Nexus claims a resolve and delivers no entity. It must not read as "no such
|
||||
// entity", which would let the caller fall through to local execution with the
|
||||
// user's verb intact.
|
||||
func fixtureNexusResolvedEmpty() string {
|
||||
return `{"status":"resolved"}`
|
||||
}
|
||||
|
||||
func fixtureNexusNotFound() string {
|
||||
return `{"status":"not_found"}`
|
||||
}
|
||||
@@ -225,6 +258,16 @@ func fixtureHexisExecutionFailed(id, message string) string {
|
||||
return mustJSON(map[string]any{"id": id, "status": "failed", "error": message})
|
||||
}
|
||||
|
||||
// fixturePraxisAttentionScoped tags each item with an entity_id, which is what
|
||||
// a Praxis that understands the entity_id query parameter returns. A Praxis
|
||||
// that ignores it answers with untagged items from every entity.
|
||||
func fixturePraxisAttentionScoped(entityID string, items ...map[string]any) string {
|
||||
for _, item := range items {
|
||||
item["entity_id"] = entityID
|
||||
}
|
||||
return mustJSON(items)
|
||||
}
|
||||
|
||||
func fixturePraxisAttentionItems(items ...map[string]any) string {
|
||||
return mustJSON(items)
|
||||
}
|
||||
@@ -243,18 +286,28 @@ func mustJSON(v any) string {
|
||||
// (e.g. asserting age-based digest ordering without sleeping).
|
||||
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
step time.Duration // advanced on every read, so elapsed time is measurable
|
||||
}
|
||||
|
||||
func newFakeClock(start time.Time) *fakeClock {
|
||||
return &fakeClock{t: start}
|
||||
}
|
||||
|
||||
// newTickingClock advances by step on every read. Durations measured across
|
||||
// hops are then non-zero without sleeping, which is what lets a test tell a
|
||||
// trace that measured something from one that measured nothing.
|
||||
func newTickingClock(start time.Time, step time.Duration) *fakeClock {
|
||||
return &fakeClock{t: start, step: step}
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
now := c.t
|
||||
c.t = c.t.Add(c.step)
|
||||
return now
|
||||
}
|
||||
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
|
||||
Reference in New Issue
Block a user