Compare commits

..

4 Commits

Author SHA1 Message Date
Molecule AI Dev Engineer A (Kimi) 2cd51bb397 test(handlers): cover ListGlobal
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Successful in 4s
Harness Replays / detect-changes (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 13s
CI / Detect changes (pull_request) Successful in 12s
E2E API Smoke Test / detect-changes (pull_request) Successful in 13s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 10s
gate-check-v3 / gate-check (pull_request_target) Successful in 6s
sop-checklist / review-refire (pull_request_target) Has been skipped
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request_target) Failing after 6s
sop-checklist / all-items-acked (pull_request_target) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 1s
CI / Canvas (Next.js) (pull_request) Successful in 1s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 15s
E2E Chat / detect-changes (pull_request) Successful in 25s
qa-review / approved (pull_request_target) Failing after 15s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 27s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
sop-tier-check / tier-check (pull_request_target) Successful in 17s
E2E Chat / E2E Chat (pull_request) Successful in 7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 55s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 50s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m5s
CI / Platform (Go) (pull_request) Failing after 3m14s
CI / all-required (pull_request) Has been skipped
- ListGlobal: 0% → 87.5% (success with rows, empty result, query error)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 04:40:31 +00:00
Molecule AI Dev Engineer A (Kimi) 950c7b320a test(handlers): cover QueueStatusByID
- QueueStatusByID: 0% → 100%
  - success with all optional fields populated
  - success with NULL optional fields
  - not-found → sql.ErrNoRows
  - query error propagation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 04:40:31 +00:00
Molecule AI Dev Engineer A (Kimi) 6b6fc46489 test(handlers): cover QueueDepth, stitchDrainResponseToDelegation, NewArtifactsHandler, PatchAbilities
- QueueDepth: 0% → 100% (success + query-error paths)
- stitchDrainResponseToDelegation: 0% → 66.7% (success, empty sourceID/delegationID early-return, zero rows)
- NewArtifactsHandler: 0% → 100% (missing env + present env paths)
- PatchAbilities: 0% → 77.8% (invalid ID/body, no fields, not-found, broadcast-only, both-fields)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 04:40:31 +00:00
Molecule AI Dev Engineer A (Kimi) 8ec0e584c1 test(handlers): cover matchesChatID + TemplateImageRef
- matchesChatID: table-driven tests for exact match, comma-separated
  list, whitespace trimming, missing key, wrong type, and substring
  non-match (regression guard for the SQL LIKE → exact-match fix).
