Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4713a0725 |
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
/** Org-wide broadcast banner.
|
||||
*
|
||||
* Rendered at the top of the canvas (below the toolbar) whenever the store
|
||||
* holds one or more unread BROADCAST_MESSAGE entries. Each entry shows:
|
||||
* - sender name (workspace that issued the broadcast)
|
||||
* - the message text
|
||||
* - a dismiss button
|
||||
*
|
||||
* Dismissing an entry removes it from the store via consumeBroadcastMessages.
|
||||
* The dismissed state is intentionally ephemeral — dismissed broadcasts reappear
|
||||
* on page refresh since they are not persisted server-side; this is intentional
|
||||
* (the platform's activity log already provides the audit trail).
|
||||
*/
|
||||
export function BroadcastBanner() {
|
||||
const broadcastMessages = useCanvasStore((s) => s.broadcastMessages);
|
||||
const consumeBroadcastMessages = useCanvasStore((s) => s.consumeBroadcastMessages);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
void consumeBroadcastMessages();
|
||||
}, [consumeBroadcastMessages]);
|
||||
|
||||
if (broadcastMessages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-16 left-1/2 -translate-x-1/2 z-30 flex flex-col gap-2 items-center w-full max-w-xl px-4 pointer-events-none">
|
||||
{broadcastMessages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="pointer-events-auto w-full bg-blue-950/80 backdrop-blur-md border border-blue-700/50 rounded-xl px-5 py-3 shadow-2xl shadow-black/40 animate-in slide-in-from-top duration-300"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Megaphone icon */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="w-7 h-7 rounded-lg bg-blue-900/50 flex items-center justify-center shrink-0 mt-0.5"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-blue-300"
|
||||
>
|
||||
<path d="M3 11l18-5v12L3 13v-2z" />
|
||||
<path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-blue-300 font-semibold">
|
||||
Broadcast from{" "}
|
||||
<span className="text-blue-100">{msg.sender}</span>
|
||||
</div>
|
||||
<div className="text-sm text-blue-50 mt-0.5 leading-snug break-words">
|
||||
{msg.message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss broadcast"
|
||||
className="shrink-0 w-6 h-6 rounded text-blue-400 hover:text-blue-200 hover:bg-blue-800/50 flex items-center justify-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-1 focus-visible:ring-offset-blue-950"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { CreateWorkspaceButton } from "./CreateWorkspaceDialog";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { TemplatePalette } from "./TemplatePalette";
|
||||
import { ApprovalBanner } from "./ApprovalBanner";
|
||||
import { BroadcastBanner } from "./BroadcastBanner";
|
||||
import { BundleDropZone } from "./BundleDropZone";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { OnboardingWizard } from "./OnboardingWizard";
|
||||
@@ -367,6 +368,7 @@ function CanvasInner() {
|
||||
<OnboardingWizard />
|
||||
<Toolbar />
|
||||
<ApprovalBanner />
|
||||
<BroadcastBanner />
|
||||
<BundleDropZone />
|
||||
<TemplatePalette />
|
||||
<SidePanel />
|
||||
|
||||
@@ -73,6 +73,8 @@ const mockStoreState = {
|
||||
clearSelection: vi.fn(),
|
||||
toggleNodeSelection: vi.fn(),
|
||||
deletingIds: new Set<string>(),
|
||||
broadcastMessages: [],
|
||||
consumeBroadcastMessages: vi.fn(() => []),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
@@ -100,6 +102,7 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
|
||||
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
|
||||
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
|
||||
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
|
||||
vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
|
||||
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
|
||||
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
|
||||
vi.mock("../settings", () => ({
|
||||
|
||||
@@ -91,6 +91,8 @@ const mockStoreState = {
|
||||
// an empty Set mirrors the idle canvas and doesn't interact with
|
||||
// any pan/fit behaviour under test here.
|
||||
deletingIds: new Set<string>(),
|
||||
broadcastMessages: [],
|
||||
consumeBroadcastMessages: vi.fn(() => []),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
@@ -117,6 +119,7 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
|
||||
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
|
||||
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
|
||||
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
|
||||
vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
|
||||
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
|
||||
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
|
||||
vi.mock("../settings", () => ({
|
||||
|
||||
@@ -53,9 +53,10 @@ function makeStore(
|
||||
edges: Edge[] = [],
|
||||
selectedNodeId: string | null = null,
|
||||
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string }>> = {},
|
||||
liveAnnouncement = ""
|
||||
liveAnnouncement = "",
|
||||
broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }> = []
|
||||
) {
|
||||
const state = { nodes, edges, selectedNodeId, agentMessages, liveAnnouncement };
|
||||
const state = { nodes, edges, selectedNodeId, agentMessages, liveAnnouncement, broadcastMessages };
|
||||
const get = () => state;
|
||||
const set = vi.fn((partial: Record<string, unknown>) => {
|
||||
Object.assign(state, partial);
|
||||
@@ -1013,3 +1014,149 @@ describe("handleCanvasEvent – liveAnnouncement", () => {
|
||||
expect(state.liveAnnouncement ?? "").toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BROADCAST_MESSAGE
|
||||
//
|
||||
// Verifies that incoming org-wide broadcast WebSocket events are captured
|
||||
// in the store's broadcastMessages array and announced via liveAnnouncement
|
||||
// for screen readers. The Go platform already HTML-escaped the content at
|
||||
// broadcast time (OFFSEC-015 fix), so the handler renders it as-is.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("handleCanvasEvent – BROADCAST_MESSAGE", () => {
|
||||
it("appends a broadcast message to broadcastMessages with correct fields", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
sender: "Ops Agent",
|
||||
message: "All systems go — deploy in 5 minutes",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
expect(set).toHaveBeenCalledOnce();
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages).toHaveLength(1);
|
||||
expect(next.broadcastMessages[0].senderId).toBe("ws-ops");
|
||||
expect(next.broadcastMessages[0].sender).toBe("Ops Agent");
|
||||
expect(next.broadcastMessages[0].message).toBe("All systems go — deploy in 5 minutes");
|
||||
expect(next.broadcastMessages[0].id).toBeTruthy(); // crypto.randomUUID() called
|
||||
expect(next.broadcastMessages[0].timestamp).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sets liveAnnouncement with sender and truncated message", () => {
|
||||
const { get, set } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
sender: "Ops Agent",
|
||||
message: "Deploy starting now",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { liveAnnouncement: string };
|
||||
expect(next.liveAnnouncement).toBe("Broadcast from Ops Agent: Deploy starting now");
|
||||
});
|
||||
|
||||
it("renders sender name as truncated ID when sender field is absent", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
message: "Deploy starting now",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages[0].sender).toBe("ws-ops".slice(0, 8)); // fallback: first 8 chars of ID
|
||||
});
|
||||
|
||||
it("is a no-op when message is empty string", () => {
|
||||
const { get, set } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: { sender_id: "ws-ops", sender: "Ops Agent", message: "" },
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appends to existing broadcastMessages without replacing them", () => {
|
||||
const { get, set, state } = makeStore([], [], null, {}, "", [
|
||||
{
|
||||
id: "existing-1",
|
||||
senderId: "ws-old",
|
||||
sender: "Old Agent",
|
||||
message: "Previous broadcast",
|
||||
timestamp: "2026-05-14T12:00:00Z",
|
||||
},
|
||||
]);
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: { sender_id: "ws-ops", sender: "Ops Agent", message: "New broadcast" },
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages).toHaveLength(2);
|
||||
expect(next.broadcastMessages[0].id).toBe("existing-1");
|
||||
expect(next.broadcastMessages[1].message).toBe("New broadcast");
|
||||
});
|
||||
|
||||
it("handles XSS-like content safely (content is pre-escaped by Go platform)", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
// The Go platform applied html.EscapeString before sending, so the handler
|
||||
// receives literal strings, not raw HTML. This test verifies no panic and
|
||||
// correct storage.
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-evil",
|
||||
payload: {
|
||||
sender_id: "ws-evil",
|
||||
sender: "Evil Sender",
|
||||
message: "<script>alert('xss')</script>",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages[0].message).toBe("<script>alert('xss')</script>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,7 @@ export function handleCanvasEvent(
|
||||
edges: Edge[];
|
||||
selectedNodeId: string | null;
|
||||
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string; attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }> }>>;
|
||||
broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
|
||||
},
|
||||
set: (partial: Record<string, unknown>) => void,
|
||||
): void {
|
||||
@@ -515,6 +516,34 @@ export function handleCanvasEvent(
|
||||
break;
|
||||
}
|
||||
|
||||
case "BROADCAST_MESSAGE": {
|
||||
// An agent workspace sent an org-wide broadcast. Display it as a
|
||||
// dismissible banner so the user is always aware of org-wide signals
|
||||
// even when no workspace is selected. The Go platform already HTML-
|
||||
// escaped the content at broadcast time (OFFSEC-015 fix), so it is
|
||||
// safe to render as innerText equivalent via dangerouslySetInnerHTML
|
||||
// is not needed — just render the string as-is.
|
||||
const senderId = (msg.payload.sender_id as string) ?? "";
|
||||
const sender = (msg.payload.sender as string) ?? senderId.slice(0, 8);
|
||||
const message = (msg.payload.message as string) ?? "";
|
||||
if (!message) break;
|
||||
const { broadcastMessages } = get();
|
||||
set({
|
||||
broadcastMessages: [
|
||||
...broadcastMessages,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
senderId,
|
||||
sender,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
liveAnnouncement: `Broadcast from ${sender}: ${message}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -244,6 +244,12 @@ interface CanvasState {
|
||||
* so the same announcement doesn't re-fire on re-render. */
|
||||
liveAnnouncement: string;
|
||||
setLiveAnnouncement: (msg: string) => void;
|
||||
/** Incoming org-wide broadcast messages received via BROADCAST_MESSAGE
|
||||
* WebSocket events. Consumed by the BroadcastBanner component; each
|
||||
* entry is cleared after the user dismisses it so dismissed broadcasts
|
||||
* don't reappear on reconnect. */
|
||||
broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
|
||||
consumeBroadcastMessages: () => Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
@@ -342,6 +348,12 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
},
|
||||
liveAnnouncement: "",
|
||||
setLiveAnnouncement: (msg) => set({ liveAnnouncement: msg }),
|
||||
broadcastMessages: [],
|
||||
consumeBroadcastMessages: () => {
|
||||
const msgs = get().broadcastMessages;
|
||||
set({ broadcastMessages: [] });
|
||||
return msgs;
|
||||
},
|
||||
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
|
||||
|
||||
@@ -15,10 +15,14 @@ This file pins:
|
||||
``a2a_tools`` at module-load time (the layered architecture: it
|
||||
depends on ``a2a_tools_rbac`` + ``a2a_client`` + ``platform_auth``,
|
||||
never the kitchen-sink module).
|
||||
3. **Behavioral coverage** for paths missing from test_a2a_tools_impl:
|
||||
tool_broadcast_message, _upload_chat_files error paths.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from io import BytesIO
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -30,6 +34,55 @@ def _require_workspace_id(monkeypatch):
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_mock_client(*, post_resp=None, post_exc=None):
|
||||
mc = AsyncMock()
|
||||
mc.__aenter__ = AsyncMock(return_value=mc)
|
||||
mc.__aexit__ = AsyncMock(return_value=False)
|
||||
if post_exc:
|
||||
mc.post = AsyncMock(side_effect=post_exc)
|
||||
elif post_resp:
|
||||
mc.post = AsyncMock(return_value=post_resp)
|
||||
return mc
|
||||
|
||||
|
||||
def _mock_resp(status_code, payload):
|
||||
r = MagicMock()
|
||||
r.status_code = status_code
|
||||
r.json = MagicMock(return_value=payload)
|
||||
r.text = ""
|
||||
return r
|
||||
|
||||
|
||||
class _FakeFile:
|
||||
"""Minimal file-like for mocking builtins.open."""
|
||||
|
||||
def __init__(self, data: bytes):
|
||||
self._buf = BytesIO(data)
|
||||
|
||||
def read(self, n: int = -1) -> bytes:
|
||||
return self._buf.read(n)
|
||||
|
||||
def readline(self, n: int = -1) -> bytes:
|
||||
return self._buf.readline(n)
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
def _make_fake_file(data: bytes) -> _FakeFile:
|
||||
return _FakeFile(data)
|
||||
|
||||
|
||||
# ============== Drift gate ==============
|
||||
|
||||
class TestBackCompatAliases:
|
||||
@@ -90,3 +143,175 @@ class TestImportContract:
|
||||
assert hasattr(a2a_tools, "tool_get_workspace_info")
|
||||
assert hasattr(a2a_tools, "tool_chat_history")
|
||||
assert hasattr(a2a_tools, "_upload_chat_files")
|
||||
|
||||
|
||||
# ============== Behavioral coverage: tool_broadcast_message ==============
|
||||
#
|
||||
# POST /workspaces/:id/broadcast — the agent-facing A2A broadcast primitive.
|
||||
# Distinct from TestToolSendMessageToUser in test_a2a_tools_impl.py which
|
||||
# tests the /notify path. These tests cover broadcast-specific return shapes
|
||||
# (delivered count, 403 hint, empty-message guard) that test_a2a_tools_impl
|
||||
# does not exercise.
|
||||
#
|
||||
# Patching note: httpx.AsyncClient is patched at "a2a_tools.httpx.AsyncClient"
|
||||
# (matching the established pattern in test_a2a_tools_impl.py). The broadcast
|
||||
# handler is called via a2a_tools (root module) to ensure the patch resolves
|
||||
# correctly through the module's namespace lookup chain.
|
||||
|
||||
import a2a_tools
|
||||
|
||||
|
||||
class TestToolBroadcastMessage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_message_returns_error(self):
|
||||
result = await a2a_tools.tool_broadcast_message("")
|
||||
assert "Error" in result
|
||||
assert "required" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitespace_only_message_is_rejected(self):
|
||||
# tool_broadcast_message does not strip input — a whitespace-only
|
||||
# message is truthy and proceeds to the HTTP call. The platform
|
||||
# returns 403 because the test workspace has broadcast disabled.
|
||||
# This test verifies the error surface is at least an Error response.
|
||||
result = await a2a_tools.tool_broadcast_message(" ")
|
||||
assert "Error" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_200_returns_delivered_count(self):
|
||||
mc = _make_mock_client(post_resp=_mock_resp(200, {"delivered": 5}))
|
||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
|
||||
result = await a2a_tools.tool_broadcast_message("Deploy in 5 minutes")
|
||||
assert "Broadcast sent to 5 workspace(s)" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_200_unknown_delivered_falls_back_to_question_mark(self):
|
||||
mc = _make_mock_client(post_resp=_mock_resp(200, {}))
|
||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
|
||||
result = await a2a_tools.tool_broadcast_message("Deploy in 5 minutes")
|
||||
assert "Broadcast sent to ? workspace(s)" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_403_broadcast_disabled_includes_hint(self):
|
||||
resp = _mock_resp(
|
||||
403,
|
||||
{"error": "broadcast_disabled", "hint": "Enable via PATCH /workspaces/:id/abilities"},
|
||||
)
|
||||
mc = _make_mock_client(post_resp=resp)
|
||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
|
||||
result = await a2a_tools.tool_broadcast_message("Deploy!")
|
||||
assert "Enable via PATCH" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_403_without_hint_does_not_append_hint_fragment(self):
|
||||
resp = _mock_resp(403, {"error": "forbidden"})
|
||||
mc = _make_mock_client(post_resp=resp)
|
||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
|
||||
result = await a2a_tools.tool_broadcast_message("Deploy!")
|
||||
assert "Error" in result
|
||||
assert "PATCH" not in result # no hint field to append
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_500_includes_status_code(self):
|
||||
mc = _make_mock_client(post_resp=_mock_resp(500, {"error": "internal"}))
|
||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
|
||||
result = await a2a_tools.tool_broadcast_message("Deploy!")
|
||||
assert "500" in result
|
||||
assert "Error" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_returns_error_message(self):
|
||||
mc = _make_mock_client(post_exc=OSError("connection refused"))
|
||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
|
||||
result = await a2a_tools.tool_broadcast_message("Deploy!")
|
||||
assert "Error sending broadcast" in result
|
||||
assert "connection refused" in result
|
||||
|
||||
|
||||
# ============== Behavioral coverage: _upload_chat_files error paths ==============
|
||||
#
|
||||
# _upload_chat_files is the shared helper used by tool_send_message_to_user.
|
||||
# These tests isolate the helper directly so every error branch is explicit.
|
||||
# test_a2a_tools_impl.py exercises the tool-level paths but these cover the
|
||||
# helper-level failure modes (OSError, non-JSON, wrong file count).
|
||||
|
||||
import a2a_tools_messaging as _m
|
||||
|
||||
|
||||
class TestUploadChatFiles:
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_file_returns_error(self):
|
||||
mc = _make_mock_client()
|
||||
result, err = await _m._upload_chat_files(mc, ["/no/such/path/file.bin"])
|
||||
assert result == []
|
||||
assert err is not None
|
||||
assert "not found" in err.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oserror_on_read_returns_error(self, monkeypatch):
|
||||
# Path passes existence check; open() raises OSError.
|
||||
monkeypatch.setattr("os.path.isfile", lambda p: True)
|
||||
mc = _make_mock_client()
|
||||
with patch("builtins.open", side_effect=OSError("Disk I/O error")):
|
||||
result, err = await _m._upload_chat_files(mc, ["/some/readable/path.bin"])
|
||||
assert result == []
|
||||
assert err is not None
|
||||
assert "Disk I/O error" in err
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_exception_returns_error(self, monkeypatch):
|
||||
monkeypatch.setattr("os.path.isfile", lambda p: True)
|
||||
monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"content"))
|
||||
mc = _make_mock_client(post_exc=OSError("network unreachable"))
|
||||
result, err = await _m._upload_chat_files(mc, ["/some/path.bin"])
|
||||
assert result == []
|
||||
assert err is not None
|
||||
assert "network unreachable" in err
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_non_200_returns_error(self, monkeypatch):
|
||||
monkeypatch.setattr("os.path.isfile", lambda p: True)
|
||||
monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"content"))
|
||||
mc = _make_mock_client(post_resp=_mock_resp(502, {"error": "bad gateway"}))
|
||||
result, err = await _m._upload_chat_files(mc, ["/some/path.bin"])
|
||||
assert result == []
|
||||
assert err is not None
|
||||
assert "502" in err
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_non_json_response_returns_error(self, monkeypatch):
|
||||
monkeypatch.setattr("os.path.isfile", lambda p: True)
|
||||
monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"not json"))
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json = MagicMock(side_effect=ValueError("Expecting value"))
|
||||
resp.text = "not json"
|
||||
mc = _make_mock_client(post_resp=resp)
|
||||
result, err = await _m._upload_chat_files(mc, ["/some/path.bin"])
|
||||
assert result == []
|
||||
assert err is not None
|
||||
assert "Error parsing upload response" in err
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_response_missing_files_key_returns_error(self, monkeypatch):
|
||||
monkeypatch.setattr("os.path.isfile", lambda p: True)
|
||||
monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"content"))
|
||||
mc = _make_mock_client(post_resp=_mock_resp(200, {})) # no "files" key
|
||||
result, err = await _m._upload_chat_files(mc, ["/some/path.bin"])
|
||||
assert result == []
|
||||
assert err is not None
|
||||
assert "Error" in err # body had no "files" key
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_response_file_count_mismatch_returns_error(self, monkeypatch):
|
||||
monkeypatch.setattr("os.path.isfile", lambda p: True)
|
||||
monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"content"))
|
||||
# Asked for 2 files, platform returned 1.
|
||||
mc = _make_mock_client(
|
||||
post_resp=_mock_resp(200, {"files": [{"uri": "x", "name": "a.bin"}]})
|
||||
)
|
||||
result, err = await _m._upload_chat_files(mc, ["/a.bin", "/b.bin"])
|
||||
assert result == []
|
||||
assert err is not None
|
||||
assert "1" in err # count mismatch appears in error message
|
||||
assert "2" in err
|
||||
|
||||
Reference in New Issue
Block a user