Forked clean from public hackathon repo (Starfire-AgentTeam, BSL 1.1) with full rebrand to Molecule AI under github.com/Molecule-AI/molecule-monorepo. Brand: Starfire → Molecule AI. Slug: starfire / agent-molecule → molecule. Env vars: STARFIRE_* → MOLECULE_*. Go module: github.com/agent-molecule/platform → github.com/Molecule-AI/molecule-monorepo/platform. Python packages: starfire_plugin → molecule_plugin, starfire_agent → molecule_agent. DB: agentmolecule → molecule. History truncated; see public repo for prior commits and contributor attribution. Verified green: go test -race ./... (platform), pytest (workspace-template 1129 + sdk 132), vitest (canvas 352), build (mcp). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
331 lines
10 KiB
Go
331 lines
10 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ---------- ApprovalsHandler: Create ----------
|
|
|
|
func TestApprovals_Create_Success(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
broadcaster := newTestBroadcaster()
|
|
handler := NewApprovalsHandler(broadcaster)
|
|
|
|
// Insert approval
|
|
mock.ExpectQuery("INSERT INTO approval_requests").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("appr-1"))
|
|
|
|
// RecordAndBroadcast for APPROVAL_REQUESTED
|
|
mock.ExpectExec("INSERT INTO structure_events").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
// Parent lookup (no parent — nil)
|
|
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id").
|
|
WithArgs("ws-1").
|
|
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
|
body := `{"action":"run_script","reason":"need to execute","task_id":"task-99"}`
|
|
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Create(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["approval_id"] != "appr-1" {
|
|
t.Errorf("expected approval_id appr-1, got %v", resp["approval_id"])
|
|
}
|
|
if resp["status"] != "pending" {
|
|
t.Errorf("expected status 'pending', got %v", resp["status"])
|
|
}
|
|
}
|
|
|
|
func TestApprovals_Create_WithParentEscalation(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
broadcaster := newTestBroadcaster()
|
|
handler := NewApprovalsHandler(broadcaster)
|
|
|
|
// Insert approval
|
|
mock.ExpectQuery("INSERT INTO approval_requests").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("appr-2"))
|
|
|
|
// APPROVAL_REQUESTED broadcast
|
|
mock.ExpectExec("INSERT INTO structure_events").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
// Parent lookup — returns parent-ws
|
|
parentID := "parent-ws"
|
|
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id").
|
|
WithArgs("child-ws").
|
|
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(&parentID))
|
|
|
|
// APPROVAL_ESCALATED broadcast to parent
|
|
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: "child-ws"}}
|
|
body := `{"action":"delete_file","reason":"cleanup"}`
|
|
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Create(c)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestApprovals_Create_MissingAction(t *testing.T) {
|
|
setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewApprovalsHandler(newTestBroadcaster())
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
|
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"reason":"no action"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Create(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ---------- ApprovalsHandler: ListAll ----------
|
|
|
|
func TestApprovals_ListAll_Empty(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewApprovalsHandler(newTestBroadcaster())
|
|
|
|
// Auto-expire stale approvals
|
|
mock.ExpectExec("UPDATE approval_requests SET status = 'denied'").
|
|
WillReturnResult(sqlmock.NewResult(0, 0))
|
|
|
|
// Query all pending
|
|
mock.ExpectQuery("SELECT a.id, a.workspace_id, w.name, a.action, a.reason, a.status, a.created_at FROM approval_requests a").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id", "name", "action", "reason", "status", "created_at"}))
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest("GET", "/approvals/pending", nil)
|
|
|
|
handler.ListAll(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
var result []interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &result)
|
|
if len(result) != 0 {
|
|
t.Errorf("expected empty list, got %v", result)
|
|
}
|
|
}
|
|
|
|
func TestApprovals_ListAll_WithResults(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewApprovalsHandler(newTestBroadcaster())
|
|
|
|
mock.ExpectExec("UPDATE approval_requests SET status = 'denied'").
|
|
WillReturnResult(sqlmock.NewResult(0, 0))
|
|
|
|
reason := "needs review"
|
|
rows := sqlmock.NewRows([]string{"id", "workspace_id", "name", "action", "reason", "status", "created_at"}).
|
|
AddRow("appr-1", "ws-1", "Test WS", "run_script", &reason, "pending", "2024-01-01T00:00:00Z").
|
|
AddRow("appr-2", "ws-2", "Test WS 2", "delete_file", nil, "pending", "2024-01-02T00:00:00Z")
|
|
|
|
mock.ExpectQuery("SELECT a.id, a.workspace_id, w.name").
|
|
WillReturnRows(rows)
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest("GET", "/approvals/pending", nil)
|
|
|
|
handler.ListAll(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var result []interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &result)
|
|
if len(result) != 2 {
|
|
t.Errorf("expected 2 approvals, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
// ---------- ApprovalsHandler: List ----------
|
|
|
|
func TestApprovals_List_ForWorkspace(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewApprovalsHandler(newTestBroadcaster())
|
|
|
|
decidedBy := "admin"
|
|
decidedAt := "2024-01-01T00:00:00Z"
|
|
taskID := "task-1"
|
|
reason := "safety check"
|
|
rows := sqlmock.NewRows([]string{"id", "task_id", "action", "reason", "status", "decided_by", "decided_at", "created_at"}).
|
|
AddRow("appr-5", &taskID, "run", &reason, "approved", &decidedBy, &decidedAt, "2024-01-01T00:00:00Z")
|
|
|
|
mock.ExpectQuery("SELECT id, task_id, action, reason, status, decided_by, decided_at, created_at FROM approval_requests WHERE workspace_id").
|
|
WithArgs("ws-1").
|
|
WillReturnRows(rows)
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
|
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/approvals", nil)
|
|
|
|
handler.List(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var result []interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &result)
|
|
if len(result) != 1 {
|
|
t.Errorf("expected 1 approval, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
// ---------- ApprovalsHandler: Decide ----------
|
|
|
|
func TestApprovals_Decide_Approved(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
broadcaster := newTestBroadcaster()
|
|
handler := NewApprovalsHandler(broadcaster)
|
|
|
|
mock.ExpectExec("UPDATE approval_requests SET status").
|
|
WithArgs("approved", "human", "appr-1", "ws-1").
|
|
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-1"}, {Key: "approvalId", Value: "appr-1"}}
|
|
c.Request = httptest.NewRequest("POST", "/",
|
|
bytes.NewBufferString(`{"decision":"approved"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Decide(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"] != "approved" {
|
|
t.Errorf("expected status 'approved', got %v", resp["status"])
|
|
}
|
|
}
|
|
|
|
func TestApprovals_Decide_Denied(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
broadcaster := newTestBroadcaster()
|
|
handler := NewApprovalsHandler(broadcaster)
|
|
|
|
mock.ExpectExec("UPDATE approval_requests SET status").
|
|
WithArgs("denied", "supervisor", "appr-2", "ws-1").
|
|
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-1"}, {Key: "approvalId", Value: "appr-2"}}
|
|
c.Request = httptest.NewRequest("POST", "/",
|
|
bytes.NewBufferString(`{"decision":"denied","decided_by":"supervisor"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Decide(c)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestApprovals_Decide_NotFound(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewApprovalsHandler(newTestBroadcaster())
|
|
|
|
mock.ExpectExec("UPDATE approval_requests SET status").
|
|
WithArgs("approved", "human", "appr-none", "ws-1").
|
|
WillReturnResult(sqlmock.NewResult(0, 0)) // 0 rows affected
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "approvalId", Value: "appr-none"}}
|
|
c.Request = httptest.NewRequest("POST", "/",
|
|
bytes.NewBufferString(`{"decision":"approved"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Decide(c)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestApprovals_Decide_InvalidDecision(t *testing.T) {
|
|
setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewApprovalsHandler(newTestBroadcaster())
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "approvalId", Value: "appr-1"}}
|
|
c.Request = httptest.NewRequest("POST", "/",
|
|
bytes.NewBufferString(`{"decision":"maybe"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Decide(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestApprovals_Decide_MissingDecision(t *testing.T) {
|
|
setupTestDB(t)
|
|
setupTestRedis(t)
|
|
handler := NewApprovalsHandler(newTestBroadcaster())
|
|
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "approvalId", Value: "appr-1"}}
|
|
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
handler.Decide(c)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|