Every workspace can have children via the regular CreateWorkspace flow with parent_id set, so a separate handler that bulk-creates from config.yaml's sub_workspaces (and was non-idempotent — calling it twice duplicated the team) earned its way out. "Team" is just the state of having children; expanding/collapsing is purely a canvas-side visual action that toggles the `collapsed` column via PATCH. The non-idempotency directly caused tenant-hongming's vCPU starvation: 72 distinct child workspaces accumulated in 4 days, ~14 leaked EC2s (50 of 64 vCPU consumed by stale teams), every Canvas tabs E2E retry flaking on RunInstances VcpuLimitExceeded. What stays: - TeamHandler.Collapse — still useful; stops + removes children via StopWorkspaceAuto. Reachable from the canvas Collapse Team button. (Note: that button currently calls PATCH /workspaces/:id, not the Collapse endpoint — that's a separate reachability question for later.) - findTemplateDirByName helper — kept in team.go pending a relocate decision; no in-package consumers after Expand. - The four other paths that create child workspaces continue to work unchanged: regular POST /workspaces with parent_id, OrgHandler.Import (recursive tree), Bundle import, scripts. What goes: - POST /workspaces/:id/expand route (router.go) - TeamHandler.Expand method (team.go: ~130 lines) - 4 TestTeamExpand_* sqlmock tests (team_test.go) - TestTeamExpand_UsesAutoNotDirectDockerPath AST gate (workspace_provision_auto_test.go) — pinned a code path that no longer exists; the generic TestNoCallSiteCallsDirectProvisionerExceptAuto gate still covers the architectural intent for any future caller. Follow-up PRs: - canvas/ContextMenu.tsx: drop the "Expand to Team" right-click button + handleExpand callback; users create children via the regular + New Workspace dialog with the parent picker (already supported) - OrgHandler.Import idempotency (skip-if-exists OR replace_if_exists) — same bug class as the deleted Expand, but on the bulk-tree path - One-off cleanup script for tenant-hongming's 72 stale workspaces Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
4.0 KiB
Go
131 lines
4.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ---------- TeamHandler: Collapse ----------
|
|
|
|
func TestTeamCollapse_NoChildren(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
broadcaster := newTestBroadcaster()
|
|
handler := NewTeamHandler(broadcaster, NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()), "http://localhost:8080", "/tmp/configs")
|
|
|
|
// No children
|
|
mock.ExpectQuery("SELECT id, name FROM workspaces WHERE parent_id").
|
|
WithArgs("ws-parent").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}))
|
|
|
|
// WORKSPACE_COLLAPSED broadcast
|
|
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-parent"}}
|
|
c.Request = httptest.NewRequest("POST", "/", nil)
|
|
|
|
handler.Collapse(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"] != "collapsed" {
|
|
t.Errorf("expected status 'collapsed', got %v", resp["status"])
|
|
}
|
|
}
|
|
|
|
func TestTeamCollapse_WithChildren(t *testing.T) {
|
|
mock := setupTestDB(t)
|
|
setupTestRedis(t)
|
|
broadcaster := newTestBroadcaster()
|
|
handler := NewTeamHandler(broadcaster, NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir()), "http://localhost:8080", "/tmp/configs")
|
|
|
|
// Two children
|
|
mock.ExpectQuery("SELECT id, name FROM workspaces WHERE parent_id").
|
|
WithArgs("ws-parent").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).
|
|
AddRow("child-1", "Worker A").
|
|
AddRow("child-2", "Worker B"))
|
|
|
|
// UPDATE + DELETE + broadcast for child-1
|
|
mock.ExpectExec("UPDATE workspaces SET status =").
|
|
WithArgs("child-1").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec("DELETE FROM canvas_layouts").
|
|
WithArgs("child-1").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec("INSERT INTO structure_events").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
// UPDATE + DELETE + broadcast for child-2
|
|
mock.ExpectExec("UPDATE workspaces SET status =").
|
|
WithArgs("child-2").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec("DELETE FROM canvas_layouts").
|
|
WithArgs("child-2").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
mock.ExpectExec("INSERT INTO structure_events").
|
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
|
|
// WORKSPACE_COLLAPSED broadcast for 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: "ws-parent"}}
|
|
c.Request = httptest.NewRequest("POST", "/", nil)
|
|
|
|
handler.Collapse(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)
|
|
removed, ok := resp["removed"].([]interface{})
|
|
if !ok || len(removed) != 2 {
|
|
t.Errorf("expected 2 removed children, got %v", resp["removed"])
|
|
}
|
|
}
|
|
// ---------- findTemplateDirByName helper ----------
|
|
|
|
func TestFindTemplateDirByName_DirectMatch(t *testing.T) {
|
|
dir := t.TempDir()
|
|
subDir := filepath.Join(dir, "mybot")
|
|
os.MkdirAll(subDir, 0755)
|
|
os.WriteFile(filepath.Join(subDir, "config.yaml"), []byte("name: MyBot"), 0644)
|
|
|
|
result := findTemplateDirByName(dir, "mybot")
|
|
if result != subDir {
|
|
t.Errorf("expected %s, got %s", subDir, result)
|
|
}
|
|
}
|
|
|
|
func TestFindTemplateDirByName_NotFound(t *testing.T) {
|
|
dir := t.TempDir()
|
|
result := findTemplateDirByName(dir, "nonexistent")
|
|
if result != "" {
|
|
t.Errorf("expected empty string, got %s", result)
|
|
}
|
|
}
|
|
|
|
func TestFindTemplateDirByName_InvalidConfigsDir(t *testing.T) {
|
|
result := findTemplateDirByName("/nonexistent/path", "anything")
|
|
if result != "" {
|
|
t.Errorf("expected empty string for invalid dir, got %s", result)
|
|
}
|
|
}
|