package handlers import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/database" ) // ── Channel Request Validation ───────────────── func TestCreateChannelMissingTitle(t *testing.T) { h := NewChannelHandler() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("POST", "/api/v1/channels", strings.NewReader(`{}`)) c.Request.Header.Set("Content-Type", "application/json") h.CreateChannel(c) if w.Code != http.StatusBadRequest { t.Errorf("Expected 400, got %d: %s", w.Code, w.Body.String()) } } func TestCreateChannelTitleTooLong(t *testing.T) { h := NewChannelHandler() longTitle := strings.Repeat("x", 501) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("POST", "/api/v1/channels", strings.NewReader(`{"title":"`+longTitle+`"}`)) c.Request.Header.Set("Content-Type", "application/json") h.CreateChannel(c) if w.Code != http.StatusBadRequest { t.Errorf("Expected 400 for title > 500 chars, got %d", w.Code) } } func TestUpdateChannelEmptyBody(t *testing.T) { h := NewChannelHandler() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("user_id", "test-user-id") c.Params = gin.Params{{Key: "id", Value: "test-channel-id"}} c.Request = httptest.NewRequest("PUT", "/api/v1/channels/test-channel-id", strings.NewReader(`{}`)) c.Request.Header.Set("Content-Type", "application/json") // Without a DB connection, UpdateChannel will fail at ownership check. // Integration tests with a real DB validate the "no fields" path. // Here we just confirm it doesn't return 400 for valid JSON. h.UpdateChannel(c) if w.Code == http.StatusBadRequest { t.Error("Empty JSON body should not be a parse error") } } // ── Message Request Validation ────────────── func TestCreateMessageMissingRole(t *testing.T) { h := NewMessageHandler() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "test-channel"}} c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages", strings.NewReader(`{"content":"hello"}`)) c.Request.Header.Set("Content-Type", "application/json") h.CreateMessage(c) if w.Code != http.StatusBadRequest { t.Errorf("Expected 400 for missing role, got %d: %s", w.Code, w.Body.String()) } } func TestCreateMessageInvalidRole(t *testing.T) { h := NewMessageHandler() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "test-channel"}} c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages", strings.NewReader(`{"role":"invalid","content":"hello"}`)) c.Request.Header.Set("Content-Type", "application/json") h.CreateMessage(c) if w.Code != http.StatusBadRequest { t.Errorf("Expected 400 for invalid role, got %d", w.Code) } } func TestCreateMessageMissingContent(t *testing.T) { h := NewMessageHandler() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "id", Value: "test-channel"}} c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages", strings.NewReader(`{"role":"user"}`)) c.Request.Header.Set("Content-Type", "application/json") h.CreateMessage(c) if w.Code != http.StatusBadRequest { t.Errorf("Expected 400 for missing content, got %d", w.Code) } } // ── Integration: Message CRUD with Real DB ────── func TestCreateMessageValidRoles(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) userID := database.SeedTestUser(t, "roletester", "role@test.com") channelID := database.SeedTestChannel(t, userID, "Role Test") h := NewMessageHandler() for _, role := range []string{"user", "assistant", "system"} { t.Run(role, func(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("user_id", userID) c.Params = gin.Params{{Key: "id", Value: channelID}} c.Request = httptest.NewRequest("POST", "/api/v1/channels/"+channelID+"/messages", strings.NewReader(`{"role":"`+role+`","content":"hello from `+role+`"}`)) c.Request.Header.Set("Content-Type", "application/json") h.CreateMessage(c) if w.Code != http.StatusCreated { t.Errorf("role=%s: expected 201, got %d: %s", role, w.Code, w.Body.String()) } }) } } func TestChannelCRUDIntegration(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) userID := database.SeedTestUser(t, "cruduser", "crud@test.com") h := NewChannelHandler() r := gin.New() r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() }) r.POST("/channels", h.CreateChannel) r.GET("/channels", h.ListChannels) r.GET("/channels/:id", h.GetChannel) r.PUT("/channels/:id", h.UpdateChannel) r.DELETE("/channels/:id", h.DeleteChannel) // Create w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/channels", strings.NewReader(`{"title":"Integration Test Channel"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String()) } var created map[string]interface{} json.Unmarshal(w.Body.Bytes(), &created) channelID := created["id"].(string) // Get w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/channels/"+channelID, nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("Get: expected 200, got %d", w.Code) } // List w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/channels", nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("List: expected 200, got %d", w.Code) } var listResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &listResp) if listResp["total"].(float64) < 1 { t.Error("List: expected at least 1 channel") } // Update w = httptest.NewRecorder() req, _ = http.NewRequest("PUT", "/channels/"+channelID, strings.NewReader(`{"title":"Updated Title"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("Update: expected 200, got %d: %s", w.Code, w.Body.String()) } // Delete w = httptest.NewRecorder() req, _ = http.NewRequest("DELETE", "/channels/"+channelID, nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("Delete: expected 200, got %d", w.Code) } // Verify gone w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/channels/"+channelID, nil) r.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Errorf("Get after delete: expected 404, got %d", w.Code) } } func TestRegeneratePassesOwnershipCheck(t *testing.T) { database.RequireTestDB(t) database.TruncateAll(t) userID := database.SeedTestUser(t, "regenuser", "regen@test.com") channelID := database.SeedTestChannel(t, userID, "Regen Test") // Seed an assistant message to regenerate var msgID string err := database.DB.QueryRow(` INSERT INTO messages (channel_id, role, content, participant_type, participant_id) VALUES ($1, 'assistant', 'original response', 'model', 'test-model') RETURNING id `, channelID).Scan(&msgID) if err != nil { t.Fatalf("seed message: %v", err) } h := NewMessageHandler() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("user_id", userID) c.Params = gin.Params{ {Key: "id", Value: channelID}, {Key: "msgId", Value: msgID}, } c.Request = httptest.NewRequest("POST", "/api/v1/channels/"+channelID+"/messages/"+msgID+"/regenerate", strings.NewReader(`{}`)) c.Request.Header.Set("Content-Type", "application/json") h.Regenerate(c) // Should NOT be 404 — ownership check passed. Will be 400 or 500 // because no API config is set up, which is expected. if w.Code == http.StatusNotFound { t.Errorf("Expected to pass ownership check, got 404: %s", w.Body.String()) } } // ── Pagination Helpers ────────────────────── func TestParsePaginationDefaults(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/api/v1/channels", nil) page, perPage, offset := parsePagination(c) if page != 1 { t.Errorf("Default page should be 1, got %d", page) } if perPage != 50 { t.Errorf("Default per_page should be 50, got %d", perPage) } if offset != 0 { t.Errorf("Default offset should be 0, got %d", offset) } } func TestParsePaginationCustom(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/api/v1/channels?page=3&per_page=10", nil) page, perPage, offset := parsePagination(c) if page != 3 { t.Errorf("Page should be 3, got %d", page) } if perPage != 10 { t.Errorf("Per page should be 10, got %d", perPage) } if offset != 20 { t.Errorf("Offset should be 20, got %d", offset) } } func TestParsePaginationClampMax(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/api/v1/channels?per_page=500", nil) _, perPage, _ := parsePagination(c) if perPage != 100 { t.Errorf("Per page should be clamped to 100, got %d", perPage) } } // ── getUserID ─────────────────────────────── func TestGetUserID(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("user_id", "abc-123") uid := getUserID(c) if uid != "abc-123" { t.Errorf("Expected abc-123, got %s", uid) } } func TestGetUserIDMissing(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) uid := getUserID(c) if uid != "" { t.Errorf("Expected empty string, got %s", uid) } }