From 676710a00509020508c5e6fe708098372b2a5613 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Mon, 16 Feb 2026 01:01:33 +0000 Subject: [PATCH] Issue 9 chat api (#25) --- server/handlers/chats.go | 380 ++++++++++++++++++++++++++++++++++ server/handlers/chats_test.go | 218 +++++++++++++++++++ server/handlers/messages.go | 174 ++++++++++++++++ server/main.go | 58 +++--- server/main_test.go | 84 ++++---- 5 files changed, 836 insertions(+), 78 deletions(-) create mode 100644 server/handlers/chats.go create mode 100644 server/handlers/chats_test.go create mode 100644 server/handlers/messages.go diff --git a/server/handlers/chats.go b/server/handlers/chats.go new file mode 100644 index 0000000..ffe9933 --- /dev/null +++ b/server/handlers/chats.go @@ -0,0 +1,380 @@ +package handlers + +import ( + "database/sql" + "math" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/lib/pq" + + "git.gobha.me/xcaliber/chat-switchboard/database" +) + +// ── Request / Response types ──────────────── + +type createChatRequest struct { + Title string `json:"title" binding:"required,max=500"` + Model string `json:"model,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` + APIConfigID *string `json:"api_config_id,omitempty"` + Folder string `json:"folder,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type updateChatRequest struct { + Title *string `json:"title,omitempty"` + Model *string `json:"model,omitempty"` + SystemPrompt *string `json:"system_prompt,omitempty"` + APIConfigID *string `json:"api_config_id,omitempty"` + IsArchived *bool `json:"is_archived,omitempty"` + IsPinned *bool `json:"is_pinned,omitempty"` + Folder *string `json:"folder,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type chatResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Title string `json:"title"` + Model *string `json:"model"` + APIConfigID *string `json:"api_config_id"` + SystemPrompt *string `json:"system_prompt"` + IsArchived bool `json:"is_archived"` + IsPinned bool `json:"is_pinned"` + Folder *string `json:"folder"` + Tags []string `json:"tags"` + MessageCount int `json:"message_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type paginatedResponse struct { + Data interface{} `json:"data"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` +} + +// ChatHandler holds dependencies for chat endpoints. +type ChatHandler struct{} + +// NewChatHandler creates a new chat handler. +func NewChatHandler() *ChatHandler { + return &ChatHandler{} +} + +// ── Helpers ───────────────────────────────── + +// getUserID extracts the authenticated user's ID from context. +func getUserID(c *gin.Context) string { + uid, _ := c.Get("user_id") + s, _ := uid.(string) + return s +} + +// parsePagination reads page and per_page from query params with defaults. +func parsePagination(c *gin.Context) (page, perPage, offset int) { + page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) + perPage, _ = strconv.Atoi(c.DefaultQuery("per_page", "50")) + + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 50 + } + if perPage > 100 { + perPage = 100 + } + offset = (page - 1) * perPage + return +} + +// ── List Chats ────────────────────────────── + +func (h *ChatHandler) ListChats(c *gin.Context) { + userID := getUserID(c) + page, perPage, offset := parsePagination(c) + + // Optional filters + archived := c.DefaultQuery("archived", "false") + folder := c.Query("folder") + + // Count total + var total int + countQuery := `SELECT COUNT(*) FROM chats WHERE user_id = $1 AND is_archived = $2` + countArgs := []interface{}{userID, archived == "true"} + + if folder != "" { + countQuery += ` AND folder = $3` + countArgs = append(countArgs, folder) + } + + if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count chats"}) + return + } + + // Fetch chats with message count + query := ` + SELECT c.id, c.user_id, c.title, c.model, c.api_config_id, + c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, + COALESCE(mc.cnt, 0) AS message_count, + c.created_at, c.updated_at + FROM chats c + LEFT JOIN ( + SELECT chat_id, COUNT(*) AS cnt FROM chat_messages GROUP BY chat_id + ) mc ON mc.chat_id = c.id + WHERE c.user_id = $1 AND c.is_archived = $2` + + args := []interface{}{userID, archived == "true"} + argN := 3 + + if folder != "" { + query += ` AND c.folder = $` + strconv.Itoa(argN) + args = append(args, folder) + argN++ + } + + query += ` ORDER BY c.is_pinned DESC, c.updated_at DESC + LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1) + args = append(args, perPage, offset) + + rows, err := database.DB.Query(query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list chats"}) + return + } + defer rows.Close() + + chats := make([]chatResponse, 0) + for rows.Next() { + var chat chatResponse + var tags []string + err := rows.Scan( + &chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID, + &chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder, + pq.Array(&tags), + &chat.MessageCount, &chat.CreatedAt, &chat.UpdatedAt, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan chat"}) + return + } + if tags == nil { + tags = []string{} + } + chat.Tags = tags + chats = append(chats, chat) + } + + c.JSON(http.StatusOK, paginatedResponse{ + Data: chats, + Page: page, + PerPage: perPage, + Total: total, + TotalPages: int(math.Ceil(float64(total) / float64(perPage))), + }) +} + +// ── Create Chat ───────────────────────────── + +func (h *ChatHandler) CreateChat(c *gin.Context) { + userID := getUserID(c) + + var req createChatRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Tags == nil { + req.Tags = []string{} + } + + var chat chatResponse + var tags []string + err := database.DB.QueryRow(` + INSERT INTO chats (user_id, title, model, system_prompt, api_config_id, folder, tags) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, user_id, title, model, api_config_id, system_prompt, + is_archived, is_pinned, folder, tags, created_at, updated_at + `, userID, req.Title, req.Model, req.SystemPrompt, req.APIConfigID, + req.Folder, pq.Array(req.Tags), + ).Scan( + &chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID, + &chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder, + pq.Array(&tags), &chat.CreatedAt, &chat.UpdatedAt, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create chat"}) + return + } + + if tags == nil { + tags = []string{} + } + chat.Tags = tags + chat.MessageCount = 0 + + c.JSON(http.StatusCreated, chat) +} + +// ── Get Chat ──────────────────────────────── + +func (h *ChatHandler) GetChat(c *gin.Context) { + userID := getUserID(c) + chatID := c.Param("id") + + var chat chatResponse + var tags []string + err := database.DB.QueryRow(` + SELECT c.id, c.user_id, c.title, c.model, c.api_config_id, + c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, + COALESCE(mc.cnt, 0) AS message_count, + c.created_at, c.updated_at + FROM chats c + LEFT JOIN ( + SELECT chat_id, COUNT(*) AS cnt FROM chat_messages GROUP BY chat_id + ) mc ON mc.chat_id = c.id + WHERE c.id = $1 AND c.user_id = $2 + `, chatID, userID).Scan( + &chat.ID, &chat.UserID, &chat.Title, &chat.Model, &chat.APIConfigID, + &chat.SystemPrompt, &chat.IsArchived, &chat.IsPinned, &chat.Folder, + pq.Array(&tags), + &chat.MessageCount, &chat.CreatedAt, &chat.UpdatedAt, + ) + + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get chat"}) + return + } + + if tags == nil { + tags = []string{} + } + chat.Tags = tags + + c.JSON(http.StatusOK, chat) +} + +// ── Update Chat ───────────────────────────── + +func (h *ChatHandler) UpdateChat(c *gin.Context) { + userID := getUserID(c) + chatID := c.Param("id") + + if database.DB == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database unavailable"}) + return + } + + var req updateChatRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Verify ownership + var ownerID string + err := database.DB.QueryRow(`SELECT user_id FROM chats WHERE id = $1`, chatID).Scan(&ownerID) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) + return + } + if ownerID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "not your chat"}) + return + } + + // Build dynamic UPDATE + setClauses := []string{} + args := []interface{}{} + argN := 1 + + addClause := func(col string, val interface{}) { + setClauses = append(setClauses, col+" = $"+strconv.Itoa(argN)) + args = append(args, val) + argN++ + } + + if req.Title != nil { + addClause("title", *req.Title) + } + if req.Model != nil { + addClause("model", *req.Model) + } + if req.SystemPrompt != nil { + addClause("system_prompt", *req.SystemPrompt) + } + if req.APIConfigID != nil { + addClause("api_config_id", *req.APIConfigID) + } + if req.IsArchived != nil { + addClause("is_archived", *req.IsArchived) + } + if req.IsPinned != nil { + addClause("is_pinned", *req.IsPinned) + } + if req.Folder != nil { + addClause("folder", *req.Folder) + } + if req.Tags != nil { + addClause("tags", pq.Array(req.Tags)) + } + + if len(setClauses) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + query := "UPDATE chats SET " + for i, clause := range setClauses { + if i > 0 { + query += ", " + } + query += clause + } + query += " WHERE id = $" + strconv.Itoa(argN) + " AND user_id = $" + strconv.Itoa(argN+1) + args = append(args, chatID, userID) + + _, err = database.DB.Exec(query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update chat"}) + return + } + + // Return updated chat + h.GetChat(c) +} + +// ── Delete Chat ───────────────────────────── + +func (h *ChatHandler) DeleteChat(c *gin.Context) { + userID := getUserID(c) + chatID := c.Param("id") + + result, err := database.DB.Exec( + `DELETE FROM chats WHERE id = $1 AND user_id = $2`, + chatID, userID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete chat"}) + return + } + + rows, _ := result.RowsAffected() + if rows == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "chat deleted"}) +} diff --git a/server/handlers/chats_test.go b/server/handlers/chats_test.go new file mode 100644 index 0000000..b966439 --- /dev/null +++ b/server/handlers/chats_test.go @@ -0,0 +1,218 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// ── Chat Request Validation ───────────────── + +func TestCreateChatMissingTitle(t *testing.T) { + h := NewChatHandler() + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/api/v1/chats", + strings.NewReader(`{}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.CreateChat(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCreateChatTitleTooLong(t *testing.T) { + h := NewChatHandler() + + longTitle := strings.Repeat("x", 501) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/api/v1/chats", + strings.NewReader(`{"title":"`+longTitle+`"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.CreateChat(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for title > 500 chars, got %d", w.Code) + } +} + +func TestUpdateChatEmptyBody(t *testing.T) { + h := NewChatHandler() + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("user_id", "test-user-id") + c.Params = gin.Params{{Key: "id", Value: "test-chat-id"}} + c.Request = httptest.NewRequest("PUT", "/api/v1/chats/test-chat-id", + strings.NewReader(`{}`)) + c.Request.Header.Set("Content-Type", "application/json") + + // Without a DB connection, UpdateChat will fail at ownership check. + // Integration tests with a real DB would validate the "no fields" path. + // Here we just confirm it doesn't return 400 for valid JSON. + h.UpdateChat(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-chat"}} + c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/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-chat"}} + c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/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-chat"}} + c.Request = httptest.NewRequest("POST", "/api/v1/chats/test-chat/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) + } +} + +func TestCreateMessageValidRoles(t *testing.T) { + // Valid roles should pass validation (not return 400). + // They'll fail at ownership check (no DB), which is expected. + t.Skip("requires database connection for ownership check") +} + +// ── Regenerate Stub ───────────────────────── + +func TestRegenerateReturns501(t *testing.T) { + h := NewMessageHandler() + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/api/v1/chats/x/regenerate", nil) + + h.Regenerate(c) + + if w.Code != http.StatusNotImplemented { + t.Errorf("Expected 501, got %d", w.Code) + } +} + +// ── Pagination Helpers ────────────────────── + +func TestParsePaginationDefaults(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/chats", 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/chats?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/chats?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) + } +} diff --git a/server/handlers/messages.go b/server/handlers/messages.go new file mode 100644 index 0000000..1266b06 --- /dev/null +++ b/server/handlers/messages.go @@ -0,0 +1,174 @@ +package handlers + +import ( + "database/sql" + "math" + "net/http" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/database" +) + +// ── Request / Response types ──────────────── + +type createMessageRequest struct { + Role string `json:"role" binding:"required,oneof=user assistant system"` + Content string `json:"content" binding:"required"` + Model string `json:"model,omitempty"` +} + +type messageResponse struct { + ID string `json:"id"` + ChatID string `json:"chat_id"` + Role string `json:"role"` + Content string `json:"content"` + Model *string `json:"model"` + TokensUsed *int `json:"tokens_used"` + CreatedAt string `json:"created_at"` +} + +// MessageHandler holds dependencies for message endpoints. +type MessageHandler struct{} + +// NewMessageHandler creates a new message handler. +func NewMessageHandler() *MessageHandler { + return &MessageHandler{} +} + +// ── List Messages ─────────────────────────── + +func (h *MessageHandler) ListMessages(c *gin.Context) { + page, perPage, offset := parsePagination(c) + + userID := getUserID(c) + chatID := c.Param("id") + + // Verify ownership + if !userOwnsChat(c, chatID, userID) { + return + } + + // Count total messages + var total int + err := database.DB.QueryRow( + `SELECT COUNT(*) FROM chat_messages WHERE chat_id = $1`, + chatID, + ).Scan(&total) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count messages"}) + return + } + + // Fetch messages (oldest first for conversation display) + rows, err := database.DB.Query(` + SELECT id, chat_id, role, content, model, tokens_used, created_at + FROM chat_messages + WHERE chat_id = $1 + ORDER BY created_at ASC + LIMIT $2 OFFSET $3 + `, chatID, perPage, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"}) + return + } + defer rows.Close() + + messages := make([]messageResponse, 0) + for rows.Next() { + var msg messageResponse + err := rows.Scan( + &msg.ID, &msg.ChatID, &msg.Role, &msg.Content, + &msg.Model, &msg.TokensUsed, &msg.CreatedAt, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"}) + return + } + messages = append(messages, msg) + } + + c.JSON(http.StatusOK, paginatedResponse{ + Data: messages, + Page: page, + PerPage: perPage, + Total: total, + TotalPages: int(math.Ceil(float64(total) / float64(perPage))), + }) +} + +// ── Create Message ────────────────────────── + +func (h *MessageHandler) CreateMessage(c *gin.Context) { + var req createMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := getUserID(c) + chatID := c.Param("id") + + // Verify ownership + if !userOwnsChat(c, chatID, userID) { + return + } + + var msg messageResponse + err := database.DB.QueryRow(` + INSERT INTO chat_messages (chat_id, role, content, model) + VALUES ($1, $2, $3, $4) + RETURNING id, chat_id, role, content, model, tokens_used, created_at + `, chatID, req.Role, req.Content, req.Model).Scan( + &msg.ID, &msg.ChatID, &msg.Role, &msg.Content, + &msg.Model, &msg.TokensUsed, &msg.CreatedAt, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"}) + return + } + + // Touch the chat's updated_at so it sorts to top of list + _, _ = database.DB.Exec(`UPDATE chats SET updated_at = NOW() WHERE id = $1`, chatID) + + c.JSON(http.StatusCreated, msg) +} + +// ── Regenerate (stub for #8 Chat Engine) ──── + +func (h *MessageHandler) Regenerate(c *gin.Context) { + c.JSON(http.StatusNotImplemented, gin.H{ + "error": "regenerate requires Chat Engine (ticket #8)", + }) +} + +// ── Stream (stub for #8 Chat Engine) ──────── + +func (h *MessageHandler) Stream(c *gin.Context) { + c.JSON(http.StatusNotImplemented, gin.H{ + "error": "streaming requires Chat Engine (ticket #8)", + }) +} + +// ── Ownership Check ───────────────────────── + +func userOwnsChat(c *gin.Context, chatID, userID string) bool { + var ownerID string + err := database.DB.QueryRow( + `SELECT user_id FROM chats WHERE id = $1`, chatID, + ).Scan(&ownerID) + + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) + return false + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify chat ownership"}) + return false + } + if ownerID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "not your chat"}) + return false + } + return true +} diff --git a/server/main.go b/server/main.go index ad23128..f2a3e2b 100644 --- a/server/main.go +++ b/server/main.go @@ -34,7 +34,7 @@ func main() { // ── Auth routes (rate limited) ────────────── auth := handlers.NewAuthHandler(cfg) - authLimiter := middleware.NewRateLimiter(1, 5) // 1 req/s, burst of 5 + authLimiter := middleware.NewRateLimiter(1, 5) api := r.Group("/api/v1") { @@ -52,27 +52,30 @@ func main() { protected.Use(middleware.Auth(cfg)) { // Chats - protected.GET("/chats", handleListChats) - protected.POST("/chats", handleCreateChat) - protected.GET("/chats/:id", handleGetChat) - protected.PUT("/chats/:id", handleUpdateChat) - protected.DELETE("/chats/:id", handleDeleteChat) + chats := handlers.NewChatHandler() + protected.GET("/chats", chats.ListChats) + protected.POST("/chats", chats.CreateChat) + protected.GET("/chats/:id", chats.GetChat) + protected.PUT("/chats/:id", chats.UpdateChat) + protected.DELETE("/chats/:id", chats.DeleteChat) // Messages - protected.GET("/chats/:id/messages", handleGetMessages) - protected.POST("/chats/:id/messages", handleCreateMessage) + msgs := handlers.NewMessageHandler() + protected.GET("/chats/:id/messages", msgs.ListMessages) + protected.POST("/chats/:id/messages", msgs.CreateMessage) + protected.POST("/chats/:id/regenerate", msgs.Regenerate) - // Settings - protected.GET("/settings", handleGetSettings) - protected.PUT("/settings", handleUpdateSettings) + // Settings — ticket #10 + protected.GET("/settings", stubHandler("settings")) + protected.PUT("/settings", stubHandler("settings")) - // API Configs - protected.GET("/api-configs", handleListAPIConfigs) - protected.POST("/api-configs", handleCreateAPIConfig) - protected.DELETE("/api-configs/:id", handleDeleteAPIConfig) + // API Configs — ticket #10 + protected.GET("/api-configs", stubHandler("api-configs")) + protected.POST("/api-configs", stubHandler("api-configs")) + protected.DELETE("/api-configs/:id", stubHandler("api-configs")) - // Models - protected.GET("/models", handleListModels) + // Models — ticket #10 + protected.GET("/models", stubHandler("models")) } } @@ -82,21 +85,8 @@ func main() { } } -// Placeholder handlers — ticket #9 -func handleListChats(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleCreateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleGetChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleUpdateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleDeleteChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleGetMessages(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleCreateMessage(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleGetSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleUpdateSettings(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleListAPIConfigs(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } -func handleCreateAPIConfig(c *gin.Context) { - c.JSON(501, gin.H{"error": "not implemented"}) +func stubHandler(feature string) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(501, gin.H{"error": feature + " not implemented"}) + } } -func handleDeleteAPIConfig(c *gin.Context) { - c.JSON(501, gin.H{"error": "not implemented"}) -} -func handleListModels(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) } diff --git a/server/main_test.go b/server/main_test.go index cced5f6..52f6712 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -27,16 +27,14 @@ func TestHealthEndpoint(t *testing.T) { r.ServeHTTP(w, req) if w.Code != http.StatusOK { - t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + t.Errorf("Expected %d, got %d", http.StatusOK, w.Code) } } func TestCORSHeaders(t *testing.T) { r := gin.New() r.Use(middleware.CORS()) - r.GET("/test", func(c *gin.Context) { - c.Status(200) - }) + r.GET("/test", func(c *gin.Context) { c.Status(200) }) w := httptest.NewRecorder() req, _ := http.NewRequest("OPTIONS", "/test", nil) @@ -50,12 +48,15 @@ func TestCORSHeaders(t *testing.T) { } } -func TestAuthRoutesRegistered(t *testing.T) { - cfg := &config.Config{JWTSecret: "test-secret"} +func TestAllRoutesRegistered(t *testing.T) { + cfg := &config.Config{JWTSecret: "test"} auth := handlers.NewAuthHandler(cfg) + chats := handlers.NewChatHandler() + msgs := handlers.NewMessageHandler() r := gin.New() api := r.Group("/api/v1") + authGroup := api.Group("/auth") { authGroup.POST("/register", auth.Register) @@ -64,6 +65,19 @@ func TestAuthRoutesRegistered(t *testing.T) { authGroup.POST("/logout", auth.Logout) } + protected := api.Group("") + { + protected.GET("/chats", chats.ListChats) + protected.POST("/chats", chats.CreateChat) + protected.GET("/chats/:id", chats.GetChat) + protected.PUT("/chats/:id", chats.UpdateChat) + protected.DELETE("/chats/:id", chats.DeleteChat) + + protected.GET("/chats/:id/messages", msgs.ListMessages) + protected.POST("/chats/:id/messages", msgs.CreateMessage) + protected.POST("/chats/:id/regenerate", msgs.Regenerate) + } + routes := r.Routes() routePaths := make(map[string]bool) for _, route := range routes { @@ -75,65 +89,47 @@ func TestAuthRoutesRegistered(t *testing.T) { "POST /api/v1/auth/login", "POST /api/v1/auth/refresh", "POST /api/v1/auth/logout", + "GET /api/v1/chats", + "POST /api/v1/chats", + "GET /api/v1/chats/:id", + "PUT /api/v1/chats/:id", + "DELETE /api/v1/chats/:id", + "GET /api/v1/chats/:id/messages", + "POST /api/v1/chats/:id/messages", + "POST /api/v1/chats/:id/regenerate", } for _, e := range expected { if !routePaths[e] { - t.Errorf("Expected route %s to be registered", e) + t.Errorf("Missing route: %s", e) } } } -func TestProtectedRoutesRegistered(t *testing.T) { +func TestStubHandler(t *testing.T) { r := gin.New() - api := r.Group("/api/v1") - protected := api.Group("") - { - protected.GET("/chats", handleListChats) - protected.POST("/chats", handleCreateChat) - protected.GET("/chats/:id", handleGetChat) - protected.DELETE("/chats/:id", handleDeleteChat) - protected.GET("/chats/:id/messages", handleGetMessages) - protected.POST("/chats/:id/messages", handleCreateMessage) - protected.GET("/settings", handleGetSettings) - protected.GET("/api-configs", handleListAPIConfigs) - protected.GET("/models", handleListModels) - } + r.GET("/test", stubHandler("settings")) - routes := r.Routes() - if len(routes) != 9 { - t.Errorf("Expected 9 routes, got %d", len(routes)) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != 501 { + t.Errorf("Expected 501, got %d", w.Code) } } func TestAuthMiddlewareNoDatabase(t *testing.T) { - cfg := &config.Config{JWTSecret: "test-secret"} + cfg := &config.Config{JWTSecret: "test"} r := gin.New() r.Use(middleware.Auth(cfg)) - r.GET("/protected", func(c *gin.Context) { - c.JSON(200, gin.H{"ok": true}) - }) + r.GET("/protected", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/protected", nil) r.ServeHTTP(w, req) - // No database connected → middleware should pass through if w.Code != http.StatusOK { - t.Errorf("Expected pass-through with no DB, got status %d", w.Code) - } -} - -func TestPlaceholderHandlersReturn501(t *testing.T) { - r := gin.New() - r.GET("/chats", handleListChats) - r.POST("/chats", handleCreateChat) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/chats", nil) - r.ServeHTTP(w, req) - - if w.Code != 501 { - t.Errorf("Expected 501 for unimplemented handler, got %d", w.Code) + t.Errorf("Expected pass-through with no DB, got %d", w.Code) } }