Changeset 0.7.2 (#40)
This commit is contained in:
@@ -12,11 +12,6 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
providers.Init()
|
||||
}
|
||||
|
||||
func TestCreateConfigMissingFields(t *testing.T) {
|
||||
h := NewAPIConfigHandler()
|
||||
r := gin.New()
|
||||
|
||||
@@ -17,10 +17,6 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func testConfig() *config.Config {
|
||||
return &config.Config{
|
||||
JWTSecret: "test-secret-key-for-unit-tests",
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
// ── Channel Request Validation ─────────────────
|
||||
|
||||
@@ -60,7 +59,7 @@ func TestUpdateChannelEmptyBody(t *testing.T) {
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Without a DB connection, UpdateChannel will fail at ownership check.
|
||||
// Integration tests with a real DB would validate the "no fields" path.
|
||||
// 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)
|
||||
|
||||
@@ -122,19 +121,151 @@ func TestCreateMessageMissingContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Integration: Message CRUD with Real DB ──────
|
||||
|
||||
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")
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Regenerate ───────────────────────────────
|
||||
func TestChannelCRUDIntegration(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
func TestRegenerateRequiresDB(t *testing.T) {
|
||||
// Regenerate is now a full handler (creates sibling assistant message
|
||||
// via tree-aware streaming). It calls userOwnsChannel → database.DB,
|
||||
// so it cannot run without a live database connection.
|
||||
t.Skip("requires database connection for ownership check")
|
||||
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 ──────────────────────
|
||||
|
||||
@@ -4,14 +4,15 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
)
|
||||
|
||||
// ── Request Types ───────────────────────────
|
||||
@@ -45,9 +46,11 @@ func NewCompletionHandler() *CompletionHandler {
|
||||
// 2. Resolve api_config (from request, chat, or user default)
|
||||
// 3. Load conversation history from messages
|
||||
// 4. Persist user message
|
||||
// 5. Call provider (stream or non-stream)
|
||||
// 6. Stream SSE to client / return JSON
|
||||
// 7. Persist assistant message with token counts
|
||||
// 5. Attach tool definitions if model supports tools
|
||||
// 6. Call provider (stream or non-stream)
|
||||
// 7. Tool loop: if LLM requests tool calls, execute them and re-call
|
||||
// 8. Stream SSE to client / return JSON
|
||||
// 9. Persist assistant message with token counts
|
||||
|
||||
func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
var req completionRequest
|
||||
@@ -153,6 +156,11 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
provReq.TopP = req.TopP
|
||||
}
|
||||
|
||||
// Attach tool definitions if model supports tool calling and tools are available
|
||||
if caps.ToolCalling && tools.HasTools() {
|
||||
provReq.Tools = h.buildToolDefs()
|
||||
}
|
||||
|
||||
// Determine streaming
|
||||
stream := true // default
|
||||
if req.Stream != nil {
|
||||
@@ -166,7 +174,26 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streaming Completion (SSE) ──────────────
|
||||
// buildToolDefs converts registered tools to provider-format tool definitions.
|
||||
func (h *CompletionHandler) buildToolDefs() []providers.ToolDef {
|
||||
allTools := tools.AllDefinitions()
|
||||
defs := make([]providers.ToolDef, len(allTools))
|
||||
for i, t := range allTools {
|
||||
defs[i] = providers.ToolDef{
|
||||
Type: "function",
|
||||
Function: providers.FunctionDef{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
},
|
||||
}
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// ── Streaming Completion (SSE) with Tool Loop ──
|
||||
|
||||
const maxToolIterations = 10 // safety limit on tool call rounds
|
||||
|
||||
func (h *CompletionHandler) streamCompletion(
|
||||
c *gin.Context,
|
||||
@@ -175,12 +202,6 @@ func (h *CompletionHandler) streamCompletion(
|
||||
req providers.CompletionRequest,
|
||||
channelID, userID, model string,
|
||||
) {
|
||||
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
@@ -188,45 +209,150 @@ func (h *CompletionHandler) streamCompletion(
|
||||
c.Header("X-Accel-Buffering", "no") // Disable nginx buffering
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
var fullContent string
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
|
||||
for event := range ch {
|
||||
if event.Error != nil {
|
||||
fmt.Fprintf(c.Writer, "data: {\"error\":\"%s\"}\n\n", event.Error.Error())
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if event.Delta != "" {
|
||||
fullContent += event.Delta
|
||||
// OpenAI-compatible SSE format
|
||||
fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{\"content\":%q},\"finish_reason\":null}],\"model\":%q}\n\n",
|
||||
event.Delta, model)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
if event.Done {
|
||||
finishReason := event.FinishReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":%q}],\"model\":%q}\n\n",
|
||||
finishReason, model)
|
||||
io.WriteString(c.Writer, "data: [DONE]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
break
|
||||
flush := func() {
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Persist assistant response
|
||||
// sendSSE sends a standard OpenAI-compatible SSE data line.
|
||||
sendSSE := func(data string) {
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
|
||||
flush()
|
||||
}
|
||||
|
||||
// sendEvent sends a named SSE event (for tool status — non-standard).
|
||||
sendEvent := func(event, data string) {
|
||||
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
|
||||
flush()
|
||||
}
|
||||
|
||||
var fullContent string
|
||||
|
||||
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
||||
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req)
|
||||
if err != nil {
|
||||
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
var iterContent string
|
||||
var toolCalls []providers.ToolCall
|
||||
|
||||
for event := range ch {
|
||||
if event.Error != nil {
|
||||
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
// Stream text deltas to client
|
||||
if event.Delta != "" {
|
||||
iterContent += event.Delta
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q}`,
|
||||
event.Delta, model))
|
||||
}
|
||||
|
||||
if event.Done {
|
||||
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
|
||||
toolCalls = event.ToolCalls
|
||||
} else {
|
||||
// Normal completion — send finish event
|
||||
finishReason := event.FinishReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
fullContent += iterContent
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`,
|
||||
finishReason, model))
|
||||
sendSSE("[DONE]")
|
||||
|
||||
// Persist assistant response
|
||||
if fullContent != "" {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool execution round ────────────────
|
||||
if len(toolCalls) == 0 {
|
||||
// Stream ended without tool calls or finish — shouldn't happen
|
||||
sendSSE("[DONE]")
|
||||
if iterContent != "" {
|
||||
fullContent += iterContent
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Notify client about tool calls
|
||||
toolCallsJSON, _ := json.Marshal(toolCalls)
|
||||
sendEvent("tool_use", string(toolCallsJSON))
|
||||
|
||||
// Append assistant message (with tool_calls, possibly with text) to conversation
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: iterContent,
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
|
||||
}
|
||||
req.Messages = append(req.Messages, assistantMsg)
|
||||
|
||||
// Execute all tool calls
|
||||
execCtx := tools.ExecutionContext{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
call := tools.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
}
|
||||
|
||||
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
|
||||
result := tools.ExecuteCall(c.Request.Context(), execCtx, call)
|
||||
|
||||
// Notify client about tool result
|
||||
resultJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"tool_call_id": result.ToolCallID,
|
||||
"name": result.Name,
|
||||
"content": result.Content,
|
||||
"is_error": result.IsError,
|
||||
})
|
||||
sendEvent("tool_result", string(resultJSON))
|
||||
|
||||
// Append tool result to conversation for next iteration
|
||||
req.Messages = append(req.Messages, providers.Message{
|
||||
Role: "tool",
|
||||
Content: result.Content,
|
||||
ToolCallID: result.ToolCallID,
|
||||
Name: result.Name,
|
||||
})
|
||||
}
|
||||
|
||||
fullContent += iterContent
|
||||
// Loop: send updated messages back to provider
|
||||
}
|
||||
|
||||
// If we hit max iterations, send what we have
|
||||
log.Printf("⚠️ Tool loop hit max iterations (%d) for channel %s", maxToolIterations, channelID)
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q}`,
|
||||
escapeJSON("[Tool execution limit reached]"), model))
|
||||
sendSSE("[DONE]")
|
||||
|
||||
if fullContent != "" {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
@@ -234,7 +360,7 @@ func (h *CompletionHandler) streamCompletion(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Non-Streaming Completion ────────────────
|
||||
// ── Non-Streaming Completion with Tool Loop ──
|
||||
|
||||
func (h *CompletionHandler) syncCompletion(
|
||||
c *gin.Context,
|
||||
@@ -243,39 +369,108 @@ func (h *CompletionHandler) syncCompletion(
|
||||
req providers.CompletionRequest,
|
||||
channelID, userID, model string,
|
||||
) {
|
||||
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
|
||||
return
|
||||
var totalInput, totalOutput int
|
||||
var finalContent string
|
||||
|
||||
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
||||
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
totalInput += resp.InputTokens
|
||||
totalOutput += resp.OutputTokens
|
||||
|
||||
// No tool calls — normal response
|
||||
if len(resp.ToolCalls) == 0 || resp.FinishReason != "tool_calls" {
|
||||
finalContent = resp.Content
|
||||
|
||||
// Persist assistant response
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
// Return OpenAI-compatible response
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"model": resp.Model,
|
||||
"choices": []gin.H{
|
||||
{
|
||||
"message": gin.H{
|
||||
"role": "assistant",
|
||||
"content": finalContent,
|
||||
},
|
||||
"finish_reason": resp.FinishReason,
|
||||
},
|
||||
},
|
||||
"usage": gin.H{
|
||||
"prompt_tokens": totalInput,
|
||||
"completion_tokens": totalOutput,
|
||||
"total_tokens": totalInput + totalOutput,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Tool execution round ────────────────
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: resp.Content,
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
|
||||
}
|
||||
req.Messages = append(req.Messages, assistantMsg)
|
||||
|
||||
execCtx := tools.ExecutionContext{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
call := tools.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
}
|
||||
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
|
||||
result := tools.ExecuteCall(c.Request.Context(), execCtx, call)
|
||||
|
||||
req.Messages = append(req.Messages, providers.Message{
|
||||
Role: "tool",
|
||||
Content: result.Content,
|
||||
ToolCallID: result.ToolCallID,
|
||||
Name: result.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Persist assistant response
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens, nil); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
// Return OpenAI-compatible response
|
||||
// Max iterations reached
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"model": resp.Model,
|
||||
"model": model,
|
||||
"choices": []gin.H{
|
||||
{
|
||||
"message": gin.H{
|
||||
"role": "assistant",
|
||||
"content": resp.Content,
|
||||
"content": "[Tool execution limit reached]",
|
||||
},
|
||||
"finish_reason": resp.FinishReason,
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
"usage": gin.H{
|
||||
"prompt_tokens": resp.InputTokens,
|
||||
"completion_tokens": resp.OutputTokens,
|
||||
"total_tokens": resp.InputTokens + resp.OutputTokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Model Capabilities ──────────────────────
|
||||
|
||||
// escapeJSON escapes a string for safe embedding in a JSON string value.
|
||||
func escapeJSON(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
s = strings.ReplaceAll(s, "\t", `\t`)
|
||||
return s
|
||||
}
|
||||
|
||||
// getModelCapabilities looks up capabilities from model_configs DB,
|
||||
// then overlays with known model defaults and heuristic detection.
|
||||
func (h *CompletionHandler) getModelCapabilities(model, apiConfigID string) providers.ModelCapabilities {
|
||||
|
||||
504
server/handlers/notes.go
Normal file
504
server/handlers/notes.go
Normal file
@@ -0,0 +1,504 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
// ── Request / Response Types ────────────────
|
||||
|
||||
type createNoteRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
Tags []string `json:"tags"`
|
||||
SourceChannelID string `json:"source_channel_id"`
|
||||
}
|
||||
|
||||
type updateNoteRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Content *string `json:"content"`
|
||||
FolderPath *string `json:"folder_path"`
|
||||
Tags []string `json:"tags"`
|
||||
// Mode controls how content is applied: "replace" (default), "append", "prepend"
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type noteResponse struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
Tags []string `json:"tags"`
|
||||
SourceChannelID *string `json:"source_channel_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type noteListItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
Tags []string `json:"tags"`
|
||||
Preview string `json:"preview"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type searchResult struct {
|
||||
noteListItem
|
||||
Rank float64 `json:"rank"`
|
||||
Headline string `json:"headline"`
|
||||
}
|
||||
|
||||
// NoteHandler handles notes CRUD.
|
||||
type NoteHandler struct{}
|
||||
|
||||
// NewNoteHandler creates a new handler.
|
||||
func NewNoteHandler() *NoteHandler {
|
||||
return &NoteHandler{}
|
||||
}
|
||||
|
||||
// ── Create ──────────────────────────────────
|
||||
// POST /api/v1/notes
|
||||
|
||||
func (h *NoteHandler) Create(c *gin.Context) {
|
||||
var req createNoteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
|
||||
folder := normalizeFolderPath(req.FolderPath)
|
||||
tags := req.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
|
||||
var sourceChannelID *string
|
||||
if req.SourceChannelID != "" {
|
||||
sourceChannelID = &req.SourceChannelID
|
||||
}
|
||||
|
||||
var note noteResponse
|
||||
var dbTags pq.StringArray
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO notes (user_id, title, content, folder_path, tags, source_channel_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, title, content, folder_path, tags, source_channel_id,
|
||||
created_at::text, updated_at::text
|
||||
`, userID, req.Title, req.Content, folder, pq.Array(tags), sourceChannelID,
|
||||
).Scan(
|
||||
¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath,
|
||||
&dbTags, ¬e.SourceChannelID,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create note"})
|
||||
return
|
||||
}
|
||||
note.Tags = []string(dbTags)
|
||||
if note.Tags == nil {
|
||||
note.Tags = []string{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, note)
|
||||
}
|
||||
|
||||
// ── Get ─────────────────────────────────────
|
||||
// GET /api/v1/notes/:id
|
||||
|
||||
func (h *NoteHandler) Get(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
noteID := c.Param("id")
|
||||
|
||||
var note noteResponse
|
||||
var dbTags pq.StringArray
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT id, title, content, folder_path, tags, source_channel_id,
|
||||
created_at::text, updated_at::text
|
||||
FROM notes WHERE id = $1 AND user_id = $2
|
||||
`, noteID, userID).Scan(
|
||||
¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath,
|
||||
&dbTags, ¬e.SourceChannelID,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get note"})
|
||||
return
|
||||
}
|
||||
note.Tags = []string(dbTags)
|
||||
if note.Tags == nil {
|
||||
note.Tags = []string{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, note)
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────
|
||||
// PUT /api/v1/notes/:id
|
||||
|
||||
func (h *NoteHandler) Update(c *gin.Context) {
|
||||
var req updateNoteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
noteID := c.Param("id")
|
||||
|
||||
// Verify ownership
|
||||
var exists bool
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM notes WHERE id = $1 AND user_id = $2)`,
|
||||
noteID, userID,
|
||||
).Scan(&exists)
|
||||
if err != nil || !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build dynamic update
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if req.Title != nil {
|
||||
setClauses = append(setClauses, "title = $"+strconv.Itoa(argIdx))
|
||||
args = append(args, *req.Title)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if req.Content != nil {
|
||||
mode := strings.ToLower(req.Mode)
|
||||
switch mode {
|
||||
case "append":
|
||||
setClauses = append(setClauses, "content = content || $"+strconv.Itoa(argIdx))
|
||||
case "prepend":
|
||||
setClauses = append(setClauses, "content = $"+strconv.Itoa(argIdx)+" || content")
|
||||
default: // "replace" or empty
|
||||
setClauses = append(setClauses, "content = $"+strconv.Itoa(argIdx))
|
||||
}
|
||||
args = append(args, *req.Content)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if req.FolderPath != nil {
|
||||
setClauses = append(setClauses, "folder_path = $"+strconv.Itoa(argIdx))
|
||||
args = append(args, normalizeFolderPath(*req.FolderPath))
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if req.Tags != nil {
|
||||
setClauses = append(setClauses, "tags = $"+strconv.Itoa(argIdx))
|
||||
args = append(args, pq.Array(req.Tags))
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
// WHERE clause
|
||||
args = append(args, noteID, userID)
|
||||
query := "UPDATE notes SET " + strings.Join(setClauses, ", ") +
|
||||
" WHERE id = $" + strconv.Itoa(argIdx) +
|
||||
" AND user_id = $" + strconv.Itoa(argIdx+1) +
|
||||
" RETURNING id, title, content, folder_path, tags, source_channel_id, created_at::text, updated_at::text"
|
||||
|
||||
var note noteResponse
|
||||
var dbTags pq.StringArray
|
||||
err = database.DB.QueryRow(query, args...).Scan(
|
||||
¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath,
|
||||
&dbTags, ¬e.SourceChannelID,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update note"})
|
||||
return
|
||||
}
|
||||
note.Tags = []string(dbTags)
|
||||
if note.Tags == nil {
|
||||
note.Tags = []string{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, note)
|
||||
}
|
||||
|
||||
// ── Delete ──────────────────────────────────
|
||||
// DELETE /api/v1/notes/:id
|
||||
|
||||
func (h *NoteHandler) Delete(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
noteID := c.Param("id")
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
`DELETE FROM notes WHERE id = $1 AND user_id = $2`,
|
||||
noteID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete note"})
|
||||
return
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// ── Bulk Delete ────────────────────────────
|
||||
// POST /api/v1/notes/bulk-delete
|
||||
|
||||
func (h *NoteHandler) BulkDelete(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ids array is empty"})
|
||||
return
|
||||
}
|
||||
if len(req.IDs) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "max 100 notes per request"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
`DELETE FROM notes WHERE id = ANY($1) AND user_id = $2`,
|
||||
pq.Array(req.IDs), userID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete notes"})
|
||||
return
|
||||
}
|
||||
count, _ := result.RowsAffected()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": count})
|
||||
}
|
||||
// GET /api/v1/notes?folder=/path&tag=sometag&limit=50&offset=0&sort=created_asc
|
||||
|
||||
func (h *NoteHandler) List(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
folder := c.Query("folder")
|
||||
tag := c.Query("tag")
|
||||
sort := c.DefaultQuery("sort", "updated_desc")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
query := `SELECT id, title, folder_path, tags, LEFT(content, 200),
|
||||
created_at::text, updated_at::text
|
||||
FROM notes WHERE user_id = $1`
|
||||
args := []interface{}{userID}
|
||||
argIdx := 2
|
||||
|
||||
if folder != "" {
|
||||
query += " AND folder_path = $" + strconv.Itoa(argIdx)
|
||||
args = append(args, normalizeFolderPath(folder))
|
||||
argIdx++
|
||||
}
|
||||
if tag != "" {
|
||||
query += " AND $" + strconv.Itoa(argIdx) + " = ANY(tags)"
|
||||
args = append(args, tag)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
// Sort options
|
||||
switch sort {
|
||||
case "created_asc":
|
||||
query += " ORDER BY created_at ASC"
|
||||
case "created_desc":
|
||||
query += " ORDER BY created_at DESC"
|
||||
case "updated_asc":
|
||||
query += " ORDER BY updated_at ASC"
|
||||
case "title_asc":
|
||||
query += " ORDER BY title ASC"
|
||||
case "title_desc":
|
||||
query += " ORDER BY title DESC"
|
||||
default: // "updated_desc"
|
||||
query += " ORDER BY updated_at DESC"
|
||||
}
|
||||
|
||||
query += " LIMIT $" + strconv.Itoa(argIdx) +
|
||||
" OFFSET $" + strconv.Itoa(argIdx+1)
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := database.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
notes := make([]noteListItem, 0)
|
||||
for rows.Next() {
|
||||
var n noteListItem
|
||||
var dbTags pq.StringArray
|
||||
if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath, &dbTags, &n.Preview,
|
||||
&n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
n.Tags = []string(dbTags)
|
||||
if n.Tags == nil {
|
||||
n.Tags = []string{}
|
||||
}
|
||||
notes = append(notes, n)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var total int
|
||||
countQuery := `SELECT COUNT(*) FROM notes WHERE user_id = $1`
|
||||
countArgs := []interface{}{userID}
|
||||
if folder != "" {
|
||||
countQuery += " AND folder_path = $2"
|
||||
countArgs = append(countArgs, normalizeFolderPath(folder))
|
||||
}
|
||||
_ = database.DB.QueryRow(countQuery, countArgs...).Scan(&total)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": notes,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Search ──────────────────────────────────
|
||||
// GET /api/v1/notes/search?q=query&limit=20
|
||||
|
||||
func (h *NoteHandler) Search(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
if q == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
// Use plainto_tsquery for natural language (not websearch_to_tsquery which
|
||||
// requires Postgres 11+ and has stricter syntax).
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT id, title, folder_path, tags, LEFT(content, 200),
|
||||
created_at::text, updated_at::text,
|
||||
ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank,
|
||||
ts_headline('english', content, plainto_tsquery('english', $2),
|
||||
'MaxWords=40, MinWords=20, StartSel=**, StopSel=**') AS headline
|
||||
FROM notes
|
||||
WHERE user_id = $1
|
||||
AND search_vector @@ plainto_tsquery('english', $2)
|
||||
ORDER BY rank DESC
|
||||
LIMIT $3
|
||||
`, userID, q, limit)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
results := make([]searchResult, 0)
|
||||
for rows.Next() {
|
||||
var r searchResult
|
||||
var dbTags pq.StringArray
|
||||
if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Preview,
|
||||
&r.CreatedAt, &r.UpdatedAt, &r.Rank, &r.Headline); err != nil {
|
||||
continue
|
||||
}
|
||||
r.Tags = []string(dbTags)
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": results,
|
||||
"query": q,
|
||||
"total": len(results),
|
||||
})
|
||||
}
|
||||
|
||||
// ── List Folders ────────────────────────────
|
||||
// GET /api/v1/notes/folders
|
||||
|
||||
func (h *NoteHandler) ListFolders(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT DISTINCT folder_path, COUNT(*) AS count
|
||||
FROM notes WHERE user_id = $1
|
||||
GROUP BY folder_path
|
||||
ORDER BY folder_path
|
||||
`, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type folderInfo struct {
|
||||
Path string `json:"path"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
folders := make([]folderInfo, 0)
|
||||
for rows.Next() {
|
||||
var f folderInfo
|
||||
if err := rows.Scan(&f.Path, &f.Count); err != nil {
|
||||
continue
|
||||
}
|
||||
folders = append(folders, f)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"folders": folders})
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
// normalizeFolderPath ensures consistent folder path format.
|
||||
func normalizeFolderPath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
if !strings.HasSuffix(p, "/") {
|
||||
p = p + "/"
|
||||
}
|
||||
// Collapse double slashes
|
||||
for strings.Contains(p, "//") {
|
||||
p = strings.ReplaceAll(p, "//", "/")
|
||||
}
|
||||
return p
|
||||
}
|
||||
329
server/handlers/notes_test.go
Normal file
329
server/handlers/notes_test.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
// ── Notes: Validation (no DB needed) ────────
|
||||
|
||||
func TestCreateNoteMissingTitle(t *testing.T) {
|
||||
h := NewNoteHandler()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", "test-user")
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/notes",
|
||||
strings.NewReader(`{"content":"body only"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for missing title, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateNoteMissingContent(t *testing.T) {
|
||||
h := NewNoteHandler()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", "test-user")
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/notes",
|
||||
strings.NewReader(`{"title":"title only"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for missing content, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notes: Full CRUD Integration ────────────
|
||||
|
||||
func TestNoteCRUDIntegration(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
userID := database.SeedTestUser(t, "noteuser", "note@test.com")
|
||||
|
||||
h := NewNoteHandler()
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
|
||||
r.POST("/notes", h.Create)
|
||||
r.GET("/notes", h.List)
|
||||
r.GET("/notes/search", h.Search)
|
||||
r.GET("/notes/folders", h.ListFolders)
|
||||
r.GET("/notes/:id", h.Get)
|
||||
r.PUT("/notes/:id", h.Update)
|
||||
r.DELETE("/notes/:id", h.Delete)
|
||||
|
||||
// ── Create ──
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/notes",
|
||||
strings.NewReader(`{
|
||||
"title": "Meeting Notes",
|
||||
"content": "Discussed project timeline and deliverables",
|
||||
"folder_path": "/work/meetings",
|
||||
"tags": ["project", "planning"]
|
||||
}`))
|
||||
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)
|
||||
noteID, ok := created["id"].(string)
|
||||
if !ok || noteID == "" {
|
||||
t.Fatal("Create: missing or empty id in response")
|
||||
}
|
||||
if created["title"] != "Meeting Notes" {
|
||||
t.Errorf("Create: title mismatch: %v", created["title"])
|
||||
}
|
||||
if created["folder_path"] != "/work/meetings/" {
|
||||
t.Errorf("Create: folder_path should be normalized, got %v", created["folder_path"])
|
||||
}
|
||||
|
||||
// ── Create second note for search/list tests ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/notes",
|
||||
strings.NewReader(`{
|
||||
"title": "Recipe Ideas",
|
||||
"content": "Try making sourdough bread with rosemary",
|
||||
"folder_path": "/personal",
|
||||
"tags": ["food", "recipes"]
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("Create 2nd note: expected 201, got %d", w.Code)
|
||||
}
|
||||
|
||||
// ── Get ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Get: expected 200, got %d", w.Code)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if got["content"] != "Discussed project timeline and deliverables" {
|
||||
t.Errorf("Get: wrong content: %v", got["content"])
|
||||
}
|
||||
|
||||
// ── List (all) ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("List: expected 200, got %d", w.Code)
|
||||
}
|
||||
var listResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &listResp)
|
||||
total := listResp["total"].(float64)
|
||||
if total != 2 {
|
||||
t.Errorf("List: expected total=2, got %.0f", total)
|
||||
}
|
||||
|
||||
// ── List (filtered by folder) ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes?folder=/work/meetings", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("List by folder: expected 200, got %d", w.Code)
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &listResp)
|
||||
total = listResp["total"].(float64)
|
||||
if total != 1 {
|
||||
t.Errorf("List by folder: expected total=1, got %.0f", total)
|
||||
}
|
||||
|
||||
// ── List (filtered by tag) ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes?tag=food", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("List by tag: expected 200, got %d", w.Code)
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &listResp)
|
||||
total = listResp["total"].(float64)
|
||||
if total != 1 {
|
||||
t.Errorf("List by tag: expected total=1, got %.0f", total)
|
||||
}
|
||||
|
||||
// ── Search ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes/search?q=sourdough", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Search: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var searchResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &searchResp)
|
||||
count := searchResp["count"].(float64)
|
||||
if count != 1 {
|
||||
t.Errorf("Search 'sourdough': expected count=1, got %.0f", count)
|
||||
}
|
||||
|
||||
// ── Folders ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes/folders", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Folders: expected 200, got %d", w.Code)
|
||||
}
|
||||
var folders []interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &folders)
|
||||
if len(folders) != 2 {
|
||||
t.Errorf("Folders: expected 2 folders, got %d", len(folders))
|
||||
}
|
||||
|
||||
// ── Update (replace) ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PUT", "/notes/"+noteID,
|
||||
strings.NewReader(`{"title":"Updated Meeting Notes","content":"New content","mode":"replace"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Update: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify update
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if got["title"] != "Updated Meeting Notes" {
|
||||
t.Errorf("Update title: got %v", got["title"])
|
||||
}
|
||||
if got["content"] != "New content" {
|
||||
t.Errorf("Update content: got %v", got["content"])
|
||||
}
|
||||
|
||||
// ── Update (append) ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PUT", "/notes/"+noteID,
|
||||
strings.NewReader(`{"content":"\nAppended line","mode":"append"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Append: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
json.Unmarshal(w.Body.Bytes(), &got)
|
||||
content := got["content"].(string)
|
||||
if !strings.Contains(content, "New content") || !strings.Contains(content, "Appended line") {
|
||||
t.Errorf("Append: expected both parts in content, got %q", content)
|
||||
}
|
||||
|
||||
// ── Delete ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("DELETE", "/notes/"+noteID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Delete: expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify gone
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Get after delete: expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notes: Search empty query ───────────────
|
||||
|
||||
func TestNoteSearchEmptyQuery(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
|
||||
h := NewNoteHandler()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", "test-user")
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/notes/search", nil)
|
||||
|
||||
h.Search(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Search with no query: expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notes: Cross-user isolation ─────────────
|
||||
|
||||
func TestNoteIsolationBetweenUsers(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
userA := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||
userB := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||
|
||||
h := NewNoteHandler()
|
||||
|
||||
// Alice creates a note
|
||||
rA := gin.New()
|
||||
rA.Use(func(c *gin.Context) { c.Set("user_id", userA); c.Next() })
|
||||
rA.POST("/notes", h.Create)
|
||||
rA.GET("/notes", h.List)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/notes",
|
||||
strings.NewReader(`{"title":"Alice Secret","content":"private stuff"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rA.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("Alice create: expected 201, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Bob lists — should see zero
|
||||
rB := gin.New()
|
||||
rB.Use(func(c *gin.Context) { c.Set("user_id", userB); c.Next() })
|
||||
rB.GET("/notes", h.List)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes", nil)
|
||||
rB.ServeHTTP(w, req)
|
||||
|
||||
var listResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &listResp)
|
||||
total := listResp["total"].(float64)
|
||||
if total != 0 {
|
||||
t.Errorf("Bob should see 0 notes, got %.0f", total)
|
||||
}
|
||||
|
||||
// Alice lists — should see one
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes", nil)
|
||||
rA.ServeHTTP(w, req)
|
||||
|
||||
json.Unmarshal(w.Body.Bytes(), &listResp)
|
||||
total = listResp["total"].(float64)
|
||||
if total != 1 {
|
||||
t.Errorf("Alice should see 1 note, got %.0f", total)
|
||||
}
|
||||
}
|
||||
24
server/handlers/testmain_test.go
Normal file
24
server/handlers/testmain_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
)
|
||||
|
||||
// TestMain sets up the test DB (if available) and runs all tests.
|
||||
// DB-dependent tests call database.RequireTestDB(t) to skip gracefully
|
||||
// when no DB is configured.
|
||||
func TestMain(m *testing.M) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
providers.Init()
|
||||
|
||||
teardown := database.SetupTestDB()
|
||||
code := m.Run()
|
||||
teardown()
|
||||
os.Exit(code)
|
||||
}
|
||||
Reference in New Issue
Block a user