daemon: integrate QoL slices 1-5 (model/rename/catalog, history deletes, stats, repo prepare+imageUsed+built) — 95.3% cover green
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
package main
|
||||
|
||||
// model_rename_test.go — slice 1: set_model/rename routing, SetSessionName
|
||||
// merge + session_list broadcast, model catalog parsing and PATCH semantics.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestAPISetModelRoutingAndOffline409(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
|
||||
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/model", testToken,
|
||||
`{"provider":"zai-renaud","modelId":"glm-5.3"}`); code != http.StatusConflict {
|
||||
t.Fatalf("offline set_model = %d, want 409", code)
|
||||
}
|
||||
|
||||
ws := dialAgent(t, ts)
|
||||
_ = ws.WriteJSON(helloFrame("s1"))
|
||||
_ = readFrame(t, ws)
|
||||
|
||||
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/model", testToken,
|
||||
`{"modelId":"glm-5.3"}`); code != http.StatusBadRequest {
|
||||
t.Fatalf("set_model without provider = %d, want 400", code)
|
||||
}
|
||||
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/model", testToken,
|
||||
`{"provider":"zai-renaud"}`); code != http.StatusBadRequest {
|
||||
t.Fatalf("set_model without modelId = %d, want 400", code)
|
||||
}
|
||||
|
||||
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/model", testToken,
|
||||
`{"provider":"zai-renaud","modelId":"glm-5.3"}`); code != http.StatusOK || !strings.Contains(body, `"ok":true`) {
|
||||
t.Fatalf("online set_model = %d %s, want 200 ok", code, body)
|
||||
}
|
||||
got := readFrame(t, ws)
|
||||
if got["type"] != evSetModel || got["provider"] != "zai-renaud" || got["modelId"] != "glm-5.3" {
|
||||
t.Fatalf("set_model frame = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreSetSessionNameMergesIntoInfoBlob(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
name := "original"
|
||||
if err := store.UpsertSession(SessionInfo{
|
||||
ID: "s1", Name: &name, Cwd: "/w", Model: "glm-5.3",
|
||||
Provider: "zai-renaud", StartedAt: 5,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
found, err := store.SetSessionName("s1", "renamed")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("SetSessionName = %v %v, want true nil", found, err)
|
||||
}
|
||||
rows, err := store.Sessions()
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("sessions: %v %v", rows, err)
|
||||
}
|
||||
info := rows[0].Info
|
||||
if info.Name == nil || *info.Name != "renamed" {
|
||||
t.Fatalf("name = %v, want renamed", info.Name)
|
||||
}
|
||||
if info.Cwd != "/w" || info.Model != "glm-5.3" || info.Provider != "zai-renaud" || info.StartedAt != 5 {
|
||||
t.Fatalf("merge clobbered sibling fields: %+v", info)
|
||||
}
|
||||
|
||||
found, err = store.SetSessionName("missing", "x")
|
||||
if err != nil || found {
|
||||
t.Fatalf("SetSessionName unknown id = %v %v, want false nil", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// dialWebWS opens the browser websocket and returns a frame channel.
|
||||
func dialWebWS(t *testing.T, ts *httptest.Server) <-chan map[string]any {
|
||||
t.Helper()
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?token=" + testToken
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial web ws: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
frames := make(chan map[string]any, 64)
|
||||
go func() {
|
||||
defer close(frames)
|
||||
for {
|
||||
var m map[string]any
|
||||
if err := conn.ReadJSON(&m); err != nil {
|
||||
return
|
||||
}
|
||||
frames <- m
|
||||
}
|
||||
}()
|
||||
return frames
|
||||
}
|
||||
|
||||
// waitSessionList reads frames until a session_list names the session `id`
|
||||
// with the wanted name (nil-safe), or fails on timeout.
|
||||
func waitSessionList(t *testing.T, frames <-chan map[string]any, id, wantName string) {
|
||||
t.Helper()
|
||||
deadline := time.After(3 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case m, ok := <-frames:
|
||||
if !ok {
|
||||
t.Fatal("web ws closed while waiting for session_list")
|
||||
}
|
||||
if m["type"] != frameSessionList {
|
||||
continue
|
||||
}
|
||||
for _, s := range m["sessions"].([]any) {
|
||||
row := s.(map[string]any)
|
||||
if row["id"] != id {
|
||||
continue
|
||||
}
|
||||
if name, _ := row["name"].(string); name == wantName {
|
||||
return
|
||||
}
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatalf("no session_list with %s name %q before timeout", id, wantName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRenamePatchPersistsBroadcastsAndRoutes(t *testing.T) {
|
||||
ts, store := newTestServer(t)
|
||||
frames := dialWebWS(t, ts)
|
||||
|
||||
// The initial session_list arrives on connect; the agent hello broadcasts
|
||||
// another. Consume both before PATCHing, then expect the renamed list.
|
||||
ws := dialAgent(t, ts)
|
||||
_ = ws.WriteJSON(helloFrame("s1"))
|
||||
_ = readFrame(t, ws)
|
||||
waitSessionList(t, frames, "s1", "")
|
||||
|
||||
if code, _ := apiReq(t, http.MethodPatch, ts.URL+"/api/sessions/s1", testToken,
|
||||
`{"name":"renamed"}`); code != http.StatusOK {
|
||||
t.Fatalf("patch rename = %d, want 200", code)
|
||||
}
|
||||
waitSessionList(t, frames, "s1", "renamed")
|
||||
|
||||
rows, err := store.Sessions()
|
||||
if err != nil || len(rows) != 1 || rows[0].Info.Name == nil || *rows[0].Info.Name != "renamed" {
|
||||
t.Fatalf("persisted name after patch: %v %v", rows, err)
|
||||
}
|
||||
|
||||
// live plugin receives the rename frame too
|
||||
got := readFrame(t, ws)
|
||||
if got["type"] != evRename || got["name"] != "renamed" {
|
||||
t.Fatalf("rename frame = %v", got)
|
||||
}
|
||||
|
||||
if code, _ := apiReq(t, http.MethodPatch, ts.URL+"/api/sessions/s1", testToken,
|
||||
`{"name":" "}`); code != http.StatusBadRequest {
|
||||
t.Fatalf("blank rename = %d, want 400", code)
|
||||
}
|
||||
if code, _ := apiReq(t, http.MethodPatch, ts.URL+"/api/sessions/missing", testToken,
|
||||
`{"name":"x"}`); code != http.StatusNotFound {
|
||||
t.Fatalf("unknown session rename = %d, want 404", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelCatalog(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"providers": {
|
||||
"zai-renaud": {"models": [
|
||||
{"id": "glm-5.3", "name": "GLM-5.3"},
|
||||
{"id": "glm-5.4"},
|
||||
{"name": "idless, skipped"}
|
||||
]},
|
||||
"anthropic": {"models": [{"id": "claude-x", "name": "Claude X"}]}
|
||||
}
|
||||
}`)
|
||||
got := parseModelCatalog(body)
|
||||
want := []ModelCatalogItem{
|
||||
{Provider: "anthropic", ID: "claude-x", Name: "Claude X"},
|
||||
{Provider: "zai-renaud", ID: "glm-5.3", Name: "GLM-5.3"},
|
||||
{Provider: "zai-renaud", ID: "glm-5.4", Name: "glm-5.4"},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("catalog = %+v", got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("catalog[%d] = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
if parseModelCatalog([]byte(`not json`)) != nil {
|
||||
t.Fatal("malformed body must parse to nil")
|
||||
}
|
||||
if out := parseModelCatalog([]byte(`{"providers":{}}`)); len(out) != 0 {
|
||||
t.Fatalf("empty providers = %+v, want empty", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIModelCatalog(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
good := filepath.Join(dir, "models.json")
|
||||
if err := os.WriteFile(good, []byte(`{"providers":{"zai-renaud":{"models":[{"id":"glm-5.3","name":"GLM-5.3"}]}}}`), 0o644); err != nil {
|
||||
t.Fatalf("write models.json: %v", err)
|
||||
}
|
||||
t.Setenv(envModelsFile, good)
|
||||
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("catalog = %d %s", code, body)
|
||||
}
|
||||
var entries []ModelCatalogItem
|
||||
if err := json.Unmarshal([]byte(body), &entries); err != nil {
|
||||
t.Fatalf("decode catalog: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0] != (ModelCatalogItem{Provider: "zai-renaud", ID: "glm-5.3", Name: "GLM-5.3"}) {
|
||||
t.Fatalf("entries = %+v", entries)
|
||||
}
|
||||
|
||||
bad := filepath.Join(dir, "bad.json")
|
||||
if err := os.WriteFile(bad, []byte(`{`), 0o644); err != nil {
|
||||
t.Fatalf("write bad.json: %v", err)
|
||||
}
|
||||
t.Setenv(envModelsFile, bad)
|
||||
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, ""); code != http.StatusInternalServerError {
|
||||
t.Fatalf("malformed catalog = %d, want 500", code)
|
||||
}
|
||||
|
||||
t.Setenv(envModelsFile, filepath.Join(dir, "absent.json"))
|
||||
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, ""); code != http.StatusInternalServerError {
|
||||
t.Fatalf("missing catalog file = %d, want 500", code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user