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, pool, 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) } }