package web import ( "context" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "antidrift/internal/ai" "antidrift/internal/domain" "antidrift/internal/evidence" "antidrift/internal/session" "github.com/gin-gonic/gin" ) func newTestServer(t *testing.T) *Server { t.Helper() gin.SetMode(gin.TestMode) path := filepath.Join(t.TempDir(), "state.json") ctrl, err := session.New(path) if err != nil { t.Fatalf("controller: %v", err) } return NewServer(ctrl) } func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) return w } func TestPlanningThenCommitmentReachesActive(t *testing.T) { s := newTestServer(t) r := s.Router() if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { t.Fatalf("/planning code %d", w.Code) } body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}` if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) } if s.ctrl.State().RuntimeState != domain.RuntimeActive { t.Fatalf("expected Active, got %s", s.ctrl.State().RuntimeState) } } func TestCommitmentRejectsInvalidInput(t *testing.T) { s := newTestServer(t) r := s.Router() _ = post(t, r, "/planning", "") body := `{"next_action":"","success_condition":"x","timebox_secs":1500}` w := post(t, r, "/commitment", body) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestIllegalTransitionReturns409(t *testing.T) { s := newTestServer(t) r := s.Router() // /complete from Locked is illegal. w := post(t, r, "/complete", "") if w.Code != http.StatusConflict { t.Fatalf("expected 409, got %d", w.Code) } } func TestActiveStatePayloadCarriesEvidence(t *testing.T) { s := newTestServer(t) r := s.Router() _ = post(t, r, "/planning", "") body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}` if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) } // A focus update should appear in the serialized state. s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "antidrift", Health: evidence.EvidenceHealth{Available: true}}) js := s.stateJSON() if !strings.Contains(js, `"evidence"`) { t.Fatalf("payload missing evidence object: %s", js) } if !strings.Contains(js, `"class":"code"`) { t.Fatalf("payload missing current window: %s", js) } } func TestLockedStateHasNullEvidence(t *testing.T) { s := newTestServer(t) js := s.stateJSON() if !strings.Contains(js, `"evidence":null`) { t.Fatalf("locked payload should have null evidence: %s", js) } } type stubCoach struct { prop ai.Proposal err error } func (s stubCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) { return s.prop, s.err } func TestCoachRouteHappyPath(t *testing.T) { s := newTestServer(t) s.ctrl.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}}) r := s.Router() _ = post(t, r, "/planning", "") if w := post(t, r, "/coach", `{"intent":"do something"}`); w.Code != http.StatusOK { t.Fatalf("/coach code %d body %s", w.Code, w.Body.String()) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if cv := s.ctrl.State().Coach; cv != nil && cv.Status == "ready" { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("coach never reached ready: %+v", s.ctrl.State().Coach) } func TestCoachRouteOutsidePlanning(t *testing.T) { s := newTestServer(t) s.ctrl.SetCoach(stubCoach{}) r := s.Router() if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestCoachRouteInvalidJSON(t *testing.T) { s := newTestServer(t) r := s.Router() _ = post(t, r, "/planning", "") if w := post(t, r, "/coach", `not json`); w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestCoachRouteUnavailableDegrades(t *testing.T) { s := newTestServer(t) // no SetCoach r := s.Router() _ = post(t, r, "/planning", "") if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusOK { t.Fatalf("unavailable coach should still 200, got %d", w.Code) } if cv := s.ctrl.State().Coach; cv == nil || cv.Status != "error" { t.Fatalf("want error status, got %+v", cv) } } func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) { s := newTestServer(t) r := s.Router() if w := post(t, r, "/refocus", ""); w.Code != http.StatusBadRequest { t.Fatalf("refocus outside Active: want 400, got %d", w.Code) } if w := post(t, r, "/ontask", ""); w.Code != http.StatusBadRequest { t.Fatalf("ontask outside Active: want 400, got %d", w.Code) } } func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) { s := newTestServer(t) r := s.Router() _ = post(t, r, "/planning", "") body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"]}` if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { t.Fatalf("commitment: want 200, got %d (%s)", w.Code, w.Body.String()) } if got := s.ctrl.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" { t.Fatalf("commitment did not carry allowed classes: %#v", got) } // Now Active: overrides succeed. if w := post(t, r, "/ontask", ""); w.Code != http.StatusOK { t.Fatalf("ontask while Active: want 200, got %d", w.Code) } if w := post(t, r, "/refocus", ""); w.Code != http.StatusOK { t.Fatalf("refocus while Active: want 200, got %d", w.Code) } }