Compare commits

..

2 Commits

Author SHA1 Message Date
fullstack-engineer 1b9e69b309 test(handlers): add queueRowAuthFields + additional GetA2AQueueStatus coverage
CI / all-required (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 31s
CI / Detect changes (pull_request) Successful in 34s
Harness Replays / detect-changes (pull_request) Successful in 32s
E2E API Smoke Test / detect-changes (pull_request) Successful in 46s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 46s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 37s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 51s
gate-check-v3 / gate-check (pull_request) Successful in 33s
qa-review / approved (pull_request) Successful in 35s
sop-tier-check / tier-check (pull_request) Successful in 36s
sop-checklist / all-items-acked (pull_request) Successful in 40s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m47s
CI / Canvas (Next.js) (pull_request) Failing after 10m22s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Failing after 10m44s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 12s
CI / Python Lint & Test (pull_request) Successful in 11s
Harness Replays / Harness Replays (pull_request) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 13s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 6m55s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 7m4s
security-review / approved (pull_request) Has been cancelled
Extends fix/a2a-queue-status-coverage with:
- TestQueueRowAuthFields_Success_BothPresent (internal helper success path)
- TestQueueRowAuthFields_NoRows_ReturnsErrNoRows
- TestQueueRowAuthFields_QueryError_ReturnsError
- TestGetA2AQueueStatus_AuthPass_CallerMatchesCallerID (caller_id auth path)
- TestGetA2AQueueStatus_AuthPass_OrgTokenBypassesAuth (org-level token bypass)
- TestGetA2AQueueStatus_StatusQueryNoRows_NotFound (race-to-404)
- TestGetA2AQueueStatus_ResponseBodyIncludedWhenCompleted

All 30 platform packages pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 14:23:21 +00:00
fullstack-engineer 0967ed908d test(handlers): add coverage for QueueDepth, QueueStatusByID, GetA2AQueueStatus, emitOrgEvent nil-payload
a2a_queue_status.go had 0% coverage across all 3 exported symbols.
Added 14 tests exercising:

QueueDepth (package-level):
- TestQueueDepth_Success: COUNT returns 7
- TestQueueDepth_EmptyQueue: COUNT returns 0
- TestQueueDepth_QueryError_ReturnsZero: DB error → returns 0 (non-fatal)

QueueStatusByID (package-level):
- TestQueueStatusByID_Success: fully-populated QueueStatus from LEFT JOIN
- TestQueueStatusByID_CompletedWithResponse: completed item populates ResponseBody
- TestQueueStatusByID_ErrNoRows: sql.ErrNoRows propagates
- TestQueueStatusByID_QueryError: DB error propagates

GetA2AQueueStatus (HTTP handler):
- TestGetA2AQueueStatus_MissingQueueID_Returns400
- TestGetA2AQueueStatus_NoIdentity_Returns404 (not 401 per design)
- TestGetA2AQueueStatus_QueueNotFound_Returns404
- TestGetA2AQueueStatus_UnauthorizedCaller_Returns404 (not 403 per design)
- TestGetA2AQueueStatus_AuthorizedAsTarget_Success
- TestGetA2AQueueStatus_QueueRowLookupError_Returns500
- TestGetA2AQueueStatus_StatusFetchError_Returns500

org_import_reconcile_test.go:
- TestEmitOrgEvent_NilPayloadInitializesEmptyMap: exercises the
  payload == nil branch so the empty-map init path is covered.

All tests pass; full suite: 69.1% → 69.7%.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 14:23:21 +00:00
8 changed files with 624 additions and 643 deletions
@@ -1,183 +0,0 @@
// @vitest-environment jsdom
//
// Behavioral coverage for the SkillsTab registry loading and source schemes
// flows. Two regressions this pins down:
//
// 1. Registry fetch timeout: when GET /plugins takes >10s the component
// used to silently swallow the error (console.warn only), making it
// indistinguishable from a genuinely empty registry. Now it surfaces
// a specific timeout error with a Retry button so the user can recover.
//
// 2. Source schemes fallback: GET /plugins/sources failure is silent
// (falls back to "local only" UX) rather than crashing the component.
// This test verifies the fallback works without breaking the rest of
// the UI.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, act, waitFor } from "@testing-library/react";
import React from "react";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
Element.prototype.scrollIntoView = vi.fn();
});
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((selector: (s: Record<string, unknown>) => unknown) =>
selector({ setPanelTab: vi.fn() } as Record<string, unknown>),
),
{ getState: () => ({ setPanelTab: vi.fn() }) },
),
summarizeWorkspaceCapabilities: vi.fn(() => ({ skills: [], tools: [] })),
}));
vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
import { SkillsTab } from "../tabs/SkillsTab";
import { api } from "@/lib/api";
function makeData() {
return {
name: "Test WS",
status: "online",
tier: 1,
agentCard: null,
activeTasks: 0,
collapsed: false,
role: "agent",
lastErrorRate: 0,
lastSampleError: "",
url: "http://localhost:9000",
parentId: null,
currentTask: "",
runtime: "claude_code",
needsRestart: false,
budgetLimit: null,
};
}
const REGISTRY = [
{
name: "browser-automation",
version: "1.1.0",
description: "Browser automation + testing",
author: "molecule",
tags: ["browser", "playwright"],
skills: [],
runtimes: ["claude-code"],
},
];
// Stable spy reference so tests can re-configure mockImplementation
// without re-creating the spy (avoids any restoration ordering issues).
let getSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Restore previous spy before creating a new one so old implementations
// don't leak between tests.
if (getSpy) getSpy.mockRestore();
getSpy = vi.spyOn(api, "get");
// Polyfill: jsdom Element.scrollIntoView is undefined without a browser.
Element.prototype.scrollIntoView = vi.fn();
// Microtasks (promise rejections) need real timers to flush so React
// state updates are visible to waitFor polling.
vi.useRealTimers();
});
// ─── Registry-loading tests ──────────────────────────────────────────────────
describe("SkillsTab registry loading", () => {
it("shows a generic error when GET /plugins fails", async () => {
getSpy.mockImplementation((path: string) => {
if (path === "/plugins") return Promise.reject(new Error("503 Service Unavailable"));
if (path === `/workspaces/ws-1/plugins`) return Promise.resolve([]);
if (path === "/plugins/sources") return Promise.resolve({ schemes: ["local://"] });
return Promise.resolve(null);
});
render(<SkillsTab workspaceId="ws-1" data={makeData() as never} />);
// Expand the registry section to see the error div (showRegistry starts false).
// Note: auto-expand requires registry.length > 0, so it doesn't fire on errors.
// Match compact-empty test pattern: wait for pill to settle before clicking.
await waitFor(() => {
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
});
const installBtn = screen.getByRole("button", { name: /\+ Install Plugin/i });
fireEvent.click(installBtn);
// Wait for the error div to appear inside the expanded registry.
await screen.findByText(/503 Service Unavailable/i);
});
it("Retry button re-fetches the registry after a generic error", async () => {
let attempt = 0;
getSpy.mockImplementation((path: string) => {
if (path === "/plugins") {
attempt++;
if (attempt === 1) return Promise.reject(new Error("server error"));
return Promise.resolve(REGISTRY);
}
if (path === `/workspaces/ws-1/plugins`) return Promise.resolve([]);
if (path === "/plugins/sources") return Promise.resolve({ schemes: ["local://"] });
return Promise.resolve(null);
});
render(<SkillsTab workspaceId="ws-1" data={makeData() as never} />);
// Expand the registry section to see the error div (showRegistry starts false).
await waitFor(() => {
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
});
const installBtn = screen.getByRole("button", { name: /\+ Install Plugin/i });
fireEvent.click(installBtn);
// Wait for error state to appear.
await screen.findByText(/server error/i);
// Click Retry — force=true bypasses the in-flight gate so the
// stranded promise from the first attempt is ignored.
const retryBtn = await screen.findByRole("button", { name: /retry/i });
fireEvent.click(retryBtn);
// After retry succeeds, registry plugins appear.
await screen.findByText("browser-automation");
});
});
// ─── Source-schemes tests ────────────────────────────────────────────────────
describe("SkillsTab source schemes", () => {
it("loads source schemes from GET /plugins/sources without crashing", async () => {
getSpy.mockImplementation((path: string) => {
if (path === "/plugins") return Promise.resolve(REGISTRY);
if (path === `/workspaces/ws-1/plugins`) return Promise.resolve([]);
if (path === "/plugins/sources") return Promise.resolve({ schemes: ["local://", "github://"] });
return Promise.resolve(null);
});
render(<SkillsTab workspaceId="ws-1" data={makeData() as never} />);
await screen.findByText("browser-automation");
expect(getSpy).toHaveBeenCalledWith("/plugins/sources");
});
it("gracefully falls back when GET /plugins/sources fails", async () => {
// /plugins/sources rejects (non-fatal); /plugins and /workspaces/:id/plugins succeed.
getSpy.mockImplementation((path: string) => {
if (path === "/plugins") return Promise.resolve(REGISTRY);
if (path === `/workspaces/ws-1/plugins`) return Promise.resolve([]);
if (path === "/plugins/sources") return Promise.reject(new Error("server error"));
return Promise.resolve(null);
});
// Must not throw — the component catches this and falls back silently.
expect(() => render(<SkillsTab workspaceId="ws-1" data={makeData() as never} />))
.not.toThrow();
// The rest of the UI still works — registry loaded despite sources failure.
await screen.findByText("browser-automation");
});
});
@@ -1,7 +1,16 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// TestExtractExpiresInSeconds covers the JSON parser used at enqueue time
@@ -58,3 +67,597 @@ func TestExtractExpiresInSeconds(t *testing.T) {
})
}
}
// ─── QueueDepth ─────────────────────────────────────────────────────────────
// TestQueueDepth_Success verifies QueueDepth returns the COUNT of queued items
// for a workspace.
func TestQueueDepth_Success(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM a2a_queue WHERE workspace_id = \$1 AND status = 'queued'`).
WithArgs("ws-queue-depth-1").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(7))
got := QueueDepth(context.Background(), "ws-queue-depth-1")
if got != 7 {
t.Errorf("QueueDepth() = %d; want 7", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
// TestQueueDepth_EmptyQueue returns 0 when no queued items exist.
func TestQueueDepth_EmptyQueue(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM a2a_queue WHERE workspace_id = \$1 AND status = 'queued'`).
WithArgs("ws-empty").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
got := QueueDepth(context.Background(), "ws-empty")
if got != 0 {
t.Errorf("QueueDepth() = %d; want 0", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
// TestQueueDepth_QueryError returns 0 on DB error (non-fatal; caller only uses
// the count for display purposes).
func TestQueueDepth_QueryError_ReturnsZero(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM a2a_queue WHERE workspace_id = \$1 AND status = 'queued'`).
WithArgs("ws-err").
WillReturnError(errors.New("connection refused"))
// QueueDepth swallows the error and returns 0.
got := QueueDepth(context.Background(), "ws-err")
if got != 0 {
t.Errorf("QueueDepth() on error = %d; want 0", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
// ─── QueueStatusByID ────────────────────────────────────────────────────────
// TestQueueStatusByID_Success verifies QueueStatusByID returns a fully-populated
// QueueStatus from the LEFT JOIN of a2a_queue and activity_logs.
func TestQueueStatusByID_Success(t *testing.T) {
mock := setupTestDB(t)
// The LEFT JOIN query returns all queue columns + NULL for activity_logs
// when no delegation row exists.
mock.ExpectQuery(`SELECT\s+q\.id,\s+q\.workspace_id,\s+q\.status,\s+q\.priority,\s+q\.attempts,\s+q\.last_error,\s+q\.enqueued_at::text,\s+q\.dispatched_at::text,\s+q\.completed_at::text,\s+q\.expires_at::text,\s+al\.response_body::text\s+FROM a2a_queue q\s+LEFT JOIN activity_logs al`).
WithArgs("queue-ok-1").
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "status", "priority", "attempts",
"last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at",
"response_body",
}).AddRow(
"queue-ok-1", "ws-1", "queued", 50, 1,
nil, "2026-05-16T10:00:00Z", nil, nil, "2026-05-16T12:00:00Z",
nil,
))
qs, err := QueueStatusByID(context.Background(), "queue-ok-1")
if err != nil {
t.Fatalf("QueueStatusByID() error = %v; want nil", err)
}
if qs.ID != "queue-ok-1" {
t.Errorf("ID = %q; want queue-ok-1", qs.ID)
}
if qs.WorkspaceID != "ws-1" {
t.Errorf("WorkspaceID = %q; want ws-1", qs.WorkspaceID)
}
if qs.Status != "queued" {
t.Errorf("Status = %q; want queued", qs.Status)
}
if qs.Priority != 50 {
t.Errorf("Priority = %d; want 50", qs.Priority)
}
if qs.Attempts != 1 {
t.Errorf("Attempts = %d; want 1", qs.Attempts)
}
if qs.LastError != nil {
t.Errorf("LastError = %v; want nil", qs.LastError)
}
if qs.EnqueuedAt != "2026-05-16T10:00:00Z" {
t.Errorf("EnqueuedAt = %q; want 2026-05-16T10:00:00Z", qs.EnqueuedAt)
}
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 != "2026-05-16T12:00:00Z" {
t.Errorf("ExpiresAt = %v; want 2026-05-16T12:00:00Z", qs.ExpiresAt)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
// TestQueueStatusByID_CompletedWithResponse verifies that a completed queue item
// populates ResponseBody from the LEFT JOINed activity_logs row.
func TestQueueStatusByID_CompletedWithResponse(t *testing.T) {
mock := setupTestDB(t)
respBody := `{"result":"done"}`
mock.ExpectQuery(`SELECT\s+q\.id`).
WithArgs("queue-done-1").
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "status", "priority", "attempts",
"last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at",
"response_body",
}).AddRow(
"queue-done-1", "ws-1", "completed", 50, 1,
nil, "2026-05-16T10:00:00Z", "2026-05-16T10:01:00Z", "2026-05-16T10:02:00Z", nil,
respBody,
))
qs, err := QueueStatusByID(context.Background(), "queue-done-1")
if err != nil {
t.Fatalf("QueueStatusByID() error = %v; want nil", err)
}
if qs.Status != "completed" {
t.Errorf("Status = %q; want completed", qs.Status)
}
if qs.ResponseBody == nil {
t.Fatal("ResponseBody = nil; want non-nil for completed item")
}
var resp map[string]interface{}
if err := json.Unmarshal(qs.ResponseBody, &resp); err != nil {
t.Fatalf("ResponseBody not valid JSON: %v", err)
}
if resp["result"] != "done" {
t.Errorf("ResponseBody result = %v; want done", resp["result"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
// TestQueueStatusByID_ErrNoRows returns sql.ErrNoRows when the queue ID doesn't exist.
func TestQueueStatusByID_ErrNoRows(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT\s+q\.id`).
WithArgs("queue-missing").
WillReturnError(sql.ErrNoRows)
_, err := QueueStatusByID(context.Background(), "queue-missing")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("QueueStatusByID() error = %v; want sql.ErrNoRows", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
// TestQueueStatusByID_QueryError propagates DB errors as-is.
func TestQueueStatusByID_QueryError(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT\s+q\.id`).
WithArgs("queue-err").
WillReturnError(errors.New("connection refused"))
_, err := QueueStatusByID(context.Background(), "queue-err")
if err == nil {
t.Fatal("QueueStatusByID() error = nil; want non-nil")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
// ─── GetA2AQueueStatus (HTTP handler) ─────────────────────────────────────
func newGetA2AQueueStatusHarness(t *testing.T) (sqlmock.Sqlmock, *httptest.ResponseRecorder, *gin.Context) {
mock := setupTestDB(t)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
return mock, w, c
}
func TestGetA2AQueueStatus_MissingQueueID_Returns400(t *testing.T) {
_, w, c := newGetA2AQueueStatusHarness(t)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: ""}}
c.Request = httptest.NewRequest("GET", "/", nil)
h := newHandlerWithTestDeps(t)
h.GetA2AQueueStatus(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetA2AQueueStatus_NoIdentity_Returns404(t *testing.T) {
_, w, c := newGetA2AQueueStatusHarness(t)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-123"}}
c.Request = httptest.NewRequest("GET", "/", nil)
h := newHandlerWithTestDeps(t)
h.GetA2AQueueStatus(c)
// Returns 404 (not 401) per the existence-non-inference policy.
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetA2AQueueStatus_QueueNotFound_Returns404(t *testing.T) {
mock, w, c := newGetA2AQueueStatusHarness(t)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-404"}}
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("X-Workspace-ID", "ws-1")
mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`).
WithArgs("q-404").
WillReturnError(sql.ErrNoRows)
h := newHandlerWithTestDeps(t)
h.GetA2AQueueStatus(c)
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: %v", err)
}
}
func TestGetA2AQueueStatus_UnauthorizedCaller_Returns404(t *testing.T) {
mock, w, c := newGetA2AQueueStatusHarness(t)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-unauth"}}
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("X-Workspace-ID", "ws-wrong")
mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`).
WithArgs("q-unauth").
WillReturnRows(sqlmock.NewRows([]string{"caller_id", "workspace_id"}).
AddRow("ws-caller-a", "ws-target-b"))
h := newHandlerWithTestDeps(t)
h.GetA2AQueueStatus(c)
// Returns 404 per the existence-non-inference policy.
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: %v", err)
}
}
func TestGetA2AQueueStatus_AuthorizedAsTarget_Success(t *testing.T) {
mock, w, c := newGetA2AQueueStatusHarness(t)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-ok"}}
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("X-Workspace-ID", "ws-target")
mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`).
WithArgs("q-ok").
WillReturnRows(sqlmock.NewRows([]string{"caller_id", "workspace_id"}).
AddRow("ws-caller", "ws-target"))
mock.ExpectQuery(`SELECT\s+q\.id`).
WithArgs("q-ok").
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "status", "priority", "attempts",
"last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at",
"response_body",
}).AddRow(
"q-ok", "ws-target", "queued", 50, 1,
nil, "2026-05-16T10:00:00Z", nil, nil, nil,
nil,
))
h := newHandlerWithTestDeps(t)
h.GetA2AQueueStatus(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var qs QueueStatus
if err := json.Unmarshal(w.Body.Bytes(), &qs); err != nil {
t.Fatalf("body parse: %v", err)
}
if qs.ID != "q-ok" {
t.Errorf("queue_id = %q; want q-ok", qs.ID)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
func TestGetA2AQueueStatus_QueueRowLookupError_Returns500(t *testing.T) {
mock, w, c := newGetA2AQueueStatusHarness(t)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-lookup-err"}}
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("X-Workspace-ID", "ws-1")
mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`).
WithArgs("q-lookup-err").
WillReturnError(errors.New("connection refused"))
h := newHandlerWithTestDeps(t)
h.GetA2AQueueStatus(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
func TestGetA2AQueueStatus_StatusFetchError_Returns500(t *testing.T) {
mock, w, c := newGetA2AQueueStatusHarness(t)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-status-err"}}
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("X-Workspace-ID", "ws-1")
mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`).
WithArgs("q-status-err").
WillReturnRows(sqlmock.NewRows([]string{"caller_id", "workspace_id"}).
AddRow("ws-1", "ws-1"))
mock.ExpectQuery(`SELECT\s+q\.id`).
WithArgs("q-status-err").
WillReturnError(errors.New("connection refused"))
h := newHandlerWithTestDeps(t)
h.GetA2AQueueStatus(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
// ─── queueRowAuthFields (internal helper) ─────────────────────────────────────
// Covers the auth-only 2-col SELECT used by GetA2AQueueStatus to determine
// whether the caller has access before projecting the public status fields.
func TestQueueRowAuthFields_Success_BothPresent(t *testing.T) {
mock := setupTestDB(t)
queueID := "qqqqqqqq-0003-0003-0003-000000000003"
rows := sqlmock.NewRows([]string{"caller_id", "workspace_id"}).
AddRow("ws-caller-3", "ws-target-3")
mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`).
WithArgs(queueID).
WillReturnRows(rows)
callerID, workspaceID, err := queueRowAuthFields(context.Background(), queueID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if callerID != "ws-caller-3" {
t.Errorf("callerID = %q, want %q", callerID, "ws-caller-3")
}
if workspaceID != "ws-target-3" {
t.Errorf("workspaceID = %q, want %q", workspaceID, "ws-target-3")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestQueueRowAuthFields_NoRows_ReturnsErrNoRows(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`).
WithArgs("qqqqqqqq-missing").
WillReturnError(sql.ErrNoRows)
_, _, err := queueRowAuthFields(context.Background(), "qqqqqqqq-missing")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("expected sql.ErrNoRows, got %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestQueueRowAuthFields_QueryError_ReturnsError(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`).
WithArgs("qqqqqqqq-dberr").
WillReturnError(sql.ErrConnDone)
_, _, err := queueRowAuthFields(context.Background(), "qqqqqqqq-dberr")
if err == nil {
t.Fatal("expected error, got nil")
}
if errors.Is(err, sql.ErrNoRows) {
t.Error("expected non-no-rows error, got sql.ErrNoRows")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ─── Additional GetA2AQueueStatus coverage ─────────────────────────────────────
// TestGetA2AQueueStatus_AuthPass_CallerMatchesCallerID verifies that a caller
// whose workspace matches queue.caller_id (not just workspace_id) passes auth
// and receives the status. This path is distinct from the existing "authorized
// as target" test which covers workspace_id = caller.
func TestGetA2AQueueStatus_AuthPass_CallerMatchesCallerID(t *testing.T) {
mock, w, c := newGetA2AQueueStatusHarness(t)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-caller-match"}}
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("X-Workspace-ID", "ws-caller-match")
// Queue row: ws-caller-match is the caller, ws-other-target is the target.
mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`).
WithArgs("q-caller-match").
WillReturnRows(sqlmock.NewRows([]string{"caller_id", "workspace_id"}).
AddRow("ws-caller-match", "ws-other-target"))
mock.ExpectQuery(`SELECT\s+q\.id`).
WithArgs("q-caller-match").
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "status", "priority", "attempts",
"last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at",
"response_body",
}).AddRow(
"q-caller-match", "ws-other-target", "queued", 50, 0,
nil, "2026-05-16T10:00:00Z", nil, nil, nil,
nil,
))
h := newHandlerWithTestDeps(t)
h.GetA2AQueueStatus(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var qs QueueStatus
json.Unmarshal(w.Body.Bytes(), &qs)
if qs.ID != "q-caller-match" {
t.Errorf("queue_id = %q; want q-caller-match", qs.ID)
}
if qs.Status != "queued" {
t.Errorf("status = %q; want queued", qs.Status)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
// TestGetA2AQueueStatus_AuthPass_OrgTokenBypassesAuth verifies that an org-level
// token (canvas/admin) bypasses the caller_id / workspace_id match entirely.
// No X-Workspace-ID header is required; org_token_id in context is sufficient.
func TestGetA2AQueueStatus_AuthPass_OrgTokenBypassesAuth(t *testing.T) {
mock, w, c := newGetA2AQueueStatusHarness(t)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-org-bypass"}}
c.Request = httptest.NewRequest("GET", "/", nil)
// No X-Workspace-ID header — org token is set via context instead.
c.Set("org_token_id", "org-admin-1")
mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`).
WithArgs("q-org-bypass").
WillReturnRows(sqlmock.NewRows([]string{"caller_id", "workspace_id"}).
AddRow("ws-anyone", "ws-anyone"))
mock.ExpectQuery(`SELECT\s+q\.id`).
WithArgs("q-org-bypass").
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "status", "priority", "attempts",
"last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at",
"response_body",
}).AddRow(
"q-org-bypass", "ws-anyone", "queued", 25, 0,
nil, "2026-05-16T10:00:00Z", nil, nil, nil,
nil,
))
h := newHandlerWithTestDeps(t)
h.GetA2AQueueStatus(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: %v", err)
}
}
// TestGetA2AQueueStatus_StatusQueryNoRows_NotFound covers the theoretical race:
// queue row exists (auth check passes), but is deleted before QueueStatusByID runs.
// Handler returns 404 (not 500) — matching the existence-non-inference policy.
func TestGetA2AQueueStatus_StatusQueryNoRows_NotFound(t *testing.T) {
mock, w, c := newGetA2AQueueStatusHarness(t)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-race-no-rows"}}
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("X-Workspace-ID", "ws-caller")
mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`).
WithArgs("q-race-no-rows").
WillReturnRows(sqlmock.NewRows([]string{"caller_id", "workspace_id"}).
AddRow("ws-caller", "ws-target"))
// Status query returns no rows — row was deleted between auth check and status fetch.
mock.ExpectQuery(`SELECT\s+q\.id`).
WithArgs("q-race-no-rows").
WillReturnError(sql.ErrNoRows)
h := newHandlerWithTestDeps(t)
h.GetA2AQueueStatus(c)
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: %v", err)
}
}
// TestGetA2AQueueStatus_ResponseBodyIncludedWhenCompleted confirms that a completed
// queue item surfaces response_body from activity_logs in the HTTP response body.
func TestGetA2AQueueStatus_ResponseBodyIncludedWhenCompleted(t *testing.T) {
mock, w, c := newGetA2AQueueStatusHarness(t)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-completed-body"}}
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("X-Workspace-ID", "ws-caller")
mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`).
WithArgs("q-completed-body").
WillReturnRows(sqlmock.NewRows([]string{"caller_id", "workspace_id"}).
AddRow("ws-caller", "ws-target"))
respBody := `{"result":{"status":"ok","reply":"hello world"}}`
mock.ExpectQuery(`SELECT\s+q\.id`).
WithArgs("q-completed-body").
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "status", "priority", "attempts",
"last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at",
"response_body",
}).AddRow(
"q-completed-body", "ws-target", "completed", 50, 1,
nil, "2026-05-16T10:00:00Z", "2026-05-16T10:01:00Z", "2026-05-16T10:02:00Z", nil,
respBody,
))
h := newHandlerWithTestDeps(t)
h.GetA2AQueueStatus(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var qs QueueStatus
json.Unmarshal(w.Body.Bytes(), &qs)
if qs.ResponseBody == nil {
t.Fatal("ResponseBody should be set for completed status")
}
if string(qs.ResponseBody) != respBody {
t.Errorf("ResponseBody = %q, want %q", string(qs.ResponseBody), respBody)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
@@ -156,3 +156,20 @@ func equalStrings(a, b []string) bool {
}
return true
}
// TestEmitOrgEvent_NilPayload exercises the `if payload == nil` branch that
// re-initializes payload to an empty map before marshaling.
func TestEmitOrgEvent_NilPayloadInitializesEmptyMap(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectExec(`INSERT INTO structure_events`).
WithArgs("org.import.started", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
// Passing nil triggers: if payload == nil { payload = map[string]any{} }
emitOrgEvent(context.Background(), "org.import.started", nil)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
@@ -214,7 +214,7 @@ func strDefault(m map[string]interface{}, key, fallback string) string {
// inputs. Transient daemon errors are logged distinctly so triage doesn't
// confuse a flaky daemon with a stopped container.
func (h *PluginsHandler) findRunningContainer(ctx context.Context, workspaceID string) string {
name, err := provisioner.RunningContainerNameFunc(ctx, h.docker, workspaceID)
name, err := provisioner.RunningContainerName(ctx, h.docker, workspaceID)
if err != nil {
log.Printf("plugins: docker inspect transient error for %s: %v (treating as not-running for this request)", workspaceID, err)
return ""
@@ -66,11 +66,9 @@ func TestFindRunningContainer_RoutesThroughProvisionerSSOT(t *testing.T) {
if !ok {
return true
}
// Pkg.Func form: provisioner.RunningContainerNameFunc(...)
// Uses the pluggable wrapper, not the raw function — tests swap the
// wrapper so ListInstalled can be tested without a real Docker client.
// Pkg.Func form: provisioner.RunningContainerName(...)
if pkgIdent, ok := sel.X.(*ast.Ident); ok {
if pkgIdent.Name == "provisioner" && sel.Sel.Name == "RunningContainerNameFunc" {
if pkgIdent.Name == "provisioner" && sel.Sel.Name == "RunningContainerName" {
callsRunningContainerName = true
}
}
@@ -85,7 +83,7 @@ func TestFindRunningContainer_RoutesThroughProvisionerSSOT(t *testing.T) {
if !callsRunningContainerName {
t.Errorf(
"findRunningContainer must call provisioner.RunningContainerNameFunc for the SSOT inspect — see molecule-core#10. Found no such call.",
"findRunningContainer must call provisioner.RunningContainerName for the SSOT inspect — see molecule-core#10. Found no such call.",
)
}
if callsContainerInspectRaw {
@@ -1,346 +0,0 @@
package handlers
// plugins_listing_test.go — coverage for plugins_listing.go.
//
// Covered handlers:
// - ListRegistry GET /plugins
// - ListAvailableForWorkspace GET /workspaces/:id/plugins/available
// - ListInstalled GET /workspaces/:id/plugins
// - CheckRuntimeCompatibility GET /workspaces/:id/plugins/compatibility?runtime=<name>
//
// The Docker client is NOT mocked directly. Instead, the package-level
// provisioner.RunningContainerNameFunc is swapped via provisioner.StubRunningContainerName.
// This mirrors the existing stubInstallPluginViaEIC pattern and avoids needing
// to implement the full docker.APIClient interface.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/docker/docker/client"
"github.com/gin-gonic/gin"
)
// ─── helpers ──────────────────────────────────────────────────────────────────
// stageRegistry creates a plugin dir under tmpDir with a plugin.yaml.
func stageRegistry(t *testing.T, tmpDir, name, manifestYAML string) {
t.Helper()
plugDir := filepath.Join(tmpDir, name)
if err := os.Mkdir(plugDir, 0755); err != nil {
t.Fatalf("mkdir plugin dir: %v", err)
}
if err := os.WriteFile(filepath.Join(plugDir, "plugin.yaml"), []byte(manifestYAML), 0644); err != nil {
t.Fatalf("write plugin.yaml: %v", err)
}
}
// stubContainerRunning stubs RunningContainerNameFunc so findRunningContainer
// returns the given container name (empty string = container not running).
func stubContainerRunning(t *testing.T, name string) {
t.Helper()
provisioner.StubRunningContainerName(t,
func(ctx context.Context, cli *client.Client, workspaceID string) (string, error) {
return name, nil
},
)
}
// ─── ListRegistry ──────────────────────────────────────────────────────────────
func TestListRegistry_EmptyDir_ReturnsEmptyArray(t *testing.T) {
tmpDir := t.TempDir()
h := NewPluginsHandler(tmpDir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins", nil)
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 0 {
t.Fatalf("expected empty array, got %d plugins", len(plugins))
}
}
func TestListRegistry_WithPlugins_ReturnsPluginInfo(t *testing.T) {
tmpDir := t.TempDir()
stageRegistry(t, tmpDir, "browser-automation",
"name: browser-automation\nversion: \"1.2.0\"\ndescription: Browser automation\nauthor: molecule\ntags:\n - browser\n - playwright\nskills:\n - automates-ui\nruntimes:\n - claude_code\n")
h := NewPluginsHandler(tmpDir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins", nil)
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
if plugins[0]["name"] != "browser-automation" {
t.Errorf("unexpected plugin: %v", plugins[0])
}
if plugins[0]["version"] != "1.2.0" {
t.Errorf("expected version 1.2.0, got %v", plugins[0]["version"])
}
}
func TestListRegistry_RuntimeFilter_IncludesUnspecifiedPlugin(t *testing.T) {
tmpDir := t.TempDir()
// Plugin without runtimes field — treated as "unspecified, try it".
stageRegistry(t, tmpDir, "generic-tool",
"name: generic-tool\nversion: \"1.0.0\"\ndescription: Works everywhere\n")
h := NewPluginsHandler(tmpDir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins?runtime=claude_code", nil)
h.ListRegistry(c)
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 1 || plugins[0]["name"] != "generic-tool" {
t.Errorf("expected [generic-tool], got %v", plugins)
}
}
func TestListRegistry_RuntimeFilter_ExcludesIncompatiblePlugin(t *testing.T) {
tmpDir := t.TempDir()
stageRegistry(t, tmpDir, "hermes-only",
"name: hermes-only\nversion: \"1.0.0\"\nruntimes:\n - hermes\n")
h := NewPluginsHandler(tmpDir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins?runtime=claude_code", nil)
h.ListRegistry(c)
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 0 {
t.Errorf("expected 0 plugins (hermes-only filtered out), got %d", len(plugins))
}
}
func TestListRegistry_RuntimeFilter_NormalizesHyphen(t *testing.T) {
tmpDir := t.TempDir()
stageRegistry(t, tmpDir, "tool-x",
"name: tool-x\nversion: \"1.0.0\"\nruntimes:\n - claude-code\n")
h := NewPluginsHandler(tmpDir, nil, nil)
// Query uses "claude_code" (underscore); manifest uses "claude-code" (hyphen).
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins?runtime=claude_code", nil)
h.ListRegistry(c)
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 1 {
t.Errorf("expected 1 plugin (hyphen/underscore normalised), got %d", len(plugins))
}
}
func TestListRegistry_MultipleRuntimes_PluginIncludedForEach(t *testing.T) {
tmpDir := t.TempDir()
stageRegistry(t, tmpDir, "multi-tool",
"name: multi-tool\nversion: \"1.0.0\"\nruntimes:\n - claude_code\n - hermes\n")
h := NewPluginsHandler(tmpDir, nil, nil)
for _, runtime := range []string{"claude_code", "hermes"} {
t.Run(runtime, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins?runtime="+runtime, nil)
h.ListRegistry(c)
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 1 {
t.Errorf("expected 1 plugin for runtime %s, got %d", runtime, len(plugins))
}
})
}
}
// ─── ListAvailableForWorkspace ────────────────────────────────────────────────
func TestListAvailableForWorkspace_NoRuntimeLookup_ReturnsUnfiltered(t *testing.T) {
tmpDir := t.TempDir()
stageRegistry(t, tmpDir, "tool-a", "name: tool-a\nruntimes:\n - claude_code\n")
stageRegistry(t, tmpDir, "tool-b", "name: tool-b\nruntimes:\n - hermes\n")
h := NewPluginsHandler(tmpDir, nil, nil)
// No runtimeLookup → unfiltered registry
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-any"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-any/plugins/available", nil)
h.ListAvailableForWorkspace(c)
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 2 {
t.Errorf("expected 2 plugins (unfiltered), got %d", len(plugins))
}
}
func TestListAvailableForWorkspace_WithRuntimeLookup_FiltersByRuntime(t *testing.T) {
tmpDir := t.TempDir()
stageRegistry(t, tmpDir, "tool-claude", "name: tool-claude\nruntimes:\n - claude_code\n")
stageRegistry(t, tmpDir, "tool-hermes", "name: tool-hermes\nruntimes:\n - hermes\n")
h := NewPluginsHandler(tmpDir, nil, nil).WithRuntimeLookup(
func(workspaceID string) (string, error) { return "claude_code", nil },
)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-claude"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-claude/plugins/available", nil)
h.ListAvailableForWorkspace(c)
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 1 || plugins[0]["name"] != "tool-claude" {
t.Errorf("expected [tool-claude], got %v", plugins)
}
}
func TestListAvailableForWorkspace_RuntimeLookupError_FallsBackToUnfiltered(t *testing.T) {
tmpDir := t.TempDir()
stageRegistry(t, tmpDir, "tool-any", "name: tool-any\nruntimes:\n - claude_code\n")
h := NewPluginsHandler(tmpDir, nil, nil).WithRuntimeLookup(
func(workspaceID string) (string, error) { return "", fmt.Errorf("db unavailable") },
)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-err"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-err/plugins/available", nil)
h.ListAvailableForWorkspace(c)
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
// Falls back to unfiltered (empty string runtime = include all)
if len(plugins) != 1 {
t.Errorf("expected 1 plugin (unfiltered fallback), got %d", len(plugins))
}
}
// ─── ListInstalled ──────────────────────────────────────────────────────────────
func TestListInstalled_ContainerNotRunning_ReturnsEmpty(t *testing.T) {
tmpDir := t.TempDir()
// Stub RunningContainerName to return "" (container not running).
stubContainerRunning(t, "")
h := NewPluginsHandler(tmpDir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-stopped"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-stopped/plugins", nil)
h.ListInstalled(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 0 {
t.Errorf("expected empty array (container not running), got %d plugins", len(plugins))
}
}
// ─── CheckRuntimeCompatibility ────────────────────────────────────────────────
func TestCheckRuntimeCompatibility_MissingRuntimeParam_Returns400(t *testing.T) {
tmpDir := t.TempDir()
h := NewPluginsHandler(tmpDir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-x"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-x/plugins/compatibility", nil)
h.CheckRuntimeCompatibility(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
if !bytes.Contains(w.Body.Bytes(), []byte("runtime")) {
t.Errorf("expected 'runtime' in error body, got: %s", w.Body.String())
}
}
func TestCheckRuntimeCompatibility_ContainerNotRunning_ReturnsAllCompatible(t *testing.T) {
tmpDir := t.TempDir()
stubContainerRunning(t, "") // container not running
h := NewPluginsHandler(tmpDir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-stopped"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-stopped/plugins/compatibility?runtime=claude_code", nil)
h.CheckRuntimeCompatibility(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if resp["all_compatible"] != true {
t.Errorf("expected all_compatible=true (no container), got %v", resp["all_compatible"])
}
}
@@ -1,76 +0,0 @@
package handlers
// plugins_sources_test.go — coverage for plugins_sources.go (ListSources).
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
"github.com/gin-gonic/gin"
)
// stubPluginSources implements pluginSources for test purposes.
type stubPluginSources struct {
schemes []string
}
func (s *stubPluginSources) Register(resolver plugins.SourceResolver) {}
func (s *stubPluginSources) Resolve(source plugins.Source) (plugins.SourceResolver, error) {
return nil, nil
}
func (s *stubPluginSources) Schemes() []string { return s.schemes }
// TestListSources_ReturnsSchemes verifies the endpoint returns whatever the
// source registry reports — the handler itself is a thin passthrough.
func TestListSources_ReturnsSchemes(t *testing.T) {
// Build a PluginsHandler with a stub source registry.
h := &PluginsHandler{sources: &stubPluginSources{
schemes: []string{"local://", "github://", "clawhub://"},
}}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins/sources", nil)
h.ListSources(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
schemes, ok := resp["schemes"].([]any)
if !ok {
t.Fatalf("expected 'schemes' array, got %T", resp["schemes"])
}
if len(schemes) != 3 {
t.Errorf("expected 3 schemes, got %d: %v", len(schemes), schemes)
}
}
func TestListSources_EmptyRegistry_ReturnsEmptyArray(t *testing.T) {
h := &PluginsHandler{sources: &stubPluginSources{schemes: []string{}}}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins/sources", nil)
h.ListSources(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
schemes := resp["schemes"].([]any)
if len(schemes) != 0 {
t.Errorf("expected empty schemes, got %v", schemes)
}
}
@@ -1,32 +0,0 @@
package provisioner
// provisioner_stub.go — test stub for RunningContainerName.
//
// RunningContainerNameFunc is a package-level variable pointing to the
// real RunningContainerName. Tests in other packages (handlers/) swap it
// via StubRunningContainerName so ListInstalled and CheckRuntimeCompatibility
// can be tested without a real Docker client.
//
// This file intentionally does NOT import "testing" — it ships in the
// production binary so the handlers package can call it.
import (
"context"
"testing"
"github.com/docker/docker/client"
)
// RunningContainerNameFunc is the pluggable entry point used by
// PluginsHandler.findRunningContainer. Defaults to RunningContainerName;
// swapped via StubRunningContainerName in tests.
var RunningContainerNameFunc = RunningContainerName
// StubRunningContainerName swaps RunningContainerNameFunc for the duration
// of a test; restored by t.Cleanup.
func StubRunningContainerName(t *testing.T, fn func(context.Context, *client.Client, string) (string, error)) {
t.Helper()
prev := RunningContainerNameFunc
RunningContainerNameFunc = fn
t.Cleanup(func() { RunningContainerNameFunc = prev })
}