package handlers import ( "encoding/json" "net/http" "testing" "github.com/gin-gonic/gin" "github.com/google/uuid" "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/middleware" "git.gobha.me/xcaliber/chat-switchboard/store" postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" ) // ── Model Prefs Test Harness ────────────── type modelPrefsHarness struct { *testHarness userToken string userID string user2Token string user2ID string configID string // seeded provider_config for preference tests } func setupModelPrefsHarness(t *testing.T) *modelPrefsHarness { t.Helper() database.RequireTestDB(t) database.TruncateAll(t) cfg := &config.Config{ JWTSecret: testJWTSecret, BasePath: "", } var stores store.Stores if database.IsSQLite() { stores = sqlite.NewStores(database.TestDB) } else { stores = postgres.NewStores(database.TestDB) } userCache := middleware.NewUserStatusCache() r := gin.New() api := r.Group("/api/v1") protected := api.Group("") protected.Use(middleware.Auth(cfg, stores.Users, userCache)) modelPrefs := NewModelPrefsHandler(stores) protected.GET("/models/preferences", modelPrefs.GetPreferences) protected.PUT("/models/preferences", modelPrefs.SetPreference) protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences) // Seed two users userID := database.SeedTestUser(t, "prefuser", "prefuser@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) userToken := makeToken(userID, "prefuser@test.com", "user") user2ID := database.SeedTestUser(t, "prefuser2", "prefuser2@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID) user2Token := makeToken(user2ID, "prefuser2@test.com", "user") // Seed a provider config for preference targets configID := uuid.New().String() if database.IsSQLite() { database.TestDB.Exec(` INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope) VALUES (?, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`, configID) } else { database.TestDB.Exec(` INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope) VALUES ($1, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`, configID) } return &modelPrefsHarness{ testHarness: &testHarness{router: r, t: t}, userToken: userToken, userID: userID, user2Token: user2Token, user2ID: user2ID, configID: configID, } } // ── GET /models/preferences — empty state ─ func TestModelPrefs_Get_Empty(t *testing.T) { h := setupModelPrefsHarness(t) resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var body map[string]interface{} json.NewDecoder(resp.Body).Decode(&body) data, ok := body["data"] if !ok { t.Fatal("response must have 'data' key") } arr, ok := data.([]interface{}) if !ok { t.Fatal("data must be an array") } if len(arr) != 0 { t.Fatalf("expected empty array, got %d items", len(arr)) } } // ── PUT + GET round-trip ────────────────── func TestModelPrefs_Set_RoundTrip(t *testing.T) { h := setupModelPrefsHarness(t) // Set preference resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ "model_id": "test-model-1", "provider_config_id": h.configID, "hidden": true, "sort_order": 5, }) if resp.Code != http.StatusOK { t.Fatalf("PUT: got %d, body: %s", resp.Code, resp.Body.String()) } // Read back resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("GET: got %d", resp.Code) } var body map[string]interface{} json.NewDecoder(resp.Body).Decode(&body) arr := body["data"].([]interface{}) if len(arr) != 1 { t.Fatalf("expected 1 preference, got %d", len(arr)) } pref := arr[0].(map[string]interface{}) if pref["model_id"] != "test-model-1" { t.Errorf("model_id: got %v", pref["model_id"]) } if pref["provider_config_id"] != h.configID { t.Errorf("provider_config_id: got %v, want %s", pref["provider_config_id"], h.configID) } if pref["hidden"] != true { t.Errorf("hidden: got %v, want true", pref["hidden"]) } if pref["sort_order"] != float64(5) { t.Errorf("sort_order: got %v, want 5", pref["sort_order"]) } } // ── Upsert — no duplicate rows ──────────── func TestModelPrefs_Upsert_NoDuplicate(t *testing.T) { h := setupModelPrefsHarness(t) for _, hidden := range []bool{true, false, true} { resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ "model_id": "test-model-1", "provider_config_id": h.configID, "hidden": hidden, }) if resp.Code != http.StatusOK { t.Fatalf("PUT hidden=%v: got %d", hidden, resp.Code) } } resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil) var body map[string]interface{} json.NewDecoder(resp.Body).Decode(&body) arr := body["data"].([]interface{}) if len(arr) != 1 { t.Fatalf("upsert produced %d rows, want 1", len(arr)) } pref := arr[0].(map[string]interface{}) if pref["hidden"] != true { t.Errorf("final hidden state should be true, got %v", pref["hidden"]) } } // ── Validation: missing fields ──────────── func TestModelPrefs_Set_MissingModelID(t *testing.T) { h := setupModelPrefsHarness(t) resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ "provider_config_id": h.configID, "hidden": true, }) if resp.Code != http.StatusBadRequest { t.Fatalf("missing model_id: want 400, got %d", resp.Code) } } func TestModelPrefs_Set_MissingProviderConfigID(t *testing.T) { h := setupModelPrefsHarness(t) resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ "model_id": "test-model-1", "hidden": true, }) if resp.Code != http.StatusBadRequest { t.Fatalf("missing provider_config_id: want 400, got %d", resp.Code) } } // ── Bulk set hidden ─────────────────────── func TestModelPrefs_BulkSetHidden(t *testing.T) { h := setupModelPrefsHarness(t) resp := h.request("POST", "/api/v1/models/preferences/bulk", h.userToken, map[string]interface{}{ "entries": []map[string]string{ {"model_id": "bulk-model-a", "provider_config_id": h.configID}, {"model_id": "bulk-model-b", "provider_config_id": h.configID}, }, "hidden": true, }) if resp.Code != http.StatusOK { t.Fatalf("bulk: got %d, body: %s", resp.Code, resp.Body.String()) } var bulkResp map[string]interface{} json.NewDecoder(resp.Body).Decode(&bulkResp) if bulkResp["count"] != float64(2) { t.Errorf("bulk count: got %v, want 2", bulkResp["count"]) } // Verify both are hidden resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil) var body map[string]interface{} json.NewDecoder(resp.Body).Decode(&body) arr := body["data"].([]interface{}) hiddenCount := 0 for _, raw := range arr { p := raw.(map[string]interface{}) if p["hidden"] == true { hiddenCount++ } } if hiddenCount < 2 { t.Errorf("expected at least 2 hidden prefs, got %d", hiddenCount) } } // ── User isolation ──────────────────────── func TestModelPrefs_UserIsolation(t *testing.T) { h := setupModelPrefsHarness(t) // User 1 sets a preference resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ "model_id": "isolated-model", "provider_config_id": h.configID, "hidden": true, }) if resp.Code != http.StatusOK { t.Fatalf("user1 PUT: got %d", resp.Code) } // User 2 should see empty preferences resp = h.request("GET", "/api/v1/models/preferences", h.user2Token, nil) if resp.Code != http.StatusOK { t.Fatalf("user2 GET: got %d", resp.Code) } var body map[string]interface{} json.NewDecoder(resp.Body).Decode(&body) arr := body["data"].([]interface{}) if len(arr) != 0 { t.Fatalf("user2 should have 0 prefs, got %d", len(arr)) } } // ── Shape validation ────────────────────── func TestModelPrefs_ResponseShape(t *testing.T) { h := setupModelPrefsHarness(t) // Create a preference so we have something to inspect h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ "model_id": "shape-model", "provider_config_id": h.configID, "hidden": false, "sort_order": 0, }) resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil) var body map[string]interface{} json.NewDecoder(resp.Body).Decode(&body) arr := body["data"].([]interface{}) if len(arr) == 0 { t.Fatal("expected at least 1 preference for shape check") } pref := arr[0].(map[string]interface{}) requiredFields := []string{"id", "user_id", "model_id", "provider_config_id", "hidden", "sort_order", "created_at", "updated_at"} for _, f := range requiredFields { if _, ok := pref[f]; !ok { t.Errorf("missing required field %q in preference response", f) } } // id, user_id, model_id, provider_config_id should be non-empty strings for _, f := range []string{"id", "user_id", "model_id", "provider_config_id"} { v, _ := pref[f].(string) if v == "" { t.Errorf("field %q should be a non-empty string, got %v", f, pref[f]) } } } // ── Different provider_config_id = different entry ── func TestModelPrefs_SameModel_DifferentProvider(t *testing.T) { h := setupModelPrefsHarness(t) // Seed a second provider config config2ID := uuid.New().String() database.TestDB.Exec(dialectSQL( "INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope) VALUES ($1, 'global', 'Test Provider 2', 'anthropic', 'https://api.anthropic.com/v1', 'global')"), config2ID) // Set preference for same model under two providers h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ "model_id": "dual-provider-model", "provider_config_id": h.configID, "hidden": true, }) h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ "model_id": "dual-provider-model", "provider_config_id": config2ID, "hidden": false, }) // Should have two distinct entries resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil) var body map[string]interface{} json.NewDecoder(resp.Body).Decode(&body) arr := body["data"].([]interface{}) matches := 0 for _, raw := range arr { p := raw.(map[string]interface{}) if p["model_id"] == "dual-provider-model" { matches++ } } if matches != 2 { t.Fatalf("same model, different providers: expected 2 entries, got %d", matches) } }