package inference import ( "context" "encoding/json" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" "github.com/missionworkforce/missionmatch/internal/platform/scrub" ) func newTestClient(t *testing.T, h http.Handler) *Client { t.Helper() srv := httptest.NewServer(h) t.Cleanup(srv.Close) c, err := New(Config{BaseURL: srv.URL, EmbedModel: "all-minilm", Dimension: 384, Timeout: 2 * time.Second}) if err != nil { t.Fatalf("New: %v", err) } return c } func TestEmbedReturnsVectorsAndChecksDimension(t *testing.T) { c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req EmbedRequest _ = json.NewDecoder(r.Body).Decode(&req) out := EmbedResponse{Model: req.Model, Dimension: 384, Embeddings: make([][]float32, len(req.Inputs))} for i := range out.Embeddings { out.Embeddings[i] = make([]float32, 384) } _ = json.NewEncoder(w).Encode(out) })) res, err := c.Embed(context.Background(), []string{"a", "b"}) if err != nil { t.Fatalf("Embed: %v", err) } if len(res.Embeddings) != 2 || len(res.Embeddings[0]) != 384 { t.Fatalf("expected 2 vectors of width 384, got %d x %d", len(res.Embeddings), len(res.Embeddings[0])) } } func TestEmbedRejectsWrongDimension(t *testing.T) { c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { // A plane that returns 768 wide vectors would silently corrupt the vector(384) column. _ = json.NewEncoder(w).Encode(EmbedResponse{Dimension: 768, Embeddings: [][]float32{make([]float32, 768)}}) })) if _, err := c.Embed(context.Background(), []string{"x"}); err == nil { t.Fatal("Embed must hard fail when the plane returns the wrong embedding width") } } func TestRetriesOn5xxThenSucceeds(t *testing.T) { var calls int32 c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { if atomic.AddInt32(&calls, 1) < 3 { w.WriteHeader(http.StatusBadGateway) return } _ = json.NewEncoder(w).Encode(GenerateResponse{Text: "ok"}) })) c.cfg.MaxRetries = 3 res, err := c.Generate(context.Background(), GenerateRequest{Prompt: "hi"}) if err != nil { t.Fatalf("Generate should succeed after transient 5xx retries: %v", err) } if res.Text != "ok" || atomic.LoadInt32(&calls) != 3 { t.Fatalf("expected success on the 3rd attempt, got %q after %d calls", res.Text, calls) } } func TestNon5xxErrorIsNotRetried(t *testing.T) { var calls int32 c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { atomic.AddInt32(&calls, 1) w.WriteHeader(http.StatusBadRequest) })) c.cfg.MaxRetries = 3 if _, err := c.Generate(context.Background(), GenerateRequest{Prompt: "hi"}); err == nil { t.Fatal("a 400 must not be retried and must surface as an error") } if atomic.LoadInt32(&calls) != 1 { t.Fatalf("a 4xx must be tried exactly once, got %d calls", calls) } } func TestBreakerOpensAfterRepeatedFailures(t *testing.T) { c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) })) c.cfg.MaxRetries = 0 c.cb = newBreaker(3, time.Minute) for i := 0; i < 3; i++ { _, _ = c.Generate(context.Background(), GenerateRequest{Prompt: "x"}) } if _, err := c.Generate(context.Background(), GenerateRequest{Prompt: "x"}); err != ErrCircuitOpen { t.Fatalf("after repeated failures the breaker should fail fast with ErrCircuitOpen, got %v", err) } } func TestTeachEnforcesScrubBoundaryAndRespectsPosture(t *testing.T) { var transmitted int32 c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { atomic.AddInt32(&transmitted, 1) w.WriteHeader(http.StatusAccepted) })) // A signal carrying a client fact (a UUID in model_version) must be refused before any transmit. leak := scrub.TeachingSignal{Pattern: "adjacent_skill_substitution_outcome", Band: scrub.BandHigh, Outcome: scrub.OutcomeApproved, Observations: 1, Successes: 1, ModelVersion: "11111111-1111-1111-1111-111111111111"} if err := c.Teach(context.Background(), leak); err == nil { t.Fatal("Teach must refuse a signal carrying a client fact") } // A clean signal with teaching disabled (default posture) validates but does NOT transmit. clean := scrub.TeachingSignal{Pattern: "adjacent_skill_substitution_outcome", Band: scrub.BandHigh, Outcome: scrub.OutcomeApproved, Observations: 10, Successes: 7, ModelVersion: "v1.0.0"} if err := c.Teach(context.Background(), clean); err != nil { t.Fatalf("a clean signal should pass the boundary: %v", err) } if atomic.LoadInt32(&transmitted) != 0 { t.Fatal("with teaching disabled the signal must not be transmitted (federated loop is scaffolded, not active)") } }