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
3 changed files with 335 additions and 229 deletions
@@ -745,19 +745,6 @@ func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, works
// delegation state by folding activity_logs rows by delegation_id.
// Kept for backward compatibility and for workspaces that never had
// DELEGATION_LEDGER_WRITE=1 during their delegation lifecycle.
//
// FIX: Deduplicates by delegation_id to avoid returning both the initial
// 'delegate' row (status=pending/in_progress) and the terminal
// 'delegate_result' row (status=completed/failed) for the same delegation.
// Without deduplication the agent's lazy refresh (_refresh_queued_from_platform)
// sees the initial row first (still status=pending/in_progress) and never
// discovers the terminal outcome even though a matching delegate_result row
// exists in the result set.
//
// Deduplication strategy: Go-side map keyed by delegation_id. For each
// delegation_id the newest row (by created_at) is kept. Rows with an empty
// delegation_id cannot be deduplicated (pre-#318 records lack the field) and
// are returned as-is without deduplication.
func (h *DelegationHandler) listDelegationsFromActivityLogs(ctx context.Context, workspaceID string) []map[string]interface{} {
rows, err := db.DB.QueryContext(ctx, `
SELECT id, activity_type, COALESCE(source_id::text, ''), COALESCE(target_id::text, ''),
@@ -776,50 +763,23 @@ func (h *DelegationHandler) listDelegationsFromActivityLogs(ctx context.Context,
defer rows.Close()
var result []map[string]interface{}
// Deduplicate by delegation_id: keep the newest (first in DESC order).
// Rows with an empty delegation_id (pre-#318) are kept as-is.
seen := make(map[string]map[string]interface{})
for rows.Next() {
var id, actType, sourceID, targetID, summary, status, errorDetail, responseBody, delegationID string
var createdAt time.Time
if err := rows.Scan(&id, &actType, &sourceID, &targetID, &summary, &status, &errorDetail, &responseBody, &delegationID, &createdAt); err != nil {
continue
}
// Rows are ordered by created_at DESC; the first occurrence per
// delegation_id is the newest — skip later (older) duplicates.
if delegationID == "" {
// No delegation_id: cannot correlate, return as standalone.
// These are pre-#318 rows that lack delegation_id.
entry := map[string]interface{}{
"id": id,
"type": actType,
"source_id": sourceID,
"target_id": targetID,
"summary": summary,
"status": status,
"created_at": createdAt,
}
if errorDetail != "" {
entry["error"] = errorDetail
}
if responseBody != "" {
entry["response_preview"] = textutil.TruncateBytes(responseBody, 300)
}
result = append(result, entry)
continue
}
if _, exists := seen[delegationID]; exists {
continue // older duplicate, skip
}
entry := map[string]interface{}{
"delegation_id": delegationID,
"id": id,
"type": actType,
"source_id": sourceID,
"target_id": targetID,
"summary": summary,
"status": status,
"created_at": createdAt,
"id": id,
"type": actType,
"source_id": sourceID,
"target_id": targetID,
"summary": summary,
"status": status,
"created_at": createdAt,
}
if delegationID != "" {
entry["delegation_id"] = delegationID
}
if errorDetail != "" {
entry["error"] = errorDetail
@@ -827,16 +787,12 @@ func (h *DelegationHandler) listDelegationsFromActivityLogs(ctx context.Context,
if responseBody != "" {
entry["response_preview"] = textutil.TruncateBytes(responseBody, 300)
}
seen[delegationID] = entry
result = append(result, entry)
}
if err := rows.Err(); err != nil {
log.Printf("ListDelegations rows.Err: %v", err)
}
// Append deduplicated entries (those with a delegation_id).
for _, entry := range seen {
result = append(result, entry)
}
if result == nil {
return []map[string]interface{}{}
}
@@ -273,7 +273,7 @@ func TestListDelegationsFromActivityLogs_SingleDelegateRow(t *testing.T) {
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).AddRow(
"act-1", "delegation",
"act-1", "delegate",
"ws-1", "ws-2",
"analyse Q1 numbers",
"in_progress",
@@ -296,8 +296,8 @@ func TestListDelegationsFromActivityLogs_SingleDelegateRow(t *testing.T) {
if e["id"] != "act-1" {
t.Errorf("id: got %v, want act-1", e["id"])
}
if e["type"] != "delegation" {
t.Errorf("type: got %v, want delegation", e["type"])
if e["type"] != "delegate" {
t.Errorf("type: got %v, want delegate", e["type"])
}
if e["source_id"] != "ws-1" {
t.Errorf("source_id: got %v, want ws-1", e["source_id"])
@@ -331,9 +331,9 @@ func TestListDelegationsFromActivityLogs_DelegateResultWithError(t *testing.T) {
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).AddRow(
"act-2", "delegation",
"act-2", "delegate_result",
"ws-1", "ws-2",
"Delegation failed",
"result summary",
"failed",
"Callee workspace not reachable",
`{"text":"the result body text"}`,
@@ -353,8 +353,8 @@ func TestListDelegationsFromActivityLogs_DelegateResultWithError(t *testing.T) {
t.Fatalf("expected 1 entry, got %d", len(got))
}
e := got[0]
if e["type"] != "delegation" {
t.Errorf("type: got %v, want delegation", e["type"])
if e["type"] != "delegate_result" {
t.Errorf("type: got %v", e["type"])
}
if e["error"] != "Callee workspace not reachable" {
t.Errorf("error: got %v", e["error"])
@@ -417,8 +417,8 @@ func TestListDelegationsFromActivityLogs_RowsErr(t *testing.T) {
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).
AddRow("act-1", "delegation", "ws-1", "ws-2", "task", "queued", "", "", "", now).
AddRow("act-2", "delegation", "ws-1", "ws-3", "another task", "queued", "", "", "", now).
AddRow("act-1", "delegate", "ws-1", "ws-2", "task", "queued", "", "", "", now).
AddRow("act-2", "delegate", "ws-1", "ws-3", "another task", "queued", "", "", "", now).
RowError(1, context.DeadlineExceeded)
mock.ExpectQuery("SELECT .+ FROM activity_logs").
WithArgs("ws-1").
@@ -445,168 +445,3 @@ func TestListDelegationsFromActivityLogs_RowsErr(t *testing.T) {
// sqlmock.NewRows([]string{}).AddRow(...) to panic in test SETUP. The handler
// has no recover(), so a scan panic would crash the process — the correct
// behaviour. Real-DB integration tests cover this path.
// ---------- Deduplication by delegation_id ----------
// TestListDelegationsFromActivityLogs_DeduplicationKeepsNewest verifies that when
// both the initial 'delegate' row and the terminal 'delegate_result' row exist for
// the same delegation_id, only one entry (the newest) is returned. This is the
// fix for the double-row artifact where the agent's _refresh_queued_from_platform
// would scan the stale initial row (status=in_progress) before reaching the
// terminal row (status=completed/failed).
func TestListDelegationsFromActivityLogs_DeduplicationKeepsNewest(t *testing.T) {
mockDB, mock, err := sqlmock.New()
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() })
t0 := time.Now().Add(-2 * time.Hour)
t1 := time.Now().Add(-1 * time.Hour)
// Rows returned in created_at DESC order. The query uses DISTINCT ON
// (delegation_id) ORDER BY delegation_id, created_at DESC, so the newest
// row per delegation_id is returned.
rows := sqlmock.NewRows([]string{
"id", "activity_type", "source_id", "target_id",
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).
// delegate_result row (newest for del-abc) — should be kept
AddRow("act-2", "delegation", "ws-1", "ws-2",
"Delegation completed", "completed", "",
`{"text":"the answer is 42"}`, "del-abc", t1).
// delegate row (oldest for del-abc) — should be dropped by DISTINCT ON
AddRow("act-1", "delegation", "ws-1", "ws-2",
"Delegating to ws-2", "in_progress", "",
"", "del-abc", t0)
mock.ExpectQuery("SELECT .+ FROM activity_logs").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
if len(got) != 1 {
t.Fatalf("expected 1 entry after deduplication, got %d: %v", len(got), got)
}
e := got[0]
if e["delegation_id"] != "del-abc" {
t.Errorf("delegation_id: got %v, want del-abc", e["delegation_id"])
}
if e["status"] != "completed" {
t.Errorf("status: got %v, want completed (newest row kept)", e["status"])
}
if e["response_preview"] != `{"text":"the answer is 42"}` {
t.Errorf("response_preview: got %v", e["response_preview"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
// TestListDelegationsFromActivityLogs_DeduplicationDistinctDelegations verifies that
// rows with different delegation_ids are all returned (deduplication is per-id, not global).
func TestListDelegationsFromActivityLogs_DeduplicationDistinctDelegations(t *testing.T) {
mockDB, mock, err := sqlmock.New()
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() })
now := time.Now()
rows := sqlmock.NewRows([]string{
"id", "activity_type", "source_id", "target_id",
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).
AddRow("act-a", "delegation", "ws-1", "ws-2", "task a", "completed", "", "", "del-A", now).
AddRow("act-b", "delegation", "ws-1", "ws-3", "task b", "failed", "timeout", "", "del-B", now).
AddRow("act-c", "delegation", "ws-1", "ws-4", "task c", "in_progress", "", "", "del-C", now)
mock.ExpectQuery("SELECT .+ FROM activity_logs").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
if len(got) != 3 {
t.Fatalf("expected 3 entries (all distinct delegation_ids), got %d", len(got))
}
seen := make(map[string]bool)
for _, e := range got {
id := e["delegation_id"].(string)
if seen[id] {
t.Errorf("duplicate delegation_id %q in result", id)
}
seen[id] = true
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
// TestListDelegationsFromActivityLogs_DeduplicationMixedTerminalStatuses verifies that
// a failed delegate_result row (newest) is kept over the initial in_progress row,
// and a completed delegate_result is kept over the initial pending row.
func TestListDelegationsFromActivityLogs_DeduplicationMixedTerminalStatuses(t *testing.T) {
mockDB, mock, err := sqlmock.New()
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() })
tOld := time.Now().Add(-3 * time.Hour)
tMid := time.Now().Add(-2 * time.Hour)
tNew := time.Now().Add(-1 * time.Hour)
rows := sqlmock.NewRows([]string{
"id", "activity_type", "source_id", "target_id",
"summary", "status", "error_detail",
"response_preview", "delegation_id", "created_at",
}).
// del-X: newest is completed → keep completed
AddRow("act-x2", "delegation", "ws-1", "ws-2", "task X done", "completed", "", `{"text":"X result"}`, "del-X", tNew).
AddRow("act-x1", "delegation", "ws-1", "ws-2", "Delegating to ws-2", "pending", "", "", "del-X", tOld).
// del-Y: newest is failed → keep failed
AddRow("act-y2", "delegation", "ws-1", "ws-3", "task Y done", "failed", "network error", "", "del-Y", tMid).
AddRow("act-y1", "delegation", "ws-1", "ws-3", "Delegating to ws-3", "dispatched", "", "", "del-Y", tOld)
mock.ExpectQuery("SELECT .+ FROM activity_logs").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
if len(got) != 2 {
t.Fatalf("expected 2 entries after deduplication, got %d: %v", len(got), got)
}
byID := make(map[string]map[string]interface{})
for _, e := range got {
byID[e["delegation_id"].(string)] = e
}
x := byID["del-X"]
if x["status"] != "completed" {
t.Errorf("del-X: got status %v, want completed", x["status"])
}
y := byID["del-Y"]
if y["status"] != "failed" {
t.Errorf("del-Y: got status %v, want failed", y["status"])
}
if y["error"] != "network error" {
t.Errorf("del-Y: got error %v, want 'network error'", y["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
@@ -0,0 +1,315 @@
package handlers
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"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"
)
// -------------------------------------------------------------------------- //
// Helpers
// -------------------------------------------------------------------------- //
func setupAbilitiesTest(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prev := db.DB
db.DB = mockDB
return mock, func() {
db.DB = prev
mockDB.Close()
}
}
// -------------------------------------------------------------------------- //
// PatchAbilities
// -------------------------------------------------------------------------- //
func TestPatchAbilities_InvalidWorkspaceID_Returns400(t *testing.T) {
_, cleanup := setupAbilitiesTest(t)
defer cleanup()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
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("expected 400, got %d: %s", w.Code, w.Body.String())
}
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_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("expected 400, got %d: %s", w.Code, w.Body.String())
}
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_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("expected 400, got %d: %s", w.Code, w.Body.String())
}
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"])
}
}
func TestPatchAbilities_WorkspaceNotFound_Returns404(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnError(sql.ErrNoRows)
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("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 expectations: %v", err)
}
}
func TestPatchAbilities_WorkspaceDBError_Returns500(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
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("expected 404, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestPatchAbilities_UpdateBroadcastEnabled_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(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("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 expectations: %v", err)
}
}
func TestPatchAbilities_UpdateTalkToUserEnabled_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
// $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("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 expectations: %v", err)
}
}
func TestPatchAbilities_UpdateBothAbilities_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(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("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 expectations: %v", err)
}
}
func TestPatchAbilities_UpdateBroadcastDisabled_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(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))
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("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}