package main import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/go-chi/chi/v5" "git.agentview.dev/profit/golangLAKEHOUSE/internal/validator" ) // newTestRouter builds the validatord router with an explicit lookup // + a fake chatd URL. Tests that exercise /iterate need a live mock // chatd (constructed inline per-test). func newTestRouter(lookup validator.WorkerLookup, chatdURL string) http.Handler { h := &handlers{ lookup: lookup, chatdURL: chatdURL, chatClient: &http.Client{Timeout: 5 * time.Second}, iterCfg: validator.IterateConfig{ DefaultMaxIterations: 3, DefaultMaxTokens: 4096, }, } r := chi.NewRouter() h.register(r) return r } // ─── /validate ───────────────────────────────────────────────── func TestValidate_RejectsUnknownKind(t *testing.T) { r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "") body := []byte(`{"kind":"unknown","artifact":{}}`) req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body)) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for unknown kind, got %d (body=%s)", w.Code, w.Body.String()) } } func TestValidate_RejectsMissingArtifact(t *testing.T) { r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "") body := []byte(`{"kind":"playbook"}`) req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body)) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for missing artifact, got %d", w.Code) } } func TestValidate_PlaybookHappyPath(t *testing.T) { r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "") body := []byte(`{ "kind": "playbook", "artifact": { "operation": "fill: Welder x2 in Toledo, OH", "endorsed_names": ["W-1","W-2"], "target_count": 2, "fingerprint": "abc123" } }`) req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body)) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String()) } var report validator.Report if err := json.Unmarshal(w.Body.Bytes(), &report); err != nil { t.Fatalf("decode response: %v", err) } if report.ElapsedMs < 0 { t.Errorf("elapsed_ms negative: %d", report.ElapsedMs) } } func TestValidate_PlaybookSchemaErrorReturns422(t *testing.T) { r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "") body := []byte(`{ "kind": "playbook", "artifact": { "operation": "wrong_prefix: foo", "endorsed_names": ["a"], "fingerprint": "x" } }`) req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body)) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d (body=%s)", w.Code, w.Body.String()) } var ve validator.ValidationError if err := json.Unmarshal(w.Body.Bytes(), &ve); err != nil { t.Fatalf("decode: %v", err) } if ve.Kind != validator.ErrSchema { t.Errorf("kind = %v, want schema", ve.Kind) } } func TestValidate_FillRoutesThroughLookup(t *testing.T) { city := "Toledo" lookup := validator.NewInMemoryWorkerLookup([]validator.WorkerRecord{ {CandidateID: "W-1", Name: "Ada", Status: "active", City: &city}, }) r := newTestRouter(lookup, "") // Candidate that doesn't exist in lookup → consistency failure. body := []byte(`{ "kind": "fill", "artifact": { "fills": [{"candidate_id":"W-PHANTOM","name":"Nobody"}] }, "context": {"target_count": 1, "city": "Toledo", "client_id": "C-1"} }`) req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body)) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusUnprocessableEntity { t.Fatalf("expected 422 for phantom candidate, got %d (body=%s)", w.Code, w.Body.String()) } } func TestValidate_ContextMergedIntoArtifactContext(t *testing.T) { // _context.target_count from the request `context` block must // reach the FillValidator's completeness check. Without the // merge, target_count would default to 0 and any non-empty fills // list would fail Completeness. city := "Toledo" role := "Welder" lookup := validator.NewInMemoryWorkerLookup([]validator.WorkerRecord{ {CandidateID: "W-1", Name: "Ada", Status: "active", City: &city, Role: &role}, }) r := newTestRouter(lookup, "") body := []byte(`{ "kind": "fill", "artifact": {"fills":[{"candidate_id":"W-1","name":"Ada"}]}, "context": {"target_count": 1, "city": "Toledo", "role": "Welder", "client_id": "C-1"} }`) req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body)) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 with context merged, got %d (body=%s)", w.Code, w.Body.String()) } } // ─── /iterate ────────────────────────────────────────────────── // fakeChatd returns a stand-in chatd HTTP server that emits the given // content string for every /chat call. Caller closes the server. func fakeChatd(t *testing.T, content string) *httptest.Server { t.Helper() mux := chi.NewRouter() mux.Post("/chat", func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ "model": "test-model", "content": content, "provider": "test", "latency_ms": 1, }) }) return httptest.NewServer(mux) } func TestIterate_RejectsMissingFields(t *testing.T) { r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "") body := []byte(`{"kind":"playbook","prompt":"x"}`) // missing provider+model req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body)) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestIterate_HappyPath_ReturnsAcceptedArtifact(t *testing.T) { server := fakeChatd(t, `{"operation":"fill: Welder x1 in Toledo, OH","endorsed_names":["W-1"],"target_count":1,"fingerprint":"abc"}`) defer server.Close() r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), server.URL) body, _ := json.Marshal(map[string]any{ "kind": "playbook", "prompt": "produce a playbook artifact", "provider": "ollama", "model": "qwen3.5:latest", }) req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body)) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String()) } var resp validator.IterateResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if resp.Iterations != 1 { t.Errorf("iterations = %d, want 1", resp.Iterations) } if resp.Artifact["operation"] != "fill: Welder x1 in Toledo, OH" { t.Errorf("artifact.operation: %v", resp.Artifact["operation"]) } } func TestIterate_MaxIterReturns422WithHistory(t *testing.T) { // Always returns a no-JSON response, so iterate exhausts retries. server := fakeChatd(t, "no json here, just prose") defer server.Close() r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), server.URL) body, _ := json.Marshal(map[string]any{ "kind": "playbook", "prompt": "produce X", "provider": "ollama", "model": "x", "max_iterations": 2, }) req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body)) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d (body=%s)", w.Code, w.Body.String()) } var fail validator.IterateFailure if err := json.Unmarshal(w.Body.Bytes(), &fail); err != nil { t.Fatalf("decode: %v", err) } if fail.Iterations != 2 { t.Errorf("iterations = %d, want 2", fail.Iterations) } for _, h := range fail.History { if h.Status.Kind != "no_json" { t.Errorf("expected all attempts to be no_json, got %v", h.Status.Kind) } } } func TestIterate_ChatdDownReturns502(t *testing.T) { r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "http://127.0.0.1:1") // unroutable body, _ := json.Marshal(map[string]any{ "kind": "playbook", "prompt": "X", "provider": "ollama", "model": "x", }) req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body)) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusBadGateway { t.Fatalf("expected 502, got %d (body=%s)", w.Code, w.Body.String()) } }