From bb8ccc078417fcbbdc5da7f7a00da3362f85354e Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Sat, 16 May 2026 15:17:59 +0000 Subject: [PATCH] Add TestPatchAbilities coverage for workspace_abilities.go Covers all branches of the PatchAbilities handler (was 0% coverage): - Validation: invalid UUID (400), invalid JSON (400), empty body {} (400), neither field provided (400) - Workspace not found: no rows (404), lookup error (404) - Update errors: broadcast_enabled exec fails (500), talk_to_user_enabled exec fails (500) - Success: broadcast_enabled=true, broadcast_enabled=false, talk_to_user_enabled=true, both fields together (all 200) Refs: #1312, #1342 --- .../handlers/workspace_abilities_test.go | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 workspace-server/internal/handlers/workspace_abilities_test.go diff --git a/workspace-server/internal/handlers/workspace_abilities_test.go b/workspace-server/internal/handlers/workspace_abilities_test.go new file mode 100644 index 000000000..5fbdd09a3 --- /dev/null +++ b/workspace-server/internal/handlers/workspace_abilities_test.go @@ -0,0 +1,272 @@ +package handlers + +import ( + "bytes" + "context" + "database/sql" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "github.com/gin-gonic/gin" +) + +// setupAbilitiesDB creates a sqlmock with QueryMatcherEqual (quoted literals +// are not used by PatchAbilities but using the same pattern as +// workspace_broadcast_test.go keeps conventions consistent). +func setupAbilitiesDB(t *testing.T) sqlmock.Sqlmock { + t.Helper() + mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + prevDB := db.DB + db.DB = mockDB + t.Cleanup(func() { db.DB = prevDB; mockDB.Close() }) + return mock +} + +// buildAbilitiesCtx creates a gin.Context wired for PATCH /workspaces/:id/abilities. +func buildAbilitiesCtx(id, body string) (*gin.Context, *httptest.ResponseRecorder) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodPatch, "/workspaces/"+id+"/abilities", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + c.Request = req.WithContext(context.Background()) + c.Params = gin.Params{{Key: "id", Value: id}} + return c, w +} + +// ─── Validation ──────────────────────────────────────────────────────────────── + +func TestPatchAbilities_InvalidWorkspaceID(t *testing.T) { + c, w := buildAbilitiesCtx("not-a-uuid", `{"broadcast_enabled":true}`) + PatchAbilities(c) + if w.Code != http.StatusBadRequest { + t.Errorf("want 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_InvalidJSON(t *testing.T) { + mock := setupAbilitiesDB(t) + c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `not json`) + + PatchAbilities(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("want 400, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet mock expectations: %v", err) + } +} + +func TestPatchAbilities_EmptyBody(t *testing.T) { + mock := setupAbilitiesDB(t) + c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{}`) + + PatchAbilities(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("want 400, got %d: %s", w.Code, w.Body.String()) + } + // No DB queries should fire for an empty-body rejection. + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet mock expectations: %v", err) + } +} + +func TestPatchAbilities_BothFieldsNil(t *testing.T) { + mock := setupAbilitiesDB(t) + c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{"other_field":true}`) + + PatchAbilities(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("want 400, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet mock expectations: %v", err) + } +} + +// ─── Workspace not found ──────────────────────────────────────────────────────── + +func TestPatchAbilities_WorkspaceNotFound(t *testing.T) { + mock := setupAbilitiesDB(t) + c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{"broadcast_enabled":true}`) + + // Workspace lookup returns exists=false. + mock.ExpectQuery("SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + PatchAbilities(c) + + if w.Code != http.StatusNotFound { + t.Errorf("want 404, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet mock expectations: %v", err) + } +} + +func TestPatchAbilities_WorkspaceLookupQueryError(t *testing.T) { + mock := setupAbilitiesDB(t) + c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{"broadcast_enabled":true}`) + + mock.ExpectQuery("SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001"). + WillReturnError(sql.ErrConnDone) + + PatchAbilities(c) + + if w.Code != http.StatusNotFound { + t.Errorf("want 404, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet mock expectations: %v", err) + } +} + +// ─── Update errors ───────────────────────────────────────────────────────────── + +func TestPatchAbilities_BroadcastUpdateError(t *testing.T) { + mock := setupAbilitiesDB(t) + c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{"broadcast_enabled":true}`) + + mock.ExpectQuery("SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + mock.ExpectExec("UPDATE workspaces SET broadcast_enabled = $2, updated_at = now() WHERE id = $1"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001", true). + WillReturnError(sql.ErrConnDone) + + PatchAbilities(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("want 500, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet mock expectations: %v", err) + } +} + +func TestPatchAbilities_TalkToUserUpdateError(t *testing.T) { + mock := setupAbilitiesDB(t) + c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{"talk_to_user_enabled":false}`) + + mock.ExpectQuery("SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + mock.ExpectExec("UPDATE workspaces SET talk_to_user_enabled = $2, updated_at = now() WHERE id = $1"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001", false). + WillReturnError(sql.ErrConnDone) + + PatchAbilities(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("want 500, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet mock expectations: %v", err) + } +} + +// ─── Success paths ───────────────────────────────────────────────────────────── + +func TestPatchAbilities_BroadcastEnabledTrue(t *testing.T) { + mock := setupAbilitiesDB(t) + c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{"broadcast_enabled":true}`) + + mock.ExpectQuery("SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + mock.ExpectExec("UPDATE workspaces SET broadcast_enabled = $2, updated_at = now() WHERE id = $1"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001", true). + WillReturnResult(sqlmock.NewResult(0, 1)) + + PatchAbilities(c) + + if w.Code != http.StatusOK { + t.Errorf("want 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet mock expectations: %v", err) + } +} + +func TestPatchAbilities_BroadcastEnabledFalse(t *testing.T) { + mock := setupAbilitiesDB(t) + c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{"broadcast_enabled":false}`) + + mock.ExpectQuery("SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + mock.ExpectExec("UPDATE workspaces SET broadcast_enabled = $2, updated_at = now() WHERE id = $1"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001", false). + WillReturnResult(sqlmock.NewResult(0, 1)) + + PatchAbilities(c) + + if w.Code != http.StatusOK { + t.Errorf("want 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet mock expectations: %v", err) + } +} + +func TestPatchAbilities_TalkToUserEnabled(t *testing.T) { + mock := setupAbilitiesDB(t) + c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{"talk_to_user_enabled":true}`) + + mock.ExpectQuery("SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + mock.ExpectExec("UPDATE workspaces SET talk_to_user_enabled = $2, updated_at = now() WHERE id = $1"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001", true). + WillReturnResult(sqlmock.NewResult(0, 1)) + + PatchAbilities(c) + + if w.Code != http.StatusOK { + t.Errorf("want 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet mock expectations: %v", err) + } +} + +func TestPatchAbilities_BothFields(t *testing.T) { + mock := setupAbilitiesDB(t) + c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{"broadcast_enabled":true,"talk_to_user_enabled":false}`) + + mock.ExpectQuery("SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + mock.ExpectExec("UPDATE workspaces SET broadcast_enabled = $2, updated_at = now() WHERE id = $1"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001", true). + WillReturnResult(sqlmock.NewResult(0, 1)) + + mock.ExpectExec("UPDATE workspaces SET talk_to_user_enabled = $2, updated_at = now() WHERE id = $1"). + WithArgs("bbbbbbbb-0001-0001-0001-000000000001", false). + WillReturnResult(sqlmock.NewResult(0, 1)) + + PatchAbilities(c) + + if w.Code != http.StatusOK { + t.Errorf("want 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet mock expectations: %v", err) + } +} -- 2.52.0