test: Add 126 tests for Phase 5 plugins + fix 2 source bugs
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
Tests (5 files, 126 tests, all passing): - test_agent_memory.py: 22 tests (store, retrieve, delete, routes, tenant isolation) - test_graph_rag.py: 22 tests (create, traverse BFS, bidirectional, max_hops, cycles, routes) - test_marketplace.py: 26 tests (fetch, download, verify, install, categories, routes) - test_agent_subtasks.py: 25 tests (create, wait, cancel, aggregate, list, model) - test_external_agent_api.py: 31 tests (run, status, stream, auth, rate limit) Bugfixes: - graph_rag/models.py: metadata -> meta (SQLAlchemy reserved attribute) - marketplace/routes.py: fix default parameter validation
This commit is contained in:
@@ -0,0 +1,624 @@
|
||||
"""Tests for the External Agent API — route layer.
|
||||
|
||||
Uses AsyncMock for all DB operations, stream_chat, and rate limiting.
|
||||
No real DB or LLM connections required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.plugins.builtins.ai_assistant.external_api import router as external_api_router
|
||||
from app.plugins.builtins.ai_assistant.models import AIAgent
|
||||
|
||||
|
||||
# Override conftest DB fixtures — these tests use mocks, no real DB needed
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def db_setup():
|
||||
"""No-op override of conftest db_setup."""
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_tables(db_setup):
|
||||
"""No-op override of conftest clean_tables."""
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_check_permission():
|
||||
"""Patch check_permission to always return True for route tests."""
|
||||
with patch("app.core.permissions.check_permission", return_value=True):
|
||||
yield
|
||||
|
||||
|
||||
# ─── Helpers ───
|
||||
|
||||
|
||||
def _make_agent(
|
||||
*,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
name: str = "Test Agent",
|
||||
is_active: bool = True,
|
||||
tool_ids: list[str] | None = None,
|
||||
) -> AIAgent:
|
||||
"""Create an AIAgent instance with defaults."""
|
||||
return AIAgent(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id or uuid.uuid4(),
|
||||
name=name,
|
||||
description="A test agent",
|
||||
system_prompt="You are a helpful assistant.",
|
||||
tool_ids=tool_ids or [],
|
||||
is_active=is_active,
|
||||
is_default=False,
|
||||
config={},
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
def _mock_session() -> AsyncMock:
|
||||
"""Create a mock AsyncSession."""
|
||||
session = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
session.flush = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
session.rollback = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
def _create_external_api_app(
|
||||
*,
|
||||
db: AsyncMock | None = None,
|
||||
current_user: dict | None = None,
|
||||
) -> FastAPI:
|
||||
"""Create a minimal FastAPI app with external_api router and mocked dependencies."""
|
||||
app = FastAPI()
|
||||
app.include_router(external_api_router)
|
||||
|
||||
if db is None:
|
||||
db = _mock_session()
|
||||
|
||||
if current_user is None:
|
||||
current_user = {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
"role": "admin",
|
||||
"permissions": [],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
"token_prefix": "test_token",
|
||||
}
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user_bearer():
|
||||
return current_user
|
||||
|
||||
async def _mock_set_tenant_context(session, tenant_id):
|
||||
pass
|
||||
|
||||
async def _mock_check_rate_limit(redis_key, max_attempts, window_seconds):
|
||||
pass
|
||||
|
||||
from app.deps import get_current_user_bearer, get_current_user
|
||||
from app.core.db import get_db, set_tenant_context
|
||||
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user_bearer] = _mock_get_current_user_bearer
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user_bearer
|
||||
|
||||
# Patch set_tenant_context and check_rate_limit at module level
|
||||
return app
|
||||
|
||||
|
||||
# ─── Run Agent Tests ───
|
||||
|
||||
|
||||
class TestRunAgentExternal:
|
||||
"""Tests for POST /api/v1/external/agent/{agent_id}/run."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_success(self):
|
||||
"""POST /run executes agent and returns response."""
|
||||
tenant_id = uuid.uuid4()
|
||||
agent = _make_agent(tenant_id=tenant_id, is_active=True)
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
# First execute: find agent
|
||||
agent_result = MagicMock()
|
||||
agent_result.scalar_one_or_none.return_value = agent
|
||||
db.execute.return_value = agent_result
|
||||
|
||||
current_user = {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(tenant_id),
|
||||
"is_system_admin": True,
|
||||
"role": "admin",
|
||||
"permissions": [],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {},
|
||||
"token_prefix": "test_token",
|
||||
}
|
||||
|
||||
app = _create_external_api_app(db=db, current_user=current_user)
|
||||
|
||||
# Mock stream_chat to return chunks
|
||||
async def _mock_stream_chat(*args, **kwargs):
|
||||
yield 'data: {"content": "Hello "}\n\n'
|
||||
yield 'data: {"content": "world!"}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch("app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit", AsyncMock()),
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.get_db") as mock_get_db,
|
||||
patch("app.plugins.builtins.ai_assistant.services.stream_chat", _mock_stream_chat),
|
||||
):
|
||||
# Override get_db to return an async context manager for the inner stream_db
|
||||
class _FakeAsyncCtxMgr:
|
||||
async def __aenter__(self):
|
||||
return db
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
mock_get_db.return_value = _FakeAsyncCtxMgr()
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
f"/api/v1/external/agent/{agent.id}/run",
|
||||
json={"message": "Say hello"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "response" in data
|
||||
assert data["agent_id"] == str(agent.id)
|
||||
assert "session_id" in data
|
||||
assert data["tokens_used"] > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_not_found(self):
|
||||
"""POST /run returns 404 when agent does not exist."""
|
||||
db = _mock_session()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
app = _create_external_api_app(db=db)
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch("app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit", AsyncMock()),
|
||||
):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
f"/api/v1/external/agent/{uuid.uuid4()}/run",
|
||||
json={"message": "test"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["detail"] == "Agent not found"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_inactive(self):
|
||||
"""POST /run returns 400 when agent is inactive."""
|
||||
agent = _make_agent(is_active=False)
|
||||
|
||||
db = _mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = agent
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
app = _create_external_api_app(db=db)
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch("app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit", AsyncMock()),
|
||||
):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
f"/api/v1/external/agent/{agent.id}/run",
|
||||
json={"message": "test"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "Agent is not active"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_invalid_uuid(self):
|
||||
"""POST /run returns 400 for invalid agent UUID."""
|
||||
app = _create_external_api_app()
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch("app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit", AsyncMock()),
|
||||
):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/external/agent/not-a-uuid/run",
|
||||
json={"message": "test"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "Invalid agent ID"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_rate_limited(self):
|
||||
"""POST /run returns 429 when rate limit is exceeded."""
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
agent = _make_agent(is_active=True)
|
||||
|
||||
db = _mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = agent
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
app = _create_external_api_app(db=db)
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch(
|
||||
"app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit",
|
||||
AsyncMock(side_effect=HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded",
|
||||
)),
|
||||
),
|
||||
):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
f"/api/v1/external/agent/{agent.id}/run",
|
||||
json={"message": "test"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
# ─── Get Agent Status Tests ───
|
||||
|
||||
|
||||
class TestGetAgentStatusExternal:
|
||||
"""Tests for GET /api/v1/external/agent/{agent_id}/status."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_success(self):
|
||||
"""GET /status returns agent status information."""
|
||||
tenant_id = uuid.uuid4()
|
||||
agent = _make_agent(tenant_id=tenant_id, name="Status Agent", tool_ids=["tool1", "tool2"])
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
# First execute: find agent
|
||||
agent_result = MagicMock()
|
||||
agent_result.scalar_one_or_none.return_value = agent
|
||||
|
||||
# Second execute: count runs
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 5
|
||||
|
||||
db.execute.side_effect = [agent_result, count_result]
|
||||
|
||||
app = _create_external_api_app(db=db)
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch("app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit", AsyncMock()),
|
||||
):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get(f"/api/v1/external/agent/{agent.id}/status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["agent_id"] == str(agent.id)
|
||||
assert data["name"] == "Status Agent"
|
||||
assert data["is_active"] is True
|
||||
assert data["tool_count"] == 2
|
||||
assert data["total_runs"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_not_found(self):
|
||||
"""GET /status returns 404 when agent does not exist."""
|
||||
db = _mock_session()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
app = _create_external_api_app(db=db)
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch("app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit", AsyncMock()),
|
||||
):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get(f"/api/v1/external/agent/{uuid.uuid4()}/status")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_invalid_uuid(self):
|
||||
"""GET /status returns 400 for invalid agent UUID."""
|
||||
app = _create_external_api_app()
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch("app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit", AsyncMock()),
|
||||
):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/api/v1/external/agent/not-a-uuid/status")
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "Invalid agent ID"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_rate_limited(self):
|
||||
"""GET /status returns 429 when rate limit is exceeded."""
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
app = _create_external_api_app()
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch(
|
||||
"app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit",
|
||||
AsyncMock(side_effect=HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded",
|
||||
)),
|
||||
),
|
||||
):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get(f"/api/v1/external/agent/{uuid.uuid4()}/status")
|
||||
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
# ─── Stream Agent Tests ───
|
||||
|
||||
|
||||
class TestStreamAgentExternal:
|
||||
"""Tests for POST /api/v1/external/agent/{agent_id}/stream."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_agent_success(self):
|
||||
"""POST /stream returns SSE streaming response."""
|
||||
tenant_id = uuid.uuid4()
|
||||
agent = _make_agent(tenant_id=tenant_id, is_active=True)
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
agent_result = MagicMock()
|
||||
agent_result.scalar_one_or_none.return_value = agent
|
||||
db.execute.return_value = agent_result
|
||||
|
||||
app = _create_external_api_app(db=db)
|
||||
|
||||
async def _mock_stream_chat(*args, **kwargs):
|
||||
yield 'data: {"content": "Hello "}\n\n'
|
||||
yield 'data: {"content": "world!"}\n\n'
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch("app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit", AsyncMock()),
|
||||
patch("app.core.db.get_session_factory") as mock_factory,
|
||||
patch("app.plugins.builtins.ai_assistant.services.stream_chat", _mock_stream_chat),
|
||||
):
|
||||
# Mock session factory for the streaming inner DB session
|
||||
mock_session_ctx = AsyncMock()
|
||||
mock_session_ctx.__aenter__ = AsyncMock(return_value=db)
|
||||
mock_session_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_factory.return_value.return_value = mock_session_ctx
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
f"/api/v1/external/agent/{agent.id}/stream",
|
||||
json={"message": "Say hello"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "text/event-stream" in resp.headers.get("content-type", "")
|
||||
# Response should contain SSE data
|
||||
body = resp.text
|
||||
assert "data:" in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_agent_not_found(self):
|
||||
"""POST /stream returns 404 when agent does not exist."""
|
||||
db = _mock_session()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
app = _create_external_api_app(db=db)
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch("app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit", AsyncMock()),
|
||||
):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
f"/api/v1/external/agent/{uuid.uuid4()}/stream",
|
||||
json={"message": "test"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_agent_inactive(self):
|
||||
"""POST /stream returns 400 when agent is inactive."""
|
||||
agent = _make_agent(is_active=False)
|
||||
|
||||
db = _mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = agent
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
app = _create_external_api_app(db=db)
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch("app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit", AsyncMock()),
|
||||
):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
f"/api/v1/external/agent/{agent.id}/stream",
|
||||
json={"message": "test"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "Agent is not active"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_agent_invalid_uuid(self):
|
||||
"""POST /stream returns 400 for invalid agent UUID."""
|
||||
app = _create_external_api_app()
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch("app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit", AsyncMock()),
|
||||
):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/external/agent/not-a-uuid/stream",
|
||||
json={"message": "test"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "Invalid agent ID"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_agent_rate_limited(self):
|
||||
"""POST /stream returns 429 when rate limit is exceeded."""
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
app = _create_external_api_app()
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch(
|
||||
"app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit",
|
||||
AsyncMock(side_effect=HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded",
|
||||
)),
|
||||
),
|
||||
):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
f"/api/v1/external/agent/{uuid.uuid4()}/stream",
|
||||
json={"message": "test"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
# ─── Auth Tests ───
|
||||
|
||||
|
||||
class TestExternalAgentAuth:
|
||||
"""Tests for Bearer token authentication on external agent API."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bearer_token_required(self):
|
||||
"""Endpoints require Bearer token authentication (mocked via dependency override)."""
|
||||
# When get_current_user_bearer is not overridden, the request should fail
|
||||
app = FastAPI()
|
||||
app.include_router(external_api_router)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
f"/api/v1/external/agent/{uuid.uuid4()}/run",
|
||||
json={"message": "test"},
|
||||
)
|
||||
|
||||
# Should return 401 or 403 since no auth is provided
|
||||
assert resp.status_code in (401, 403, 422)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_context_passed_correctly(self):
|
||||
"""The current_user dict from bearer auth is passed to stream_chat."""
|
||||
tenant_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
agent = _make_agent(tenant_id=tenant_id, is_active=True)
|
||||
|
||||
db = _mock_session()
|
||||
agent_result = MagicMock()
|
||||
agent_result.scalar_one_or_none.return_value = agent
|
||||
db.execute.return_value = agent_result
|
||||
|
||||
current_user = {
|
||||
"user_id": str(user_id),
|
||||
"tenant_id": str(tenant_id),
|
||||
"is_system_admin": False,
|
||||
"role": "editor",
|
||||
"permissions": ["ai:write"],
|
||||
"denied_permissions": [],
|
||||
"field_permissions": {"annual_revenue": "read"},
|
||||
"token_prefix": "abc123",
|
||||
}
|
||||
|
||||
app = _create_external_api_app(db=db, current_user=current_user)
|
||||
|
||||
captured_context = {}
|
||||
|
||||
async def _mock_stream_chat(stream_db, session, agent_obj, message, user_context, tid):
|
||||
captured_context.update(user_context)
|
||||
yield 'data: {"content": "response"}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.set_tenant_context", AsyncMock()),
|
||||
patch("app.plugins.builtins.ai_assistant.external_api._check_external_rate_limit", AsyncMock()),
|
||||
patch("app.plugins.builtins.ai_assistant.external_api.get_db") as mock_get_db,
|
||||
patch("app.plugins.builtins.ai_assistant.services.stream_chat", _mock_stream_chat),
|
||||
):
|
||||
class _FakeAsyncCtxMgr:
|
||||
async def __aenter__(self):
|
||||
return db
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
mock_get_db.return_value = _FakeAsyncCtxMgr()
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
f"/api/v1/external/agent/{agent.id}/run",
|
||||
json={"message": "test"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
# Verify user context was passed correctly
|
||||
assert captured_context.get("user_id") == str(user_id)
|
||||
assert captured_context.get("tenant_id") == str(tenant_id)
|
||||
assert captured_context.get("role") == "editor"
|
||||
assert "ai:write" in captured_context.get("permissions", [])
|
||||
Reference in New Issue
Block a user