c855731c39
A UI save was silently blanking the configured AW URL because handlePostSettings bound the whole Settings from the request and saved it as-is. Now reads s.settings.AWURL under the existing settingsMu lock and carries it forward when req.AWURL is empty, so fields not exposed in the form are never erased.
223 lines
6.5 KiB
Go
223 lines
6.5 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"keel/internal/settings"
|
|
)
|
|
|
|
// fakeApplier records the last settings it was asked to apply and can be told to
|
|
// reject with ErrInvalidBackend.
|
|
type fakeApplier struct {
|
|
called int
|
|
last settings.Settings
|
|
reject bool
|
|
}
|
|
|
|
func (f *fakeApplier) apply(s settings.Settings) error {
|
|
if f.reject {
|
|
return settings.ErrInvalidBackend
|
|
}
|
|
f.called++
|
|
f.last = s
|
|
return nil
|
|
}
|
|
|
|
func TestGetSettingsReturnsCurrent(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"}
|
|
fa := &fakeApplier{}
|
|
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply)
|
|
r := s.Router()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", w.Code)
|
|
}
|
|
var got settings.Settings
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if got != cur {
|
|
t.Errorf("got %+v, want %+v", got, cur)
|
|
}
|
|
}
|
|
|
|
func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
path := filepath.Join(t.TempDir(), "settings.json")
|
|
fa := &fakeApplier{}
|
|
s.SetSettings(path, settings.Settings{}, fa.apply)
|
|
r := s.Router()
|
|
|
|
body := `{"ai_backend":"codex","marvin_cmd":"uv run am","knowledge_path":"/tmp/k.md"}`
|
|
w := post(t, r, "/settings", body)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", w.Code, w.Body.String())
|
|
}
|
|
if fa.called != 1 {
|
|
t.Fatalf("applier called %d times, want 1", fa.called)
|
|
}
|
|
if fa.last.AIBackend != "codex" {
|
|
t.Errorf("applied backend = %q, want codex", fa.last.AIBackend)
|
|
}
|
|
saved, err := settings.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("settings file not loadable: %v", err)
|
|
}
|
|
if saved.AIBackend != "codex" || saved.MarvinCmd != "uv run am" || saved.KnowledgePath != "/tmp/k.md" {
|
|
t.Errorf("saved settings = %+v, want codex/uv run am//tmp/k.md", saved)
|
|
}
|
|
}
|
|
|
|
func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
path := filepath.Join(t.TempDir(), "settings.json")
|
|
fa := &fakeApplier{reject: true}
|
|
s.SetSettings(path, settings.Settings{}, fa.apply)
|
|
r := s.Router()
|
|
|
|
w := post(t, r, "/settings", `{"ai_backend":"bogus"}`)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", w.Code)
|
|
}
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Errorf("settings file should not exist after rejected save, stat err = %v", err)
|
|
}
|
|
if fa.called != 0 {
|
|
t.Errorf("applier recorded %d successful applies, want 0", fa.called)
|
|
}
|
|
}
|
|
|
|
func TestPostSettingsInvalidJSONIs400(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
fa := &fakeApplier{}
|
|
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, fa.apply)
|
|
r := s.Router()
|
|
|
|
w := post(t, r, "/settings", `{not json`)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", w.Code)
|
|
}
|
|
if fa.called != 0 {
|
|
t.Errorf("applier called on invalid json; want 0, got %d", fa.called)
|
|
}
|
|
}
|
|
|
|
func TestPostSettingsPreservesAWURL(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
path := filepath.Join(t.TempDir(), "settings.json")
|
|
fa := &fakeApplier{}
|
|
cur := settings.Settings{AIBackend: "claude", AWURL: "http://aw.remote:9999"}
|
|
s.SetSettings(path, cur, fa.apply)
|
|
r := s.Router()
|
|
|
|
// POST a body that omits aw_url (mirrors what the real UI form sends).
|
|
body := `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":""}`
|
|
w := post(t, r, "/settings", body)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body %s)", w.Code, w.Body.String())
|
|
}
|
|
if fa.called != 1 {
|
|
t.Fatalf("applier called %d times, want 1", fa.called)
|
|
}
|
|
// (a) applyFn must have received the preserved AWURL.
|
|
if fa.last.AWURL != "http://aw.remote:9999" {
|
|
t.Errorf("applied AWURL = %q, want http://aw.remote:9999", fa.last.AWURL)
|
|
}
|
|
// (b) persisted file must also preserve the AWURL.
|
|
saved, err := settings.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("settings file not loadable: %v", err)
|
|
}
|
|
if saved.AWURL != "http://aw.remote:9999" {
|
|
t.Errorf("saved AWURL = %q, want http://aw.remote:9999", saved.AWURL)
|
|
}
|
|
}
|
|
|
|
func TestBrowseListsDirsAndMarkdown(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "profile.md"), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s, _ := newTestServer(t)
|
|
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
|
|
r := s.Router()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir="+dir, nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", w.Code)
|
|
}
|
|
var resp struct {
|
|
Dir string `json:"dir"`
|
|
Parent string `json:"parent"`
|
|
Entries []struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
IsDir bool `json:"is_dir"`
|
|
} `json:"entries"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Parent != filepath.Dir(dir) {
|
|
t.Errorf("parent = %q, want %q", resp.Parent, filepath.Dir(dir))
|
|
}
|
|
if resp.Dir != dir {
|
|
t.Errorf("dir = %q, want %q", resp.Dir, dir)
|
|
}
|
|
var names []string
|
|
hasSub, hasMd, hasTxt := false, false, false
|
|
for _, e := range resp.Entries {
|
|
names = append(names, e.Name)
|
|
switch e.Name {
|
|
case "sub":
|
|
hasSub = e.IsDir
|
|
if e.Path != filepath.Join(dir, e.Name) {
|
|
t.Errorf("sub path = %q, want %q", e.Path, filepath.Join(dir, e.Name))
|
|
}
|
|
case "profile.md":
|
|
hasMd = !e.IsDir
|
|
if e.Path != filepath.Join(dir, e.Name) {
|
|
t.Errorf("profile.md path = %q, want %q", e.Path, filepath.Join(dir, e.Name))
|
|
}
|
|
case "notes.txt":
|
|
hasTxt = true
|
|
}
|
|
}
|
|
if !hasSub || !hasMd {
|
|
t.Errorf("missing dir or .md entry; names = %v", names)
|
|
}
|
|
if hasTxt {
|
|
t.Errorf(".txt should be filtered out; names = %v", names)
|
|
}
|
|
}
|
|
|
|
func TestBrowseBadDirIs400(t *testing.T) {
|
|
s, _ := newTestServer(t)
|
|
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
|
|
r := s.Router()
|
|
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", w.Code)
|
|
}
|
|
}
|