Closes the last "Go primary" backlog item in docs/ARCHITECTURE_COMPARISON.md. Go now owns the entire validator path end-to-end — no Rust dep for staffing safety net. Architecture: cmd/validatord on :3221 hosts both endpoints. Calls chatd directly for the iterate loop's LLM hop (no gateway self-loopback like the Rust shape). Gateway proxies /v1/validate + /v1/iterate to validatord. What's in: - internal/validator/playbook.go — 3rd validator kind (PRD checks: fill: prefix, endorsed_names ≤ target_count×2, fingerprint required) - internal/validator/lookup_jsonl.go — JSONL roster loader (Parquet deferred; producer one-liner documented in package comment) - internal/validator/iterate.go — ExtractJSON helper + Iterate orchestrator with ChatCaller seam for unit tests - cmd/validatord/main.go — HTTP routes, roster load, chat client - internal/shared/config.go — ValidatordConfig + gateway URL field - lakehouse.toml — [validatord] section - cmd/gateway/main.go — proxy routes for /v1/validate + /v1/iterate Smoke: 5/5 PASS through gateway :3110: ✓ playbook happy path ✓ playbook missing fingerprint → 422 schema/fingerprint ✓ phantom candidate W-PHANTOM → 422 consistency ✓ unknown kind → 400 ✓ roster loaded with 3 records go test ./... green across 33 packages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
262 lines
8.5 KiB
Go
262 lines
8.5 KiB
Go
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())
|
|
}
|
|
}
|