364 lines
11 KiB
Go
364 lines
11 KiB
Go
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)
|
|
}
|
|
}
|