package api import ( "net/http" "testing" "github.com/gin-gonic/gin" "gitea.stevedudenhoeffer.com/steve/pansy/internal/config" ) // agentCfg is a config with the assistant configured — a (fake) key, on, and a // resolvable model. The key isn't real, but ValidateAgentModel/NewRunner only // PARSE the spec (no live call), so a Runner still builds and capabilities // reports it live. That's enough to exercise the runtime on/off swap. func agentCfg() *config.Config { c := localCfg() c.Agent = config.AgentConfig{ Model: "ollama-cloud/glm-5.2:cloud", OllamaCloudAPIKey: "test-key", Enabled: true, } return c } func settingsVersion(t *testing.T, r *gin.Engine, cookie *http.Cookie) int64 { t.Helper() w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, cookie) if w.Code != http.StatusOK { t.Fatalf("get settings: status %d, body %s", w.Code, w.Body.String()) } st, _ := decodeMap(t, w.Body.Bytes())["settings"].(map[string]any) return int64(st["version"].(float64)) } // TestSettingsAdminOnly: the first registered user is admin and can read/write // settings; a second user is not and gets 403 (not 404 — settings aren't a // masked resource). func TestSettingsAdminOnly(t *testing.T) { r := authEngine(t, localCfg()) admin := registerAndCookie(t, r, "admin@example.com") // first user → admin member := registerAndCookie(t, r, "member@example.com") if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin); w.Code != http.StatusOK { t.Fatalf("admin GET settings: status %d, body %s", w.Code, w.Body.String()) } if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, member); w.Code != http.StatusForbidden { t.Errorf("member GET settings: status %d, want 403", w.Code) } if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{"agentModel": "x", "version": 1}, member); w.Code != http.StatusForbidden { t.Errorf("member PATCH settings: status %d, want 403", w.Code) } // Unauthenticated is 401, before the admin check. if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, nil); w.Code != http.StatusUnauthorized { t.Errorf("anonymous GET settings: status %d, want 401", w.Code) } } // TestSettingsInheritFromEnv: an untouched instance reports the env model as // effective, and an empty stored model keeps inheriting it. func TestSettingsInheritFromEnv(t *testing.T) { r := authEngine(t, agentCfg()) admin := registerAndCookie(t, r, "admin@example.com") w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin) if w.Code != http.StatusOK { t.Fatalf("get: status %d, body %s", w.Code, w.Body.String()) } body := decodeMap(t, w.Body.Bytes()) st := body["settings"].(map[string]any) eff := body["effective"].(map[string]any) if st["agentModel"] != "" { t.Errorf("stored model = %v, want empty (inherit)", st["agentModel"]) } if eff["model"] != "ollama-cloud/glm-5.2:cloud" { t.Errorf("effective model = %v, want the env value", eff["model"]) } if eff["hasApiKey"] != true || eff["agentLive"] != true { t.Errorf("effective = %+v, want a key present and the agent live", eff) } } // TestSettingsUpdateSwapsTheRunner is the core of #79: changing settings takes // effect on the LIVE assistant, with no restart. Driven entirely through HTTP. func TestSettingsUpdateSwapsTheRunner(t *testing.T) { r := authEngine(t, agentCfg()) admin := registerAndCookie(t, r, "admin@example.com") capsAgent := func() bool { w := doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin) return decodeMap(t, w.Body.Bytes())["agent"] == true } // Configured and on out of the box. if !capsAgent() { t.Fatal("assistant should be live at boot with a key + enabled") } // Turn it OFF via settings → capabilities flips immediately. v := settingsVersion(t, r, admin) w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin) if w.Code != http.StatusOK { t.Fatalf("disable: status %d, body %s", w.Code, w.Body.String()) } if capsAgent() { t.Error("assistant still live after being disabled — the runner wasn't swapped") } // Chat now refuses, at runtime, on a route that still exists. gid := createGardenAPI(t, r, admin, "G") if w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat", map[string]any{"gardenId": gid, "message": "hi"}, admin); w.Code != http.StatusServiceUnavailable { t.Errorf("chat while disabled: status %d, want 503", w.Code) } // Turn it back ON, with an explicit model, and confirm it's live again and the // effective model reflects the change. v = settingsVersion(t, r, admin) w = doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{ "agentModel": "ollama-cloud/kimi-k2.6:cloud", "agentEnabled": true, "version": v, }, admin) if w.Code != http.StatusOK { t.Fatalf("re-enable: status %d, body %s", w.Code, w.Body.String()) } if eff := decodeMap(t, w.Body.Bytes())["effective"].(map[string]any); eff["model"] != "ollama-cloud/kimi-k2.6:cloud" { t.Errorf("effective model = %v after change, want the new one", eff["model"]) } if !capsAgent() { t.Error("assistant not live after being re-enabled") } } // TestSettingsRejectsBadModel: a spec that won't resolve is a 400 at save time, // not a broken assistant on the next turn. func TestSettingsRejectsBadModel(t *testing.T) { r := authEngine(t, agentCfg()) admin := registerAndCookie(t, r, "admin@example.com") v := settingsVersion(t, r, admin) if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{"agentModel": "nonesuch/model", "version": v}, admin); w.Code != http.StatusBadRequest { t.Errorf("bad model: status %d, want 400", w.Code) } // agentEnabled must be a bool or null, not a string. if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest { t.Errorf("string agentEnabled: status %d, want 400", w.Code) } } // TestSettingsVersionConflict: a stale version 409s and carries the current row. func TestSettingsVersionConflict(t *testing.T) { r := authEngine(t, agentCfg()) admin := registerAndCookie(t, r, "admin@example.com") v := settingsVersion(t, r, admin) // First write succeeds and bumps the version. if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin); w.Code != http.StatusOK { t.Fatalf("first update: status %d, body %s", w.Code, w.Body.String()) } // Reusing the old version conflicts. w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{"agentModel": "", "agentEnabled": true, "version": v}, admin) if w.Code != http.StatusConflict { t.Fatalf("stale update: status %d, want 409", w.Code) } if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["version"].(float64) != float64(v+1) { t.Errorf("409 body missing the current row at the bumped version: %s", w.Body.String()) } } // TestSettingsSwapUnderRace runs settings saves concurrently with chat requests, // so `go test -race` proves the atomic swap of the live Runner is safe against // in-flight readers. The whole point of the atomic.Pointer is this: without it, // toggling the assistant while a request reads it is a data race. func TestSettingsSwapUnderRace(t *testing.T) { r := authEngine(t, agentCfg()) admin := registerAndCookie(t, r, "admin@example.com") done := make(chan struct{}) // Readers hammer capabilities, whose h.agent.get() is the SAME atomic Load the // chat handler does — so this races the pointer read against the writer's swap // without ever invoking the model (a real Run would hit the network on a fake // key). If get() is race-clean here it is race-clean in chat. for i := 0; i < 4; i++ { go func() { for { select { case <-done: return default: doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin) } } }() } // Writer: flip the assistant on and off, swapping the pointer each time. for i := 0; i < 12; i++ { v := settingsVersion(t, r, admin) doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{"agentModel": "", "agentEnabled": i%2 == 0, "version": v}, admin) } close(done) }