Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
375 lines
11 KiB
Go
375 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ---------- AgentHandler: Assign ----------
|
|
|
|
func TestAgentAssign_Success(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
broadcaster := newTestBroadcaster()
|
|
handler := NewAgentHandler(broadcaster)
|
|
|
|
// Workspace status check
|
|
mock.ExpectQuery("SELECT status FROM workspaces WHERE id").
|
|
WithArgs("ws-1").
|
|
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("online"))
|
|
|
|
// Active agent count check
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM agents WHERE workspace_id").
|
|
WithArgs("ws-1").
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
|
|
|
// Insert agent
|
|
mock.ExpectQuery("INSERT INTO agents").
|
|
WithArgs("ws-1", "claude-3-5-sonnet").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("agent-abc"))
|
|
|
|
// RecordAndBroadcast
|
|
mock.ExpectExec("INSERT INTO structure_events").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
|
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/agent",
|
|
bytes.NewBufferString(`{"model":"claude-3-5-sonnet"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Assign(c)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
if resp["agent_id"] != "agent-abc" {
|
|
t.Errorf("expected agent_id agent-abc, got %v", resp["agent_id"])
|
|
}
|
|
}
|
|
|
|
func TestAgentAssign_WorkspaceNotFound(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewAgentHandler(newTestBroadcaster())
|
|
|
|
mock.ExpectQuery("SELECT status FROM workspaces WHERE id").
|
|
WithArgs("ws-missing").
|
|
WillReturnError(sql.ErrNoRows)
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-missing"}}
|
|
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"model":"gpt-4"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Assign(c)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAgentAssign_AlreadyHasAgent(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewAgentHandler(newTestBroadcaster())
|
|
|
|
mock.ExpectQuery("SELECT status FROM workspaces WHERE id").
|
|
WithArgs("ws-1").
|
|
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("online"))
|
|
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM agents WHERE workspace_id").
|
|
WithArgs("ws-1").
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
|
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"model":"gpt-4"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Assign(c)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected 409, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAgentAssign_MissingModel(t *testing.T) {
|
|
setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewAgentHandler(newTestBroadcaster())
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
|
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Assign(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ---------- AgentHandler: Replace ----------
|
|
|
|
func TestAgentReplace_Success(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
broadcaster := newTestBroadcaster()
|
|
handler := NewAgentHandler(broadcaster)
|
|
|
|
// Deactivate current agent (only workspace_id is passed — model comes from RETURNING)
|
|
mock.ExpectQuery("UPDATE agents SET status = 'replaced'").
|
|
WithArgs("ws-1").
|
|
WillReturnRows(sqlmock.NewRows([]string{"model"}).AddRow("old-model"))
|
|
|
|
// Insert new agent
|
|
mock.ExpectQuery("INSERT INTO agents").
|
|
WithArgs("ws-1", "claude-3-5-haiku").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("agent-new"))
|
|
|
|
// RecordAndBroadcast
|
|
mock.ExpectExec("INSERT INTO structure_events").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
|
c.Request = httptest.NewRequest("PATCH", "/", bytes.NewBufferString(`{"model":"claude-3-5-haiku"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Replace(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
if resp["old_model"] != "old-model" {
|
|
t.Errorf("expected old_model 'old-model', got %v", resp["old_model"])
|
|
}
|
|
}
|
|
|
|
func TestAgentReplace_NoActiveAgent(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewAgentHandler(newTestBroadcaster())
|
|
|
|
mock.ExpectQuery("UPDATE agents SET status = 'replaced'").
|
|
WithArgs("ws-1").
|
|
WillReturnError(sql.ErrNoRows)
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
|
c.Request = httptest.NewRequest("PATCH", "/", bytes.NewBufferString(`{"model":"gpt-4"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Replace(c)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAgentReplace_MissingModel(t *testing.T) {
|
|
setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewAgentHandler(newTestBroadcaster())
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
|
c.Request = httptest.NewRequest("PATCH", "/", bytes.NewBufferString(`{}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Replace(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ---------- AgentHandler: Remove ----------
|
|
|
|
func TestAgentRemove_Success(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
broadcaster := newTestBroadcaster()
|
|
handler := NewAgentHandler(broadcaster)
|
|
|
|
mock.ExpectQuery("UPDATE agents SET status = 'removed'").
|
|
WithArgs("ws-1").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "model"}).AddRow("agent-del", "gpt-4"))
|
|
|
|
mock.ExpectExec("INSERT INTO structure_events").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
|
c.Request = httptest.NewRequest("DELETE", "/", nil)
|
|
|
|
handler.Remove(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
if resp["status"] != "removed" {
|
|
t.Errorf("expected status 'removed', got %v", resp["status"])
|
|
}
|
|
}
|
|
|
|
func TestAgentRemove_NoActiveAgent(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewAgentHandler(newTestBroadcaster())
|
|
|
|
mock.ExpectQuery("UPDATE agents SET status = 'removed'").
|
|
WithArgs("ws-1").
|
|
WillReturnError(sql.ErrNoRows)
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
|
c.Request = httptest.NewRequest("DELETE", "/", nil)
|
|
|
|
handler.Remove(c)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ---------- AgentHandler: Move ----------
|
|
|
|
func TestAgentMove_Success(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
broadcaster := newTestBroadcaster()
|
|
handler := NewAgentHandler(broadcaster)
|
|
|
|
// Target workspace lookup
|
|
mock.ExpectQuery("SELECT status FROM workspaces WHERE id").
|
|
WithArgs("ws-target").
|
|
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("online"))
|
|
|
|
// Target agent count
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM agents WHERE workspace_id").
|
|
WithArgs("ws-target").
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
|
|
|
// Move agent
|
|
mock.ExpectQuery("UPDATE agents SET workspace_id").
|
|
WithArgs("ws-source", "ws-target").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "model"}).AddRow("agent-mov", "gpt-4"))
|
|
|
|
// Two broadcast calls
|
|
mock.ExpectExec("INSERT INTO structure_events").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec("INSERT INTO structure_events").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-source"}}
|
|
c.Request = httptest.NewRequest("POST", "/",
|
|
bytes.NewBufferString(`{"target_workspace_id":"ws-target"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Move(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
if resp["from_workspace"] != "ws-source" || resp["to_workspace"] != "ws-target" {
|
|
t.Errorf("unexpected move response: %v", resp)
|
|
}
|
|
}
|
|
|
|
func TestAgentMove_TargetNotFound(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewAgentHandler(newTestBroadcaster())
|
|
|
|
mock.ExpectQuery("SELECT status FROM workspaces WHERE id").
|
|
WithArgs("ws-missing").
|
|
WillReturnError(sql.ErrNoRows)
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-source"}}
|
|
c.Request = httptest.NewRequest("POST", "/",
|
|
bytes.NewBufferString(`{"target_workspace_id":"ws-missing"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Move(c)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAgentMove_TargetAlreadyHasAgent(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewAgentHandler(newTestBroadcaster())
|
|
|
|
mock.ExpectQuery("SELECT status FROM workspaces WHERE id").
|
|
WithArgs("ws-target").
|
|
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("online"))
|
|
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM agents WHERE workspace_id").
|
|
WithArgs("ws-target").
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-source"}}
|
|
c.Request = httptest.NewRequest("POST", "/",
|
|
bytes.NewBufferString(`{"target_workspace_id":"ws-target"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Move(c)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected 409, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAgentMove_MissingTargetID(t *testing.T) {
|
|
setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewAgentHandler(newTestBroadcaster())
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-source"}}
|
|
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Move(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|