03fa52dfd4
facts gain a Subject/EntityID/ResolutionState triple and an async enrichment worker that resolves free-text subjects to canonical Nexus entity_ids, mirroring Praxis's enrichment-worker pattern. Ambiguous or unreachable Nexus never guesses an entity_id — the fact stays pending or terminal-ambiguous instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ghELqYhZNLub2TXGMazqA
101 lines
3.1 KiB
Go
101 lines
3.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
func TestFactEnrichmentWorker_ResolvesPendingSubject(t *testing.T) {
|
|
ctx := context.Background()
|
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
|
|
st := newTestStore(t)
|
|
|
|
id, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes", "the espresso machine", `"true"`, "infer:pref", 0.8, sql.NullInt64{})
|
|
if err != nil {
|
|
t.Fatalf("WriteFactAboutSubject: %v", err)
|
|
}
|
|
|
|
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
|
|
w.tick(ctx)
|
|
|
|
facts, err := st.FactsByEntity(ctx, "ent_espresso", 10)
|
|
if err != nil {
|
|
t.Fatalf("FactsByEntity: %v", err)
|
|
}
|
|
if len(facts) != 1 || facts[0].ID != id {
|
|
t.Fatalf("expected fact %d resolved to ent_espresso, got %+v", id, facts)
|
|
}
|
|
|
|
pending, err := st.PendingFactResolutions(ctx, 10)
|
|
if err != nil {
|
|
t.Fatalf("PendingFactResolutions: %v", err)
|
|
}
|
|
if len(pending) != 0 {
|
|
t.Fatalf("expected no facts left pending, got %+v", pending)
|
|
}
|
|
}
|
|
|
|
// TestFactEnrichmentWorker_NexusDownLeavesFactPending covers the fail-closed
|
|
// contract: a Nexus outage must not mark a fact resolved/not_found — it
|
|
// should stay pending so a later successful tick can still resolve it.
|
|
func TestFactEnrichmentWorker_NexusDownLeavesFactPending(t *testing.T) {
|
|
ctx := context.Background()
|
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
|
|
nexus.SetFault(503)
|
|
st := newTestStore(t)
|
|
|
|
id, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes", "the espresso machine", `"true"`, "infer:pref", 0.8, sql.NullInt64{})
|
|
if err != nil {
|
|
t.Fatalf("WriteFactAboutSubject: %v", err)
|
|
}
|
|
|
|
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
|
|
w.tick(ctx)
|
|
|
|
pending, err := st.PendingFactResolutions(ctx, 10)
|
|
if err != nil {
|
|
t.Fatalf("PendingFactResolutions: %v", err)
|
|
}
|
|
if len(pending) != 1 || pending[0].ID != id {
|
|
t.Fatalf("expected fact %d to remain pending during nexus outage, got %+v", id, pending)
|
|
}
|
|
}
|
|
|
|
func TestFactEnrichmentWorker_AmbiguousLeavesEntityIDUnset(t *testing.T) {
|
|
ctx := context.Background()
|
|
nexus := newFakeNexus(t, fixtureNexusAmbiguous(
|
|
map[string]string{"entity_id": "ent_kate_1", "display_name": "Kate Smith"},
|
|
map[string]string{"entity_id": "ent_kate_2", "display_name": "Kate Jones"},
|
|
))
|
|
st := newTestStore(t)
|
|
|
|
id, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes", "kate", `"true"`, "infer:pref", 0.8, sql.NullInt64{})
|
|
if err != nil {
|
|
t.Fatalf("WriteFactAboutSubject: %v", err)
|
|
}
|
|
|
|
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
|
|
w.tick(ctx)
|
|
|
|
pending, err := st.PendingFactResolutions(ctx, 10)
|
|
if err != nil {
|
|
t.Fatalf("PendingFactResolutions: %v", err)
|
|
}
|
|
if len(pending) != 0 {
|
|
t.Fatalf("ambiguous resolution should leave the pending queue, got %+v", pending)
|
|
}
|
|
|
|
facts, err := st.FactsByEntity(ctx, "ent_kate_1", 10)
|
|
if err != nil {
|
|
t.Fatalf("FactsByEntity: %v", err)
|
|
}
|
|
if len(facts) != 0 {
|
|
t.Fatalf("ambiguous resolution must not guess an entity_id, got %+v", facts)
|
|
}
|
|
_ = id
|
|
}
|