- Garden and plant dialogs keep centimeters as the source of truth (LengthField in lib/units.ts): a no-change Save no longer rewrites 900 cm as 899.922 or a 45 cm spacing as 44.958, bumping versions and writing bogus history entries on the way. - The UI stamps every date with the browser's local day (lib/dates.ts). Journal notes already did; plop placement, fill and removal now do too, so a 9 pm placement isn't "planted tomorrow". The fill endpoint gained an optional plantedAt; API and agent callers still default to UTC today. - Removing an object that holds plants asks first and says how many go with it. An empty one still goes straight away (one Undo restores it). - The expanded plant card's action row wraps instead of clipping "Delete". - Monogram lettering switches to a dark ink on pale marker colors (garlic, cabbage, marigold) instead of near-white on near-white. - Copy-as-plan proposes the next free year and warns when the typed name already exists, so two gardens can't both read as "the 2027 plan". - Plan cards show the base name with a "2027 plan" tag, so the year — the point of the name — survives truncation. - A rejected model spec now says which model and why: a wrapped ErrInvalidInput's reason reaches the client as the 400's message, and the Settings field shows it inline instead of toasting "invalid input". Also defuses a clock bomb in TestRemainingReturnsWhenAPlantingIsRemoved, which only passed while the real date was before 2026-08-01. Co-Authored-By: Claude Fable 5 <[email protected]>
242 lines
9.5 KiB
Go
242 lines
9.5 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"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, "[email protected]") // first user → admin
|
|
member := registerAndCookie(t, r, "[email protected]")
|
|
|
|
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, "[email protected]")
|
|
|
|
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)
|
|
}
|
|
|
|
// The read-only sign-in view the Settings page renders under "Who gets in".
|
|
// These come straight from the environment config, so the shape is what's
|
|
// asserted: a registration mode, a local-auth flag, and no secret material.
|
|
auth, ok := body["auth"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("settings response has no auth view: %v", body)
|
|
}
|
|
if reg := auth["registration"]; reg != "open" && reg != "closed" {
|
|
t.Errorf("auth.registration = %v, want open or closed", reg)
|
|
}
|
|
if _, isBool := auth["localAuth"].(bool); !isBool {
|
|
t.Errorf("auth.localAuth = %v, want a bool", auth["localAuth"])
|
|
}
|
|
for _, k := range []string{"clientId", "clientSecret", "oidcClientSecret"} {
|
|
if _, present := auth[k]; present {
|
|
t.Errorf("auth view exposes %q — secrets must never leave the environment", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, "[email protected]")
|
|
|
|
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, "[email protected]")
|
|
v := settingsVersion(t, r, admin)
|
|
|
|
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("bad model: status %d, want 400", w.Code)
|
|
}
|
|
// The message says which field and which spec, so the page can show a reason
|
|
// rather than a bare "invalid input".
|
|
var body struct {
|
|
Error struct{ Code, Message string } `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode bad-model body: %v", err)
|
|
}
|
|
if body.Error.Code != "INVALID_INPUT" || !strings.Contains(body.Error.Message, "chat model") || !strings.Contains(body.Error.Message, "nonesuch/model") {
|
|
t.Errorf("bad model error = %+v, want INVALID_INPUT naming the chat model and spec", body.Error)
|
|
}
|
|
// 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, "[email protected]")
|
|
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, "[email protected]")
|
|
|
|
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)
|
|
}
|