Changeset 0.17.1 (#76)
This commit is contained in:
@@ -7,22 +7,101 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"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/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ── Test Harness ────────────────────────────
|
||||
|
||||
// dialectSQL converts Postgres-style $N placeholders to ? for SQLite,
|
||||
// strips ::jsonb casts, and converts boolean literals to integers.
|
||||
// Allows raw SQL in tests to work on both backends.
|
||||
func dialectSQL(q string) string {
|
||||
if !database.IsSQLite() {
|
||||
return q
|
||||
}
|
||||
result := q
|
||||
// Replace high-to-low to avoid $1 matching inside $10
|
||||
for i := 20; i >= 1; i-- {
|
||||
result = strings.ReplaceAll(result, fmt.Sprintf("$%d", i), "?")
|
||||
}
|
||||
result = strings.ReplaceAll(result, "::jsonb", "")
|
||||
result = strings.ReplaceAll(result, "::text", "")
|
||||
// Boolean literals: true/false → 1/0 (only bare keywords, not string 'true')
|
||||
result = strings.ReplaceAll(result, "= true", "= 1")
|
||||
result = strings.ReplaceAll(result, "= false", "= 0")
|
||||
result = strings.ReplaceAll(result, ", true)", ", 1)")
|
||||
result = strings.ReplaceAll(result, "COALESCE(is_private, false)", "COALESCE(is_private, 0)")
|
||||
// Time functions
|
||||
result = strings.ReplaceAll(result, "NOW()", "datetime('now')")
|
||||
// NULL sort
|
||||
result = strings.ReplaceAll(result, "NULLS LAST", "")
|
||||
return result
|
||||
}
|
||||
|
||||
// seedID returns a new UUID for use in test seed data.
|
||||
func seedID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// seedInsertReturningID executes an INSERT with RETURNING id on Postgres,
|
||||
// or injects a generated UUID id on SQLite and returns it.
|
||||
func seedInsertReturningID(t *testing.T, query string, args ...interface{}) string {
|
||||
t.Helper()
|
||||
if !database.IsSQLite() {
|
||||
var id string
|
||||
err := database.TestDB.QueryRow(query, args...).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("seedInsertReturningID: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
id := seedID()
|
||||
q := dialectSQL(query)
|
||||
// Remove RETURNING clause
|
||||
if idx := strings.Index(strings.ToUpper(q), "RETURNING"); idx >= 0 {
|
||||
q = strings.TrimSpace(q[:idx])
|
||||
}
|
||||
// Inject id column
|
||||
q = database.InjectIDForTest(q)
|
||||
newArgs := append([]interface{}{id}, args...)
|
||||
_, err := database.TestDB.Exec(q, newArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("seedInsertReturningID: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// seedExec executes an INSERT via dialectSQL; on SQLite it also injects
|
||||
// a generated id column and value (first arg) so that TEXT PRIMARY KEY
|
||||
// tables that lack a DEFAULT get a proper UUID.
|
||||
func seedExec(t *testing.T, query string, args ...interface{}) {
|
||||
t.Helper()
|
||||
q := dialectSQL(query)
|
||||
if database.IsSQLite() {
|
||||
q = database.InjectIDForTest(q)
|
||||
args = append([]interface{}{seedID()}, args...)
|
||||
}
|
||||
_, err := database.TestDB.Exec(q, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("seedExec: %v\n query: %s", err, q)
|
||||
}
|
||||
}
|
||||
|
||||
const testJWTSecret = "test-secret-key-for-integration-tests"
|
||||
|
||||
type testHarness struct {
|
||||
@@ -42,7 +121,12 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
BasePath: "",
|
||||
}
|
||||
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
var stores store.Stores
|
||||
if database.IsSQLite() {
|
||||
stores = sqlite.NewStores(database.TestDB)
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
|
||||
// Roles resolver (nil vault — test-fire won't work, but CRUD will)
|
||||
roleResolver := roles.NewResolver(stores, nil)
|
||||
@@ -124,7 +208,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
|
||||
|
||||
// Notes
|
||||
notes := NewNoteHandler()
|
||||
notes := NewNoteHandler(stores)
|
||||
protected.GET("/notes", notes.List)
|
||||
protected.POST("/notes", notes.Create)
|
||||
protected.GET("/notes/:id", notes.Get)
|
||||
@@ -166,6 +250,18 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
completions := NewCompletionHandler(nil, stores, nil, nil)
|
||||
protected.POST("/chat/completions", completions.Complete)
|
||||
|
||||
// Messages
|
||||
msgs := NewMessageHandler(nil, stores, nil, nil)
|
||||
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
||||
protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
|
||||
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
|
||||
protected.GET("/channels/:id/path", msgs.GetActivePath)
|
||||
|
||||
// Avatar (uses settings handler)
|
||||
protected.PUT("/avatar", settings.UploadAvatar)
|
||||
protected.DELETE("/avatar", settings.DeleteAvatar)
|
||||
|
||||
// Admin routes
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
||||
@@ -297,7 +393,7 @@ func (h *testHarness) createAdminUser(username, email string) (userID, token str
|
||||
h.t.Helper()
|
||||
userID = database.SeedTestUser(h.t, username, email)
|
||||
// Make admin
|
||||
database.TestDB.Exec("UPDATE users SET role = 'admin', is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), userID)
|
||||
token = makeToken(userID, email, "admin")
|
||||
return
|
||||
}
|
||||
@@ -486,7 +582,7 @@ func TestIntegration_AdminProviderConfigCRUD(t *testing.T) {
|
||||
// ── Verify API key is actually stored in DB ──
|
||||
var storedKey string
|
||||
err := database.TestDB.QueryRow(
|
||||
"SELECT api_key_enc FROM provider_configs WHERE id = $1", configID,
|
||||
dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID,
|
||||
).Scan(&storedKey)
|
||||
if err != nil {
|
||||
t.Fatalf("query stored key: %v", err)
|
||||
@@ -510,7 +606,7 @@ func TestIntegration_AdminProviderConfigCRUD(t *testing.T) {
|
||||
|
||||
// Verify the key was actually updated
|
||||
err = database.TestDB.QueryRow(
|
||||
"SELECT api_key_enc FROM provider_configs WHERE id = $1", configID,
|
||||
dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID,
|
||||
).Scan(&storedKey)
|
||||
if err != nil {
|
||||
t.Fatalf("query updated key: %v", err)
|
||||
@@ -549,7 +645,7 @@ func TestIntegration_AdminProviderAPIKeyUsedByFetch(t *testing.T) {
|
||||
// Verify key stored in DB
|
||||
var storedKey string
|
||||
database.TestDB.QueryRow(
|
||||
"SELECT api_key_enc FROM provider_configs WHERE id = $1", configID,
|
||||
dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID,
|
||||
).Scan(&storedKey)
|
||||
if storedKey != "sk-badkey-for-test" {
|
||||
t.Fatalf("key not stored: want 'sk-badkey-for-test', got %q — json:\"-\" bug is back", storedKey)
|
||||
@@ -605,13 +701,10 @@ func TestIntegration_ModelVisibilityResolution(t *testing.T) {
|
||||
configID := cfg["id"].(string)
|
||||
|
||||
// Insert a model into catalog directly (simulating fetch)
|
||||
_, err := database.TestDB.Exec(`
|
||||
seedExec(t, `
|
||||
INSERT INTO model_catalog (provider_config_id, model_id, display_name, visibility)
|
||||
VALUES ($1, 'gpt-4o', 'GPT-4o', 'disabled')
|
||||
`, configID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert catalog entry: %v", err)
|
||||
}
|
||||
|
||||
// As admin, models/enabled should return empty (model disabled)
|
||||
w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
|
||||
@@ -627,7 +720,7 @@ func TestIntegration_ModelVisibilityResolution(t *testing.T) {
|
||||
|
||||
// Enable the model
|
||||
var catalogID string
|
||||
database.TestDB.QueryRow("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1", configID).Scan(&catalogID)
|
||||
database.TestDB.QueryRow(dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), configID).Scan(&catalogID)
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/models/%s", catalogID), adminToken,
|
||||
map[string]interface{}{"visibility": "enabled"})
|
||||
if w.Code != http.StatusOK {
|
||||
@@ -654,7 +747,7 @@ func TestIntegration_TeamMemberManagement(t *testing.T) {
|
||||
|
||||
// Create regular user
|
||||
userID := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
|
||||
// Create team
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
@@ -920,14 +1013,11 @@ func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) {
|
||||
|
||||
// Insert models directly (simulating successful provider fetch)
|
||||
for _, mid := range []string{"gpt-4o", "gpt-4o-mini", "o1-preview"} {
|
||||
_, err := database.TestDB.Exec(`
|
||||
seedExec(t, `
|
||||
INSERT INTO model_catalog (provider_config_id, model_id, display_name,
|
||||
capabilities, visibility)
|
||||
VALUES ($1, $2, $3, '{"streaming":true,"tool_calling":true}'::jsonb, 'disabled')
|
||||
`, configID, mid, mid)
|
||||
if err != nil {
|
||||
t.Fatalf("insert %s: %v", mid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin list should show ALL models (including disabled) ──
|
||||
@@ -949,7 +1039,7 @@ func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) {
|
||||
|
||||
// ── User should see 0 models (all disabled) ──
|
||||
userID := database.SeedTestUser(t, "testuser", "user@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "user@test.com", "user")
|
||||
w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
@@ -970,7 +1060,7 @@ func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) {
|
||||
// ── Admin enables one model ──
|
||||
var catalogID string
|
||||
database.TestDB.QueryRow(
|
||||
"SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1",
|
||||
dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"),
|
||||
configID,
|
||||
).Scan(&catalogID)
|
||||
|
||||
@@ -1097,13 +1187,10 @@ func hasModelWithScope(models []interface{}, modelID, scope string) bool {
|
||||
func simulateFetch(t *testing.T, providerConfigID string, models []string, visibility string) {
|
||||
t.Helper()
|
||||
for _, modelID := range models {
|
||||
_, err := database.TestDB.Exec(`
|
||||
seedExec(t, `
|
||||
INSERT INTO model_catalog (provider_config_id, model_id, display_name, visibility)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, providerConfigID, modelID, modelID, visibility)
|
||||
if err != nil {
|
||||
t.Fatalf("[SIMULATED FETCH] insert %s: %v", modelID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,7 +1202,7 @@ func TestUserJourney_AdminProvider_UserSeesModels(t *testing.T) {
|
||||
|
||||
// Regular user — no special role, no team
|
||||
userID := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "alice@test.com", "user")
|
||||
|
||||
// Step 1: Admin creates provider via API
|
||||
@@ -1144,7 +1231,7 @@ func TestUserJourney_AdminProvider_UserSeesModels(t *testing.T) {
|
||||
// Step 4: Admin enables one model via API
|
||||
var catalogID string
|
||||
database.TestDB.QueryRow(
|
||||
"SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1", configID,
|
||||
dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), configID,
|
||||
).Scan(&catalogID)
|
||||
|
||||
w = h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
|
||||
@@ -1308,11 +1395,11 @@ func TestUserJourney_TeamProvider_MemberVsNonMember(t *testing.T) {
|
||||
|
||||
// Create team members
|
||||
aliceID := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", aliceID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), aliceID)
|
||||
aliceToken := makeToken(aliceID, "alice@test.com", "user")
|
||||
|
||||
bobID := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", bobID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), bobID)
|
||||
bobToken := makeToken(bobID, "bob@test.com", "user")
|
||||
|
||||
// Step 1: Admin creates team via API
|
||||
@@ -1406,15 +1493,15 @@ func TestUserJourney_FullMatrix(t *testing.T) {
|
||||
|
||||
// Create all actors
|
||||
teamAdminID := database.SeedTestUser(t, "teamadmin", "teamadmin@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", teamAdminID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamadmin@test.com", "user")
|
||||
|
||||
teamMemberID := database.SeedTestUser(t, "teammember", "teammember@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", teamMemberID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamMemberID)
|
||||
teamMemberToken := makeToken(teamMemberID, "teammember@test.com", "user")
|
||||
|
||||
outsiderID := database.SeedTestUser(t, "outsider", "outsider@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", outsiderID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), outsiderID)
|
||||
outsiderToken := makeToken(outsiderID, "outsider@test.com", "user")
|
||||
|
||||
// ── Setup: Enable BYOK policy ──
|
||||
@@ -1599,7 +1686,7 @@ func TestUserJourney_FullMatrix(t *testing.T) {
|
||||
t.Run("admin_disables_global_model_users_lose_it", func(t *testing.T) {
|
||||
var catalogID string
|
||||
database.TestDB.QueryRow(
|
||||
"SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1", globalCfgID,
|
||||
dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), globalCfgID,
|
||||
).Scan(&catalogID)
|
||||
|
||||
// Admin disables gpt-4o
|
||||
@@ -1747,7 +1834,7 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
|
||||
|
||||
// Create team admin user
|
||||
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", teamAdminID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||
|
||||
// Create team
|
||||
@@ -1823,15 +1910,12 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
|
||||
// seedUsage inserts a usage_log row directly for testing.
|
||||
func seedUsage(t *testing.T, userID, provCfgID, model, scope string, prompt, completion int, costIn, costOut float64) {
|
||||
t.Helper()
|
||||
_, err := database.TestDB.Exec(`
|
||||
seedExec(t, `
|
||||
INSERT INTO usage_log (user_id, provider_config_id, provider_scope,
|
||||
model_id, prompt_tokens, completion_tokens,
|
||||
cost_input, cost_output)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`, userID, provCfgID, scope, model, prompt, completion, costIn, costOut)
|
||||
if err != nil {
|
||||
t.Fatalf("seedUsage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Usage_AdminView(t *testing.T) {
|
||||
@@ -1839,7 +1923,7 @@ func TestIntegration_Usage_AdminView(t *testing.T) {
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
userID := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
|
||||
// Create global provider
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
@@ -1881,7 +1965,7 @@ func TestIntegration_Usage_AdminExcludesBYOK(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
userID := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
|
||||
// Create global provider
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
@@ -1992,11 +2076,11 @@ func TestIntegration_Usage_TeamAdmin(t *testing.T) {
|
||||
|
||||
// Create team admin + member
|
||||
teamAdminID := database.SeedTestUser(t, "teamlead2", "teamlead2@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", teamAdminID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamlead2@test.com", "user")
|
||||
|
||||
memberID := database.SeedTestUser(t, "member2", "member2@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", memberID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), memberID)
|
||||
|
||||
// Create team
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
@@ -2060,7 +2144,7 @@ func TestIntegration_Usage_TeamNonAdmin403(t *testing.T) {
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
memberID := database.SeedTestUser(t, "member3", "member3@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", memberID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), memberID)
|
||||
memberToken := makeToken(memberID, "member3@test.com", "user")
|
||||
|
||||
// Create team, add member (NOT admin)
|
||||
@@ -2179,7 +2263,7 @@ func TestIntegration_Pricing_ExcludesBYOK(t *testing.T) {
|
||||
byokID := bcfg["id"].(string)
|
||||
|
||||
// Simulate catalog pricing for BYOK provider (as model sync would)
|
||||
database.TestDB.Exec(`
|
||||
seedExec(t, `
|
||||
INSERT INTO model_pricing (provider_config_id, model_id, input_per_m, output_per_m, source)
|
||||
VALUES ($1, 'gpt-4o-byok', 3.0, 15.0, 'catalog')
|
||||
`, byokID)
|
||||
@@ -2555,7 +2639,7 @@ func TestGroupMembers(t *testing.T) {
|
||||
|
||||
_, adminToken := h.createAdminUser("gmadmin", "gmadmin@test.com")
|
||||
userID := database.SeedTestUser(h.t, "gmuser", "gmuser@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "gmuser@test.com", "user")
|
||||
|
||||
// Create group
|
||||
@@ -2734,7 +2818,7 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
||||
// Setup: admin + regular user (not on any team)
|
||||
adminID, adminToken := h.createAdminUser("gpadmin", "gpadmin@test.com")
|
||||
userID := database.SeedTestUser(h.t, "gpuser", "gpuser@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "gpuser@test.com", "user")
|
||||
|
||||
// Create a team
|
||||
@@ -2746,15 +2830,11 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
||||
teamID := teamResp["id"].(string)
|
||||
|
||||
// Create persona scoped to that team (user shouldn't see it without group access)
|
||||
var personaID string
|
||||
err := database.DB.QueryRow(`
|
||||
personaID := seedInsertReturningID(t, `
|
||||
INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
|
||||
VALUES ('Secret Bot', 'test-model', 'team', $1, $2, true)
|
||||
RETURNING id
|
||||
`, teamID, adminID).Scan(&personaID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert persona: %v", err)
|
||||
}
|
||||
`, teamID, adminID)
|
||||
if personaID == "" {
|
||||
t.Fatal("personaID is empty after insert")
|
||||
}
|
||||
@@ -3035,3 +3115,186 @@ func TestIntegration_KB_DirectAccessPolicy(t *testing.T) {
|
||||
// (if kb_direct_access INSERT fails, the migration fails and no tests run)
|
||||
_ = w
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Messages + Treepath tests (SQLite compat)
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
func TestIntegration_Messages_CRUD(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, token := h.createAdminUser("msguser", "msg@test.com")
|
||||
|
||||
// Create channel
|
||||
w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
|
||||
"title": "Message Test",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ch map[string]interface{}
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Create first message
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello, world!",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create message: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var msg1 map[string]interface{}
|
||||
decode(w, &msg1)
|
||||
if msg1["id"] == nil || msg1["id"].(string) == "" {
|
||||
t.Fatal("message should have an id")
|
||||
}
|
||||
if msg1["content"].(string) != "Hello, world!" {
|
||||
t.Fatalf("content mismatch: got %q", msg1["content"])
|
||||
}
|
||||
msg1ID := msg1["id"].(string)
|
||||
|
||||
// Create second message (child of first)
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
|
||||
"role": "assistant",
|
||||
"content": "Hi there!",
|
||||
"parent_id": msg1ID,
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create message 2: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// List messages — should have 2
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list messages: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var listResp map[string]interface{}
|
||||
decode(w, &listResp)
|
||||
total := int(listResp["total"].(float64))
|
||||
if total != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Messages_EditFork(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, token := h.createAdminUser("forkuser", "fork@test.com")
|
||||
|
||||
// Create channel
|
||||
w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
|
||||
"title": "Fork Test",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create channel: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ch map[string]interface{}
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Create user message (root)
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "First draft",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create message: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var msg map[string]interface{}
|
||||
decode(w, &msg)
|
||||
msgID := msg["id"].(string)
|
||||
|
||||
// Edit (fork) the message — creates a sibling
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages/%s/edit", channelID, msgID), token, map[string]interface{}{
|
||||
"content": "Second draft",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("edit message: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var edited map[string]interface{}
|
||||
decode(w, &edited)
|
||||
if edited["content"].(string) != "Second draft" {
|
||||
t.Fatalf("edited content mismatch: got %q", edited["content"])
|
||||
}
|
||||
|
||||
// Sibling count should be 2 (original + edit)
|
||||
sibCount := int(edited["sibling_count"].(float64))
|
||||
if sibCount != 2 {
|
||||
t.Fatalf("expected sibling_count=2, got %d", sibCount)
|
||||
}
|
||||
|
||||
// Verify via siblings endpoint
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/messages/%s/siblings", channelID, msgID), token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list siblings: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var sibResp map[string]interface{}
|
||||
decode(w, &sibResp)
|
||||
siblings := sibResp["siblings"].([]interface{})
|
||||
if len(siblings) != 2 {
|
||||
t.Fatalf("expected 2 siblings, got %d", len(siblings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Messages_TreePath(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, token := h.createAdminUser("pathuser", "path@test.com")
|
||||
|
||||
// Create channel
|
||||
w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
|
||||
"title": "Path Test",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create channel: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ch map[string]interface{}
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Build a 3-message chain: root → child → grandchild
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
|
||||
"role": "user", "content": "root message",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("msg1: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var m1 map[string]interface{}
|
||||
decode(w, &m1)
|
||||
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
|
||||
"role": "assistant", "content": "response", "parent_id": m1["id"].(string),
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("msg2: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var m2 map[string]interface{}
|
||||
decode(w, &m2)
|
||||
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
|
||||
"role": "user", "content": "follow-up", "parent_id": m2["id"].(string),
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("msg3: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Get active path — should return 3 messages in root-first order
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/path", channelID), token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get path: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var pathEnv map[string]interface{}
|
||||
decode(w, &pathEnv)
|
||||
pathResp := pathEnv["path"].([]interface{})
|
||||
if len(pathResp) != 3 {
|
||||
t.Fatalf("expected 3 messages in path, got %d", len(pathResp))
|
||||
}
|
||||
|
||||
// Verify order: root first, grandchild last
|
||||
first := pathResp[0].(map[string]interface{})
|
||||
last := pathResp[2].(map[string]interface{})
|
||||
if first["content"].(string) != "root message" {
|
||||
t.Fatalf("first in path should be root, got %q", first["content"])
|
||||
}
|
||||
if last["content"].(string) != "follow-up" {
|
||||
t.Fatalf("last in path should be follow-up, got %q", last["content"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user