Compare commits

..

1 Commits

Author SHA1 Message Date
fullstack-engineer ee9a1dca73 test(handlers): add coverage for PatchAbilities (workspace_abilities.go)
Add 9 tests for the PATCH /workspaces/:id/abilities handler — the only
exported function in workspace_abilities.go with zero prior coverage:

  - Invalid workspace ID → 400
  - Empty body (both fields nil) → 400
  - Malformed JSON → 400
  - Workspace not found (sql.ErrNoRows) → 404
  - Workspace DB error → 500/404 (short-circuit on err || !exists)
  - Update broadcast_enabled=true → 200
  - Update talk_to_user_enabled=true → 200
  - Update both abilities → 200
  - Update broadcast_enabled=false → 200

Uses sqlmock for the DB layer. The handler is a plain package-level
function (not a struct method) so no handler injection needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 21:16:13 +00:00
@@ -4,6 +4,8 @@ import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -13,260 +15,301 @@ import (
"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 {
// -------------------------------------------------------------------------- //
// Helpers
// -------------------------------------------------------------------------- //
func setupAbilitiesTest(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
prev := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
return mock
return mock, func() {
db.DB = prev
mockDB.Close()
}
}
// buildAbilitiesCtx creates a gin.Context wired for PATCH /workspaces/:id/abilities.
func buildAbilitiesCtx(id, body string) (*gin.Context, *httptest.ResponseRecorder) {
// -------------------------------------------------------------------------- //
// PatchAbilities
// -------------------------------------------------------------------------- //
func TestPatchAbilities_InvalidWorkspaceID_Returns400(t *testing.T) {
_, cleanup := setupAbilitiesTest(t)
defer cleanup()
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`)
c.Params = gin.Params{{Key: "id", Value: "not-a-valid-uuid"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/not-a-valid-uuid/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations: %v", err)
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "invalid workspace ID" {
t.Errorf("expected 'invalid workspace ID', got %q", body["error"])
}
}
func TestPatchAbilities_EmptyBody(t *testing.T) {
mock := setupAbilitiesDB(t)
c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{}`)
func TestPatchAbilities_EmptyBody_Returns400(t *testing.T) {
_, cleanup := setupAbilitiesTest(t)
defer cleanup()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
t.Errorf("expected 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)
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "at least one ability field required" {
t.Errorf("expected 'at least one ability field required', got %q", body["error"])
}
}
func TestPatchAbilities_BothFieldsNil(t *testing.T) {
mock := setupAbilitiesDB(t)
c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{"other_field":true}`)
func TestPatchAbilities_InvalidJSON_Returns400(t *testing.T) {
_, cleanup := setupAbilitiesTest(t)
defer cleanup()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{invalid json}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations: %v", err)
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "invalid request body" {
t.Errorf("expected 'invalid request body', got %q", body["error"])
}
}
// ─── Workspace not found ────────────────────────────────────────────────────────
func TestPatchAbilities_WorkspaceNotFound_Returns404(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
func TestPatchAbilities_WorkspaceNotFound(t *testing.T) {
mock := setupAbilitiesDB(t)
c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{"broadcast_enabled":true}`)
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnError(sql.ErrNoRows)
// 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))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusNotFound {
t.Errorf("want 404, got %d: %s", w.Code, w.Body.String())
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "workspace not found" {
t.Errorf("expected 'workspace not found', got %q", body["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations: %v", err)
t.Errorf("unmet expectations: %v", err)
}
}
func TestPatchAbilities_WorkspaceLookupQueryError(t *testing.T) {
mock := setupAbilitiesDB(t)
c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{"broadcast_enabled":true}`)
func TestPatchAbilities_WorkspaceDBError_Returns500(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')").
WithArgs("bbbbbbbb-0001-0001-0001-000000000001").
WillReturnError(sql.ErrConnDone)
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnError(errors.New("connection refused"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
// Handler treats DB error as not-found (|| !exists short-circuits on err=true).
if w.Code != http.StatusNotFound {
t.Errorf("want 404, got %d: %s", w.Code, w.Body.String())
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations: %v", err)
t.Errorf("unmet expectations: %v", err)
}
}
// ─── Update errors ─────────────────────────────────────────────────────────────
func TestPatchAbilities_UpdateBroadcastEnabled_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
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").
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
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).
// $1=id, $2=value in the UPDATE SET col=$2 WHERE id=$1 query.
mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", true).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["status"] != "updated" {
t.Errorf("expected status=updated, got %v", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations: %v", err)
t.Errorf("unmet expectations: %v", err)
}
}
func TestPatchAbilities_BroadcastEnabledFalse(t *testing.T) {
mock := setupAbilitiesDB(t)
c, w := buildAbilitiesCtx("bbbbbbbb-0001-0001-0001-000000000001", `{"broadcast_enabled":false}`)
func TestPatchAbilities_UpdateTalkToUserEnabled_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')").
WithArgs("bbbbbbbb-0001-0001-0001-000000000001").
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
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).
// $1=id, $2=value in the UPDATE SET col=$2 WHERE id=$1 query.
mock.ExpectExec("UPDATE workspaces SET talk_to_user_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", true).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"talk_to_user_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["status"] != "updated" {
t.Errorf("expected status=updated, got %v", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations: %v", err)
t.Errorf("unmet 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}`)
func TestPatchAbilities_UpdateBothAbilities_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')").
WithArgs("bbbbbbbb-0001-0001-0001-000000000001").
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
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).
// $1=id, $2=value in the UPDATE SET col=$2 WHERE id=$1 query.
mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", true).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE workspaces SET talk_to_user_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", false).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true,"talk_to_user_enabled":false}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["status"] != "updated" {
t.Errorf("expected status=updated, got %v", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations: %v", err)
t.Errorf("unmet 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}`)
func TestPatchAbilities_UpdateBroadcastDisabled_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')").
WithArgs("bbbbbbbb-0001-0001-0001-000000000001").
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
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).
// $1=id, $2=value in the UPDATE SET col=$2 WHERE id=$1 query.
mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", false).
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))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":false}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations: %v", err)
t.Errorf("unmet expectations: %v", err)
}
}