Issue 9 chat api (#25)
This commit is contained in:
380
server/handlers/chats.go
Normal file
380
server/handlers/chats.go
Normal file
@@ -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"})
|
||||||
|
}
|
||||||
218
server/handlers/chats_test.go
Normal file
218
server/handlers/chats_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
174
server/handlers/messages.go
Normal file
174
server/handlers/messages.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ func main() {
|
|||||||
|
|
||||||
// ── Auth routes (rate limited) ──────────────
|
// ── Auth routes (rate limited) ──────────────
|
||||||
auth := handlers.NewAuthHandler(cfg)
|
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")
|
api := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
@@ -52,27 +52,30 @@ func main() {
|
|||||||
protected.Use(middleware.Auth(cfg))
|
protected.Use(middleware.Auth(cfg))
|
||||||
{
|
{
|
||||||
// Chats
|
// Chats
|
||||||
protected.GET("/chats", handleListChats)
|
chats := handlers.NewChatHandler()
|
||||||
protected.POST("/chats", handleCreateChat)
|
protected.GET("/chats", chats.ListChats)
|
||||||
protected.GET("/chats/:id", handleGetChat)
|
protected.POST("/chats", chats.CreateChat)
|
||||||
protected.PUT("/chats/:id", handleUpdateChat)
|
protected.GET("/chats/:id", chats.GetChat)
|
||||||
protected.DELETE("/chats/:id", handleDeleteChat)
|
protected.PUT("/chats/:id", chats.UpdateChat)
|
||||||
|
protected.DELETE("/chats/:id", chats.DeleteChat)
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
protected.GET("/chats/:id/messages", handleGetMessages)
|
msgs := handlers.NewMessageHandler()
|
||||||
protected.POST("/chats/:id/messages", handleCreateMessage)
|
protected.GET("/chats/:id/messages", msgs.ListMessages)
|
||||||
|
protected.POST("/chats/:id/messages", msgs.CreateMessage)
|
||||||
|
protected.POST("/chats/:id/regenerate", msgs.Regenerate)
|
||||||
|
|
||||||
// Settings
|
// Settings — ticket #10
|
||||||
protected.GET("/settings", handleGetSettings)
|
protected.GET("/settings", stubHandler("settings"))
|
||||||
protected.PUT("/settings", handleUpdateSettings)
|
protected.PUT("/settings", stubHandler("settings"))
|
||||||
|
|
||||||
// API Configs
|
// API Configs — ticket #10
|
||||||
protected.GET("/api-configs", handleListAPIConfigs)
|
protected.GET("/api-configs", stubHandler("api-configs"))
|
||||||
protected.POST("/api-configs", handleCreateAPIConfig)
|
protected.POST("/api-configs", stubHandler("api-configs"))
|
||||||
protected.DELETE("/api-configs/:id", handleDeleteAPIConfig)
|
protected.DELETE("/api-configs/:id", stubHandler("api-configs"))
|
||||||
|
|
||||||
// Models
|
// Models — ticket #10
|
||||||
protected.GET("/models", handleListModels)
|
protected.GET("/models", stubHandler("models"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,21 +85,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder handlers — ticket #9
|
func stubHandler(feature string) gin.HandlerFunc {
|
||||||
func handleListChats(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
return func(c *gin.Context) {
|
||||||
func handleCreateChat(c *gin.Context) { c.JSON(501, gin.H{"error": "not implemented"}) }
|
c.JSON(501, gin.H{"error": feature + " 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 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"}) }
|
|
||||||
|
|||||||
@@ -27,16 +27,14 @@ func TestHealthEndpoint(t *testing.T) {
|
|||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
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) {
|
func TestCORSHeaders(t *testing.T) {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(middleware.CORS())
|
r.Use(middleware.CORS())
|
||||||
r.GET("/test", func(c *gin.Context) {
|
r.GET("/test", func(c *gin.Context) { c.Status(200) })
|
||||||
c.Status(200)
|
|
||||||
})
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req, _ := http.NewRequest("OPTIONS", "/test", nil)
|
req, _ := http.NewRequest("OPTIONS", "/test", nil)
|
||||||
@@ -50,12 +48,15 @@ func TestCORSHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthRoutesRegistered(t *testing.T) {
|
func TestAllRoutesRegistered(t *testing.T) {
|
||||||
cfg := &config.Config{JWTSecret: "test-secret"}
|
cfg := &config.Config{JWTSecret: "test"}
|
||||||
auth := handlers.NewAuthHandler(cfg)
|
auth := handlers.NewAuthHandler(cfg)
|
||||||
|
chats := handlers.NewChatHandler()
|
||||||
|
msgs := handlers.NewMessageHandler()
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
api := r.Group("/api/v1")
|
api := r.Group("/api/v1")
|
||||||
|
|
||||||
authGroup := api.Group("/auth")
|
authGroup := api.Group("/auth")
|
||||||
{
|
{
|
||||||
authGroup.POST("/register", auth.Register)
|
authGroup.POST("/register", auth.Register)
|
||||||
@@ -64,6 +65,19 @@ func TestAuthRoutesRegistered(t *testing.T) {
|
|||||||
authGroup.POST("/logout", auth.Logout)
|
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()
|
routes := r.Routes()
|
||||||
routePaths := make(map[string]bool)
|
routePaths := make(map[string]bool)
|
||||||
for _, route := range routes {
|
for _, route := range routes {
|
||||||
@@ -75,65 +89,47 @@ func TestAuthRoutesRegistered(t *testing.T) {
|
|||||||
"POST /api/v1/auth/login",
|
"POST /api/v1/auth/login",
|
||||||
"POST /api/v1/auth/refresh",
|
"POST /api/v1/auth/refresh",
|
||||||
"POST /api/v1/auth/logout",
|
"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 {
|
for _, e := range expected {
|
||||||
if !routePaths[e] {
|
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()
|
r := gin.New()
|
||||||
api := r.Group("/api/v1")
|
r.GET("/test", stubHandler("settings"))
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
routes := r.Routes()
|
w := httptest.NewRecorder()
|
||||||
if len(routes) != 9 {
|
req, _ := http.NewRequest("GET", "/test", nil)
|
||||||
t.Errorf("Expected 9 routes, got %d", len(routes))
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 501 {
|
||||||
|
t.Errorf("Expected 501, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthMiddlewareNoDatabase(t *testing.T) {
|
func TestAuthMiddlewareNoDatabase(t *testing.T) {
|
||||||
cfg := &config.Config{JWTSecret: "test-secret"}
|
cfg := &config.Config{JWTSecret: "test"}
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(middleware.Auth(cfg))
|
r.Use(middleware.Auth(cfg))
|
||||||
r.GET("/protected", func(c *gin.Context) {
|
r.GET("/protected", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||||
c.JSON(200, gin.H{"ok": true})
|
|
||||||
})
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req, _ := http.NewRequest("GET", "/protected", nil)
|
req, _ := http.NewRequest("GET", "/protected", nil)
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
// No database connected → middleware should pass through
|
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Errorf("Expected pass-through with no DB, got status %d", w.Code)
|
t.Errorf("Expected pass-through with no DB, got %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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user