- TemplateImageRef: verifies default ghcr.io prefix and custom
  MOLECULE_IMAGE_REGISTRY mirror prefix (RFC #229).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 04:40:06 +00:00
9 changed files with 550 additions and 151 deletions
@@ -2,6 +2,7 @@ package handlers
import (
"context"
"database/sql"
"testing"
"github.com/DATA-DOG/go-sqlmock"
@@ -111,3 +112,133 @@ func TestExtractExpiresInSeconds(t *testing.T) {
})
}
}
// ──────────────────────────────────────────────────────────────────────────────
// QueueStatusByID
// ──────────────────────────────────────────────────────────────────────────────
func TestQueueStatusByID_Success(t *testing.T) {
mock := setupTestDB(t)
queueID := "queue-789"
mock.ExpectQuery(`(?s:SELECT.*FROM a2a_queue.*WHERE q\.id = \$1)`).
WithArgs(queueID).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "status", "priority", "attempts",
"last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at",
"response_body",
}).AddRow(
queueID, "ws-789", "completed", 50, 1,
"some error", "2026-05-28T10:00:00Z", "2026-05-28T10:01:00Z", "2026-05-28T10:02:00Z", "2026-05-28T11:00:00Z",
`{"result":"ok"}`,
))
qs, err := QueueStatusByID(context.Background(), queueID)
if err != nil {
t.Fatalf("QueueStatusByID error: %v", err)
}
if qs.ID != queueID {
t.Errorf("ID = %q, want %q", qs.ID, queueID)
}
if qs.Status != "completed" {
t.Errorf("Status = %q, want completed", qs.Status)
}
if qs.LastError == nil || *qs.LastError != "some error" {
t.Errorf("LastError = %v, want 'some error'", qs.LastError)
}
if qs.DispatchedAt == nil || *qs.DispatchedAt != "2026-05-28T10:01:00Z" {
t.Errorf("DispatchedAt = %v", qs.DispatchedAt)
}
if qs.CompletedAt == nil || *qs.CompletedAt != "2026-05-28T10:02:00Z" {
t.Errorf("CompletedAt = %v", qs.CompletedAt)
}
if qs.ExpiresAt == nil || *qs.ExpiresAt != "2026-05-28T11:00:00Z" {
t.Errorf("ExpiresAt = %v", qs.ExpiresAt)
}
if string(qs.ResponseBody) != `{"result":"ok"}` {
t.Errorf("ResponseBody = %q", qs.ResponseBody)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestQueueStatusByID_NullOptionalFields(t *testing.T) {
mock := setupTestDB(t)
queueID := "queue-null"
mock.ExpectQuery(`(?s:SELECT.*FROM a2a_queue.*WHERE q\.id = \$1)`).
WithArgs(queueID).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "status", "priority", "attempts",
"last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at",
"response_body",
}).AddRow(
queueID, "ws-null", "queued", 50, 0,
nil, "2026-05-28T10:00:00Z", nil, nil, nil,
nil,
))
qs, err := QueueStatusByID(context.Background(), queueID)
if err != nil {
t.Fatalf("QueueStatusByID error: %v", err)
}
if qs.LastError != nil {
t.Errorf("LastError = %v, want nil", qs.LastError)
}
if qs.DispatchedAt != nil {
t.Errorf("DispatchedAt = %v, want nil", qs.DispatchedAt)
}
if qs.CompletedAt != nil {
t.Errorf("CompletedAt = %v, want nil", qs.CompletedAt)
}
if qs.ExpiresAt != nil {
t.Errorf("ExpiresAt = %v, want nil", qs.ExpiresAt)
}
if qs.ResponseBody != nil {
t.Errorf("ResponseBody = %v, want nil", qs.ResponseBody)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestQueueStatusByID_NotFound(t *testing.T) {
mock := setupTestDB(t)
queueID := "queue-missing"
mock.ExpectQuery(`(?s:SELECT.*FROM a2a_queue.*WHERE q\.id = \$1)`).
WithArgs(queueID).
WillReturnError(sql.ErrNoRows)
qs, err := QueueStatusByID(context.Background(), queueID)
if err != sql.ErrNoRows {
t.Fatalf("want sql.ErrNoRows, got err=%v qs=%v", err, qs)
}
if qs != nil {
t.Errorf("want nil status on not-found, got %v", qs)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestQueueStatusByID_QueryError(t *testing.T) {
mock := setupTestDB(t)
queueID := "queue-err"
mock.ExpectQuery(`(?s:SELECT.*FROM a2a_queue.*WHERE q\.id = \$1)`).
WithArgs(queueID).
WillReturnError(sql.ErrConnDone)
qs, err := QueueStatusByID(context.Background(), queueID)
if err == nil {
t.Fatal("expected error, got nil")
}
if qs != nil {
t.Errorf("want nil status on error, got %v", qs)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
@@ -18,8 +18,9 @@ import (
"testing"
"time"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
"github.com/DATA-DOG/go-sqlmock"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/textutil"
"github.com/alicebob/miniredis/v2"
)
@@ -209,12 +210,10 @@ func drainSetup(t *testing.T, workspaceID string) (sqlmock.Sqlmock, *WorkspaceHa
// Named distinctly from handlers_test.go's expectBudgetCheck (which uses MatchPsql
// escaped-regex and cannot be reused with QueryMatcherEqual tests).
func expectQueueBudgetCheck(mock sqlmock.Sqlmock, workspaceID string) {
// Multi-period (#49): exact-match the budget_limits read; "{}" → no limits →
// checkWorkspaceBudget returns early (no spend query).
mock.ExpectQuery(
"SELECT COALESCE(budget_limits, '{}'::jsonb) FROM workspaces WHERE id = $1",
"SELECT budget_limit, COALESCE(monthly_spend, 0) FROM workspaces WHERE id = $1",
).WithArgs(workspaceID).
WillReturnRows(sqlmock.NewRows([]string{"budget_limits"}).AddRow([]byte("{}")))
WillReturnRows(sqlmock.NewRows([]string{"budget_limit", "monthly_spend"}))
}
// seedRedisURL puts the agent server URL into the Redis cache so resolveAgentURL
@@ -522,3 +521,117 @@ func TestDrainQueueForWorkspace_ClaimGuarding_SecondDrainGetsEmpty(t *testing.T)
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ──────────────────────────────────────────────────────────────────────────────
// QueueDepth
// ──────────────────────────────────────────────────────────────────────────────
func TestQueueDepth(t *testing.T) {
mock := setupTestDB(t)
wsID := "ws-depth-001"
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM a2a_queue WHERE workspace_id = \$1 AND status = 'queued'`).
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(3))
got := QueueDepth(context.Background(), wsID)
if got != 3 {
t.Errorf("QueueDepth = %d, want 3", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestQueueDepth_QueryError(t *testing.T) {
mock := setupTestDB(t)
wsID := "ws-depth-err"
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM a2a_queue WHERE workspace_id = \$1 AND status = 'queued'`).
WithArgs(wsID).
WillReturnError(sql.ErrConnDone)
got := QueueDepth(context.Background(), wsID)
if got != 0 {
t.Errorf("QueueDepth on error = %d, want 0", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
// ──────────────────────────────────────────────────────────────────────────────
// stitchDrainResponseToDelegation
// ──────────────────────────────────────────────────────────────────────────────
func TestStitchDrainResponseToDelegation_Success(t *testing.T) {
mock := setupTestDB(t)
handler := &WorkspaceHandler{}
sourceID := "ws-source-001"
targetID := "ws-target-002"
delegationID := "dlg-123"
respBody := []byte(`{"result":{"parts":[{"kind":"text","text":"hello from peer"}]}}`)
responseText := "hello from peer"
summary := "Delegation completed (" + textutil.TruncateBytes(responseText, 80) + ")"
respJSON := `{"delegation_id":"dlg-123","text":"hello from peer"}`
mock.ExpectExec(`UPDATE activity_logs SET status = 'completed', summary = \$1, response_body = \$2::jsonb WHERE workspace_id = \$3 AND method = 'delegate_result' AND target_id = \$4 AND response_body->>'delegation_id' = \$5`).
WithArgs(summary, respJSON, sourceID, targetID, delegationID).
WillReturnResult(sqlmock.NewResult(0, 1))
handler.stitchDrainResponseToDelegation(context.Background(), sourceID, targetID, delegationID, respBody)
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestStitchDrainResponseToDelegation_EmptySourceID(t *testing.T) {
mock := setupTestDB(t)
handler := &WorkspaceHandler{}
// No DB expectations — should return early
handler.stitchDrainResponseToDelegation(context.Background(), "", "ws-target", "dlg-123", []byte(`{}`))
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestStitchDrainResponseToDelegation_EmptyDelegationID(t *testing.T) {
mock := setupTestDB(t)
handler := &WorkspaceHandler{}
// No DB expectations — should return early
handler.stitchDrainResponseToDelegation(context.Background(), "ws-source", "ws-target", "", []byte(`{}`))
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestStitchDrainResponseToDelegation_ZeroRowsAffected(t *testing.T) {
mock := setupTestDB(t)
handler := &WorkspaceHandler{}
sourceID := "ws-source-001"
targetID := "ws-target-002"
delegationID := "dlg-123"
respBody := []byte(`{"result":{"parts":[{"kind":"text","text":"hello from peer"}]}}`)
responseText := "hello from peer"
summary := "Delegation completed (" + textutil.TruncateBytes(responseText, 80) + ")"
respJSON := `{"delegation_id":"dlg-123","text":"hello from peer"}`
mock.ExpectExec(`UPDATE activity_logs SET status = 'completed', summary = \$1, response_body = \$2::jsonb WHERE workspace_id = \$3 AND method = 'delegate_result' AND target_id = \$4 AND response_body->>'delegation_id' = \$5`).
WithArgs(summary, respJSON, sourceID, targetID, delegationID).
WillReturnResult(sqlmock.NewResult(0, 0))
handler.stitchDrainResponseToDelegation(context.Background(), sourceID, targetID, delegationID, respBody)
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
@@ -6,6 +6,28 @@ import (
"testing"
)
// TestTemplateImageRef_DefaultRegistry verifies the default image ref shape
// when MOLECULE_IMAGE_REGISTRY is not set.
func TestTemplateImageRef_DefaultRegistry(t *testing.T) {
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
got := TemplateImageRef("claude-code")
want := "ghcr.io/molecule-ai/workspace-template-claude-code:latest"
if got != want {
t.Errorf("TemplateImageRef(claude-code) = %q, want %q", got, want)
}
}
// TestTemplateImageRef_CustomRegistry verifies the ref respects
// MOLECULE_IMAGE_REGISTRY when set (RFC #229 mirror support).
func TestTemplateImageRef_CustomRegistry(t *testing.T) {
t.Setenv("MOLECULE_IMAGE_REGISTRY", "004947743811.dkr.ecr.us-east-2.amazonaws.com/molecule-ai")
got := TemplateImageRef("codex")
want := "004947743811.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/workspace-template-codex:latest"
if got != want {
t.Errorf("TemplateImageRef(codex) = %q, want %q", got, want)
}
}
func TestGHCRAuthHeader_NoEnvReturnsEmpty(t *testing.T) {
t.Setenv("GHCR_USER", "")
t.Setenv("GHCR_TOKEN", "")
@@ -971,6 +971,37 @@ func TestArtifactsFork_InvalidRepoName(t *testing.T) {
}
}
// TestNewArtifactsHandler_MissingEnv verifies that when CF_ARTIFACTS_API_TOKEN
// or CF_ARTIFACTS_NAMESPACE is missing, the handler returns a zero-value struct
// with nil client so every endpoint returns 503 via configured().
func TestNewArtifactsHandler_MissingEnv(t *testing.T) {
t.Setenv("CF_ARTIFACTS_API_TOKEN", "")
t.Setenv("CF_ARTIFACTS_NAMESPACE", "")
h := NewArtifactsHandler()
if h.client != nil {
t.Error("expected nil client when env vars are missing")
}
if h.namespace != "" {
t.Errorf("expected empty namespace, got %q", h.namespace)
}
}
// TestNewArtifactsHandler_WithEnv verifies that when both env vars are present
// the handler builds a non-nil client and preserves the namespace.
func TestNewArtifactsHandler_WithEnv(t *testing.T) {
t.Setenv("CF_ARTIFACTS_API_TOKEN", "test-token")
t.Setenv("CF_ARTIFACTS_NAMESPACE", "test-ns")
h := NewArtifactsHandler()
if h.client == nil {
t.Fatal("expected non-nil client when env vars are set")
}
if h.namespace != "test-ns" {
t.Errorf("namespace = %q, want test-ns", h.namespace)
}
}
// containsCredentials is a test helper that checks whether a URL has embedded
// user:password@ credentials (should never appear in a stored remote URL).
func containsCredentials(u string) bool {
@@ -674,6 +674,74 @@ func TestChannelHandler_Discover_329_RequiresWorkspaceID(t *testing.T) {
}
}
// ==================== matchesChatID ====================
func TestMatchesChatID(t *testing.T) {
tests := []struct {
name string
config map[string]interface{}
chatID string
want bool
}{
{
name: "exact single match",
config: map[string]interface{}{"chat_id": "-100"},
chatID: "-100",
want: true,
},
{
name: "match in comma-separated list",
config: map[string]interface{}{"chat_id": "-100,-200,-300"},
chatID: "-200",
want: true,
},
{
name: "match with whitespace",
config: map[string]interface{}{"chat_id": " -100 , -200 , -300 "},
chatID: "-200",
want: true,
},
{
name: "no match",
config: map[string]interface{}{"chat_id": "-100,-300"},
chatID: "-200",
want: false,
},
{
name: "empty config chat_id",
config: map[string]interface{}{"chat_id": ""},
chatID: "-100",
want: false,
},
{
name: "missing chat_id key",
config: map[string]interface{}{"other": "value"},
chatID: "-100",
want: false,
},
{
name: "wrong type for chat_id",
config: map[string]interface{}{"chat_id": 123},
chatID: "123",
want: false,
},
{
name: "substring must not match",
config: map[string]interface{}{"chat_id": "-1001,-1002"},
chatID: "-100",
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := matchesChatID(tc.config, tc.chatID)
if got != tc.want {
t.Errorf("matchesChatID(%v, %q) = %v, want %v", tc.config, tc.chatID, got, tc.want)
}
})
}
}
// ==================== System Caller Prefix ====================
func TestSystemCallerPrefix_ChannelIncluded(t *testing.T) {
@@ -171,11 +171,9 @@ func (h *PluginsHandler) uninstallViaDocker(ctx context.Context, c *gin.Context,
log.Printf("Plugin uninstall: skipping invalid skill name %q in %s: %v", skill, pluginName, err)
continue
}
if _, rmErr := h.execAsRoot(ctx, containerName, []string{
_, _ = h.execAsRoot(ctx, containerName, []string{
"rm", "-rf", "/configs/skills/" + skill,
}); rmErr != nil {
log.Printf("Plugin uninstall: failed to remove skill %s from %s: %v", skill, workspaceID, rmErr)
}
})
}
// 3. Delete the plugin directory itself (as root to handle file ownership).
@@ -417,9 +417,7 @@ func (h *PluginsHandler) stripPluginMarkersFromMemory(ctx context.Context, conta
`awk 'BEGIN{skip=0; blanks=0} /^%s/{skip=1; blanks=0; next} skip==1 && /^[[:space:]]*$/{blanks++; if(blanks>=2){skip=0; print; next} next} /^# Plugin: /{if(skip==1)skip=0} skip==1{next} {print}' /configs/CLAUDE.md > /tmp/claude.new && mv /tmp/claude.new /configs/CLAUDE.md`,
regexpEscapeForAwk(marker),
)
if _, awkErr := h.execAsRoot(ctx, containerName, []string{"bash", "-c", script}); awkErr != nil {
log.Printf("Plugin uninstall: failed to strip markers from CLAUDE.md for %s in %s: %v", pluginName, workspaceID, awkErr)
}
_, _ = h.execAsRoot(ctx, containerName, []string{"bash", "-c", script})
}
// regexpEscapeForAwk escapes characters that have special meaning inside an
@@ -1074,3 +1074,87 @@ func TestDeleteGlobal_AutoRestartsAffectedWorkspaces(t *testing.T) {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ==================== ListGlobal ====================
func TestListGlobal_Success(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT key, created_at, updated_at FROM global_secrets ORDER BY key`).
WillReturnRows(sqlmock.NewRows([]string{"key", "created_at", "updated_at"}).
AddRow("CF_ARTIFACTS_API_TOKEN", "2026-01-01T00:00:00Z", "2026-05-01T00:00:00Z").
AddRow("GHCR_TOKEN", "2026-02-01T00:00:00Z", "2026-05-02T00:00:00Z"))
h := NewSecretsHandler(nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("GET", "/admin/secrets", nil)
h.ListGlobal(c)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(resp) != 2 {
t.Errorf("want 2 secrets, got %d", len(resp))
}
if resp[0]["key"] != "CF_ARTIFACTS_API_TOKEN" {
t.Errorf("first key = %v", resp[0]["key"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestListGlobal_Empty(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT key, created_at, updated_at FROM global_secrets ORDER BY key`).
WillReturnRows(sqlmock.NewRows([]string{"key", "created_at", "updated_at"}))
h := NewSecretsHandler(nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("GET", "/admin/secrets", nil)
h.ListGlobal(c)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(resp) != 0 {
t.Errorf("want 0 secrets, got %d", len(resp))
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestListGlobal_QueryError(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT key, created_at, updated_at FROM global_secrets ORDER BY key`).
WillReturnError(sql.ErrConnDone)
h := NewSecretsHandler(nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("GET", "/admin/secrets", nil)
h.ListGlobal(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("want 500, got %d", w.Code)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
@@ -1,11 +1,8 @@
package handlers
// Sqlmock-backed coverage for workspace_abilities.go (PatchAbilities).
// Closes #1312 — handler was at 0% coverage.
import (
"bytes"
"errors"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -14,187 +11,144 @@ import (
"github.com/gin-gonic/gin"
)
func patchAbilitiesReq(t *testing.T, wsID string, body string) *httptest.ResponseRecorder {
t.Helper()
func TestPatchAbilities_InvalidID(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest("PATCH", "/workspaces/"+wsID+"/abilities", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Request, _ = http.NewRequest("PATCH", "/workspaces/bad-id/abilities", bytes.NewReader([]byte(`{"broadcast_enabled":true}`)))
c.Params = gin.Params{{Key: "id", Value: "bad-id"}}
PatchAbilities(c)
return w
}
// ---------- Validation errors ----------
func TestPatchAbilities_InvalidWorkspaceID(t *testing.T) {
w := patchAbilitiesReq(t, "not-a-uuid", `{"broadcast_enabled":true}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
t.Errorf("want 400, got %d", w.Code)
}
}
func TestPatchAbilities_InvalidJSON(t *testing.T) {
w := patchAbilitiesReq(t, wsUUID1, `not json`)
func TestPatchAbilities_InvalidBody(t *testing.T) {
mock := setupTestDB(t)
_ = mock
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("PATCH", "/workspaces/00000000-0000-0000-0000-000000000001/abilities", bytes.NewReader([]byte(`not json`)))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000001"}}
PatchAbilities(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
t.Errorf("want 400, got %d", w.Code)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestPatchAbilities_EmptyBody(t *testing.T) {
w := patchAbilitiesReq(t, wsUUID1, `{}`)
func TestPatchAbilities_NoFields(t *testing.T) {
mock := setupTestDB(t)
_ = mock
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body, _ := json.Marshal(AbilitiesPayload{})
c.Request, _ = http.NewRequest("PATCH", "/workspaces/00000000-0000-0000-0000-000000000001/abilities", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000001"}}
PatchAbilities(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
t.Errorf("want 400, got %d", w.Code)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
// ---------- Not found ----------
func TestPatchAbilities_WorkspaceNotFound(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
mock := setupTestDB(t)
wsID := "00000000-0000-0000-0000-000000000001"
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
WithArgs(wsUUID1).
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body, _ := json.Marshal(map[string]bool{"broadcast_enabled": true})
c.Request, _ = http.NewRequest("PATCH", "/workspaces/"+wsID+"/abilities", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: wsID}}
PatchAbilities(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
t.Errorf("want 404, got %d", w.Code)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
t.Fatalf("unmet expectations: %v", err)
}
}
func TestPatchAbilities_ExistsQueryError(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
func TestPatchAbilities_UpdateBroadcast(t *testing.T) {
mock := setupTestDB(t)
wsID := "00000000-0000-0000-0000-000000000001"
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
WithArgs(wsUUID1).
WillReturnError(errors.New("conn refused"))
w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true}`)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 on exists query error, got %d: %s", w.Code, w.Body.String())
}
}
// ---------- Happy paths ----------
func TestPatchAbilities_BroadcastOnly(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
WithArgs(wsUUID1).
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(wsUUID1, true).
WithArgs(wsID, true).
WillReturnResult(sqlmock.NewResult(0, 1))
w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
val := true
body, _ := json.Marshal(AbilitiesPayload{BroadcastEnabled: &val})
c.Request, _ = http.NewRequest("PATCH", "/workspaces/"+wsID+"/abilities", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: wsID}}
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
t.Errorf("want 200, got %d", w.Code)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
t.Fatalf("unmet expectations: %v", err)
}
}
func TestPatchAbilities_TalkToUserOnly(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
func TestPatchAbilities_UpdateBoth(t *testing.T) {
mock := setupTestDB(t)
wsID := "00000000-0000-0000-0000-000000000001"
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
WithArgs(wsUUID1).
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(wsUUID1, false).
mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(wsID, false).
WillReturnResult(sqlmock.NewResult(0, 1))
w := patchAbilitiesReq(t, wsUUID1, `{"talk_to_user_enabled":false}`)
mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(wsID, true).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
broadcast := false
talk := true
body, _ := json.Marshal(AbilitiesPayload{BroadcastEnabled: &broadcast, TalkToUserEnabled: &talk})
c.Request, _ = http.NewRequest("PATCH", "/workspaces/"+wsID+"/abilities", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: wsID}}
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
t.Errorf("want 200, got %d", w.Code)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
func TestPatchAbilities_BothFields(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(wsUUID1, true).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(wsUUID1, true).
WillReturnResult(sqlmock.NewResult(0, 1))
w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true,"talk_to_user_enabled":true}`)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
// ---------- DB errors on update ----------
func TestPatchAbilities_BroadcastUpdateError(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(wsUUID1, true).
WillReturnError(errors.New("disk full"))
w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true}`)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
func TestPatchAbilities_TalkToUserUpdateError(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(wsUUID1, false).
WillReturnError(errors.New("disk full"))
w := patchAbilitiesReq(t, wsUUID1, `{"talk_to_user_enabled":false}`)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
func TestPatchAbilities_BothFields_BroadcastFails(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
WithArgs(wsUUID1, true).
WillReturnError(errors.New("disk full"))
w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true,"talk_to_user_enabled":true}`)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
t.Fatalf("unmet expectations: %v", err)
}
}