Files
lvmh/daemon/model_rename_test.go
T

239 lines
7.5 KiB
Go

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)
}
}