feat(server): implement full REST API with JWT auth and real-time WebSocket
- Add HTTP handlers for issues, comments, agents, workspaces, inbox, members, and activity - Implement JWT authentication middleware with Bearer token validation - Add sqlc queries for all entities (CRUD operations) - Extract router into reusable NewRouter() for testability - Expand SDK with full API client methods (CRUD for all resources) - Add updateWorkspace to SDK, add Member type to shared types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d75746021f
commit
1e61c1974c
35 changed files with 3478 additions and 104 deletions
310
server/internal/handler/handler_test.go
Normal file
310
server/internal/handler/handler_test.go
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
)
|
||||
|
||||
var testHandler *Handler
|
||||
var testUserID string
|
||||
var testWorkspaceID string
|
||||
var testToken string
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(context.Background(), dbURL)
|
||||
if err != nil {
|
||||
fmt.Printf("Skipping tests: could not connect to database: %v\n", err)
|
||||
os.Exit(0)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
queries := db.New(pool)
|
||||
hub := realtime.NewHub()
|
||||
go hub.Run()
|
||||
testHandler = New(queries, hub)
|
||||
|
||||
// Get seed user and workspace IDs
|
||||
row := pool.QueryRow(context.Background(), `SELECT id FROM "user" WHERE email = 'jiayuan@multica.ai'`)
|
||||
row.Scan(&testUserID)
|
||||
|
||||
row = pool.QueryRow(context.Background(), `SELECT id FROM workspace WHERE slug = 'multica'`)
|
||||
row.Scan(&testWorkspaceID)
|
||||
|
||||
if testUserID == "" || testWorkspaceID == "" {
|
||||
fmt.Println("Skipping tests: seed data not found. Run 'go run ./cmd/seed/' first.")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Generate a test token
|
||||
import_jwt(testUserID)
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func import_jwt(userID string) {
|
||||
// Simple token generation for tests using the login handler
|
||||
// We'll just set the headers directly instead
|
||||
testToken = userID // We'll use X-User-ID header directly
|
||||
}
|
||||
|
||||
func newRequest(method, path string, body any) *http.Request {
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-User-ID", testUserID)
|
||||
req.Header.Set("X-Workspace-ID", testWorkspaceID)
|
||||
return req
|
||||
}
|
||||
|
||||
func withURLParam(req *http.Request, key, value string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add(key, value)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
func TestIssueCRUD(t *testing.T) {
|
||||
// Create
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Test issue from Go test",
|
||||
"status": "todo",
|
||||
"priority": "medium",
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var created IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&created)
|
||||
if created.Title != "Test issue from Go test" {
|
||||
t.Fatalf("CreateIssue: expected title 'Test issue from Go test', got '%s'", created.Title)
|
||||
}
|
||||
if created.Status != "todo" {
|
||||
t.Fatalf("CreateIssue: expected status 'todo', got '%s'", created.Status)
|
||||
}
|
||||
issueID := created.ID
|
||||
|
||||
// Get
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/issues/"+issueID, nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.GetIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var fetched IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&fetched)
|
||||
if fetched.ID != issueID {
|
||||
t.Fatalf("GetIssue: expected id '%s', got '%s'", issueID, fetched.ID)
|
||||
}
|
||||
|
||||
// Update - partial (only status)
|
||||
w = httptest.NewRecorder()
|
||||
status := "in_progress"
|
||||
req = newRequest("PUT", "/api/issues/"+issueID, map[string]any{
|
||||
"status": status,
|
||||
})
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.UpdateIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var updated IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&updated)
|
||||
if updated.Status != "in_progress" {
|
||||
t.Fatalf("UpdateIssue: expected status 'in_progress', got '%s'", updated.Status)
|
||||
}
|
||||
if updated.Title != "Test issue from Go test" {
|
||||
t.Fatalf("UpdateIssue: title should be preserved, got '%s'", updated.Title)
|
||||
}
|
||||
if updated.Priority != "medium" {
|
||||
t.Fatalf("UpdateIssue: priority should be preserved, got '%s'", updated.Priority)
|
||||
}
|
||||
|
||||
// List
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/issues?workspace_id="+testWorkspaceID, nil)
|
||||
testHandler.ListIssues(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListIssues: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var listResp map[string]any
|
||||
json.NewDecoder(w.Body).Decode(&listResp)
|
||||
issues := listResp["issues"].([]any)
|
||||
if len(issues) == 0 {
|
||||
t.Fatal("ListIssues: expected at least 1 issue")
|
||||
}
|
||||
|
||||
// Delete
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("DELETE", "/api/issues/"+issueID, nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.DeleteIssue(w, req)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("DeleteIssue: expected 204, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/issues/"+issueID, nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.GetIssue(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("GetIssue after delete: expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentCRUD(t *testing.T) {
|
||||
// Create an issue first
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Comment test issue",
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
var issue IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&issue)
|
||||
issueID := issue.ID
|
||||
|
||||
// Create comment
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "Test comment from Go test",
|
||||
})
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.CreateComment(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// List comments
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/issues/"+issueID+"/comments", nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.ListComments(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListComments: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var comments []CommentResponse
|
||||
json.NewDecoder(w.Body).Decode(&comments)
|
||||
if len(comments) != 1 {
|
||||
t.Fatalf("ListComments: expected 1 comment, got %d", len(comments))
|
||||
}
|
||||
if comments[0].Content != "Test comment from Go test" {
|
||||
t.Fatalf("ListComments: expected content 'Test comment from Go test', got '%s'", comments[0].Content)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("DELETE", "/api/issues/"+issueID, nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.DeleteIssue(w, req)
|
||||
}
|
||||
|
||||
func TestAgentCRUD(t *testing.T) {
|
||||
// List agents
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("GET", "/api/agents?workspace_id="+testWorkspaceID, nil)
|
||||
testHandler.ListAgents(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListAgents: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var agents []AgentResponse
|
||||
json.NewDecoder(w.Body).Decode(&agents)
|
||||
if len(agents) == 0 {
|
||||
t.Fatal("ListAgents: expected at least 1 agent")
|
||||
}
|
||||
|
||||
// Update agent status
|
||||
agentID := agents[0].ID
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
||||
"status": "idle",
|
||||
})
|
||||
req = withURLParam(req, "id", agentID)
|
||||
testHandler.UpdateAgent(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var updated AgentResponse
|
||||
json.NewDecoder(w.Body).Decode(&updated)
|
||||
if updated.Status != "idle" {
|
||||
t.Fatalf("UpdateAgent: expected status 'idle', got '%s'", updated.Status)
|
||||
}
|
||||
if updated.Name != agents[0].Name {
|
||||
t.Fatalf("UpdateAgent: name should be preserved, got '%s'", updated.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceCRUD(t *testing.T) {
|
||||
// List workspaces
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("GET", "/api/workspaces", nil)
|
||||
testHandler.ListWorkspaces(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListWorkspaces: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var workspaces []WorkspaceResponse
|
||||
json.NewDecoder(w.Body).Decode(&workspaces)
|
||||
if len(workspaces) == 0 {
|
||||
t.Fatal("ListWorkspaces: expected at least 1 workspace")
|
||||
}
|
||||
|
||||
// Get workspace
|
||||
wsID := workspaces[0].ID
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/workspaces/"+wsID, nil)
|
||||
req = withURLParam(req, "id", wsID)
|
||||
testHandler.GetWorkspace(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetWorkspace: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
body := map[string]string{"email": "test-handler@multica.ai", "name": "Test User"}
|
||||
var buf bytes.Buffer
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
req := httptest.NewRequest("POST", "/auth/login", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testHandler.Login(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Login: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp LoginResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Token == "" {
|
||||
t.Fatal("Login: expected non-empty token")
|
||||
}
|
||||
if resp.User.Email != "test-handler@multica.ai" {
|
||||
t.Fatalf("Login: expected email 'test-handler@multica.ai', got '%s'", resp.User.Email)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue