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:
@@ -47,6 +47,6 @@ class EntityRelationship(Base, TenantMixin, OwnedMixin):
|
||||
relationship_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, comment="Type of relationship (e.g. 'works_for', 'has_email', 'related_to')"
|
||||
)
|
||||
metadata: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSONB, nullable=True, default=dict, comment="Arbitrary metadata about the relationship"
|
||||
meta: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
"metadata", JSONB, nullable=True, default=dict, comment="Arbitrary metadata about the relationship"
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ class GraphRAGSearchProvider(BaseSearchProvider):
|
||||
coalesce(r.relationship_type, '') || ' ' ||
|
||||
coalesce(r.source_type, '') || ' ' ||
|
||||
coalesce(r.target_type, '') || ' ' ||
|
||||
coalesce(r.metadata::text, '')
|
||||
coalesce(r.meta::text, '')
|
||||
),
|
||||
to_tsquery('pg_catalog.german', :q)
|
||||
) AS rank
|
||||
@@ -51,7 +51,7 @@ class GraphRAGSearchProvider(BaseSearchProvider):
|
||||
coalesce(r.relationship_type, '') || ' ' ||
|
||||
coalesce(r.source_type, '') || ' ' ||
|
||||
coalesce(r.target_type, '') || ' ' ||
|
||||
coalesce(r.metadata::text, '')
|
||||
coalesce(r.meta::text, '')
|
||||
) @@ to_tsquery('pg_catalog.german', :q)
|
||||
AND r.id = ANY(:visible_ids)
|
||||
ORDER BY rank DESC
|
||||
@@ -75,7 +75,7 @@ class GraphRAGSearchProvider(BaseSearchProvider):
|
||||
coalesce(r.relationship_type, '') || ' ' ||
|
||||
coalesce(r.source_type, '') || ' ' ||
|
||||
coalesce(r.target_type, '') || ' ' ||
|
||||
coalesce(r.metadata::text, '')
|
||||
coalesce(r.meta::text, '')
|
||||
),
|
||||
to_tsquery('pg_catalog.german', :q)
|
||||
) AS rank
|
||||
@@ -86,7 +86,7 @@ class GraphRAGSearchProvider(BaseSearchProvider):
|
||||
coalesce(r.relationship_type, '') || ' ' ||
|
||||
coalesce(r.source_type, '') || ' ' ||
|
||||
coalesce(r.target_type, '') || ' ' ||
|
||||
coalesce(r.metadata::text, '')
|
||||
coalesce(r.meta::text, '')
|
||||
) @@ to_tsquery('pg_catalog.german', :q)
|
||||
ORDER BY rank DESC
|
||||
LIMIT :lim
|
||||
|
||||
@@ -108,7 +108,7 @@ async def list_relationships(
|
||||
"target_type": r.target_type,
|
||||
"target_id": str(r.target_id),
|
||||
"relationship_type": r.relationship_type,
|
||||
"metadata": r.metadata,
|
||||
"metadata": r.meta,
|
||||
"owner_id": str(r.owner_id) if r.owner_id else None,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ async def create_relationship(
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
relationship_type=relationship_type,
|
||||
metadata=metadata or {},
|
||||
meta=metadata or {},
|
||||
owner_id=owner_id,
|
||||
)
|
||||
db.add(rel)
|
||||
@@ -75,7 +75,7 @@ async def create_relationship(
|
||||
"target_type": rel.target_type,
|
||||
"target_id": str(rel.target_id),
|
||||
"relationship_type": rel.relationship_type,
|
||||
"metadata": rel.metadata,
|
||||
"metadata": rel.meta,
|
||||
"owner_id": str(rel.owner_id) if rel.owner_id else None,
|
||||
"created_at": rel.created_at.isoformat() if rel.created_at else None,
|
||||
}
|
||||
@@ -150,7 +150,7 @@ async def traverse_graph(
|
||||
"target_type": rel.target_type,
|
||||
"target_id": str(rel.target_id),
|
||||
"relationship_type": rel.relationship_type,
|
||||
"metadata": rel.metadata,
|
||||
"metadata": rel.meta,
|
||||
})
|
||||
|
||||
if target_key not in visited:
|
||||
@@ -186,7 +186,7 @@ async def traverse_graph(
|
||||
"target_type": rel.target_type,
|
||||
"target_id": str(rel.target_id),
|
||||
"relationship_type": rel.relationship_type,
|
||||
"metadata": rel.metadata,
|
||||
"metadata": rel.meta,
|
||||
})
|
||||
|
||||
if source_key not in visited:
|
||||
|
||||
@@ -96,7 +96,7 @@ async def get_marketplace_listing(
|
||||
@router.post("/install/{name}", response_model=MarketplaceInstallResponse)
|
||||
async def install_from_marketplace(
|
||||
name: str,
|
||||
body: MarketplaceInstallRequest = MarketplaceInstallRequest(name=""),
|
||||
body: MarketplaceInstallRequest = MarketplaceInstallRequest.model_construct(name=""),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
@@ -138,7 +138,7 @@ async def install_from_marketplace(
|
||||
@router.post("/verify/{name}", response_model=MarketplaceVerifyResponse)
|
||||
async def verify_plugin_signature(
|
||||
name: str,
|
||||
body: MarketplaceInstallRequest = MarketplaceInstallRequest(name=""),
|
||||
body: MarketplaceInstallRequest = MarketplaceInstallRequest.model_construct(name=""),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("marketplace:read")),
|
||||
):
|
||||
|
||||
@@ -0,0 +1,882 @@
|
||||
"""Tests for the Agent Memory plugin — service and route layers.
|
||||
|
||||
Uses AsyncMock for all DB operations and embedding generation.
|
||||
No real DB connections required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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.agent_memory.models import AgentMemory
|
||||
from app.plugins.builtins.agent_memory.routes import router as agent_memory_router
|
||||
from app.plugins.builtins.agent_memory.services import (
|
||||
delete_memory,
|
||||
retrieve_relevant_memories,
|
||||
store_memory,
|
||||
)
|
||||
|
||||
|
||||
# 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_memory(
|
||||
*,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
agent_id: uuid.UUID | None = None,
|
||||
memory_type: str = "fact",
|
||||
content: str = "test content",
|
||||
owner_id: uuid.UUID | None = None,
|
||||
) -> AgentMemory:
|
||||
"""Create an AgentMemory instance with defaults."""
|
||||
return AgentMemory(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id or uuid.uuid4(),
|
||||
agent_id=agent_id or uuid.uuid4(),
|
||||
memory_type=memory_type,
|
||||
content=content,
|
||||
owner_id=owner_id,
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
def _mock_session() -> AsyncMock:
|
||||
"""Create a mock AsyncSession with common patterns."""
|
||||
session = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
session.flush = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
session.delete = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
session.rollback = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
# ─── Service-Layer Tests ───
|
||||
|
||||
|
||||
class TestStoreMemory:
|
||||
"""Tests for store_memory() service function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_memory_creates_memory_with_embedding(self):
|
||||
"""store_memory creates a memory and stores the embedding."""
|
||||
tenant_id = uuid.uuid4()
|
||||
agent_id = uuid.uuid4()
|
||||
owner_id = uuid.uuid4()
|
||||
fake_embedding = [0.1] * 768
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
# generate_embedding returns a list of floats
|
||||
with patch(
|
||||
"app.plugins.builtins.agent_memory.services.generate_embedding",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fake_embedding,
|
||||
) as mock_gen:
|
||||
# After flush+refresh, the memory object should have id/created_at
|
||||
def _refresh_side_effect(obj):
|
||||
obj.id = uuid.uuid4()
|
||||
obj.created_at = datetime.now(UTC)
|
||||
obj.updated_at = datetime.now(UTC)
|
||||
|
||||
db.refresh.side_effect = _refresh_side_effect
|
||||
|
||||
result = await store_memory(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
content="The sky is blue",
|
||||
memory_type="fact",
|
||||
owner_id=owner_id,
|
||||
)
|
||||
|
||||
# Verify embedding was generated
|
||||
mock_gen.assert_awaited_once()
|
||||
# Verify memory was added to session
|
||||
db.add.assert_called_once()
|
||||
db.flush.assert_awaited()
|
||||
# Verify embedding SQL was executed
|
||||
assert db.execute.await_count >= 1
|
||||
# Verify result shape
|
||||
assert "id" in result
|
||||
assert result["agent_id"] == str(agent_id)
|
||||
assert result["memory_type"] == "fact"
|
||||
assert result["content"] == "The sky is blue"
|
||||
assert result["owner_id"] == str(owner_id)
|
||||
assert result["created_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_memory_without_embedding(self):
|
||||
"""store_memory still creates memory when embedding generation fails (returns None/empty)."""
|
||||
tenant_id = uuid.uuid4()
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.agent_memory.services.generate_embedding",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
def _refresh_side_effect(obj):
|
||||
obj.id = uuid.uuid4()
|
||||
obj.created_at = datetime.now(UTC)
|
||||
obj.updated_at = datetime.now(UTC)
|
||||
|
||||
db.refresh.side_effect = _refresh_side_effect
|
||||
|
||||
result = await store_memory(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
content="No embedding for this",
|
||||
)
|
||||
|
||||
assert result["content"] == "No embedding for this"
|
||||
assert result["owner_id"] is None
|
||||
# No embedding SQL should be executed when embedding is None
|
||||
db.execute.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_memory_empty_embedding_list(self):
|
||||
"""store_memory handles empty embedding list gracefully."""
|
||||
tenant_id = uuid.uuid4()
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.agent_memory.services.generate_embedding",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
):
|
||||
def _refresh_side_effect(obj):
|
||||
obj.id = uuid.uuid4()
|
||||
obj.created_at = datetime.now(UTC)
|
||||
|
||||
db.refresh.side_effect = _refresh_side_effect
|
||||
|
||||
result = await store_memory(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
content="Empty embedding",
|
||||
)
|
||||
|
||||
assert result["content"] == "Empty embedding"
|
||||
db.execute.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_memory_default_memory_type(self):
|
||||
"""store_memory uses 'fact' as default memory_type."""
|
||||
db = _mock_session()
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.agent_memory.services.generate_embedding",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[0.1] * 768,
|
||||
):
|
||||
def _refresh_side_effect(obj):
|
||||
obj.id = uuid.uuid4()
|
||||
obj.created_at = datetime.now(UTC)
|
||||
|
||||
db.refresh.side_effect = _refresh_side_effect
|
||||
|
||||
result = await store_memory(
|
||||
db=db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
agent_id=uuid.uuid4(),
|
||||
content="Default type test",
|
||||
)
|
||||
|
||||
assert result["memory_type"] == "fact"
|
||||
|
||||
|
||||
class TestRetrieveRelevantMemories:
|
||||
"""Tests for retrieve_relevant_memories() service function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_with_embedding_semantic_search(self):
|
||||
"""retrieve_relevant_memories performs vector similarity search when embedding is available."""
|
||||
tenant_id = uuid.uuid4()
|
||||
agent_id = uuid.uuid4()
|
||||
fake_embedding = [0.2] * 768
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
# Mock the SQL query result with rows
|
||||
mock_row = {
|
||||
"id": uuid.uuid4(),
|
||||
"agent_id": agent_id,
|
||||
"memory_type": "fact",
|
||||
"content": "Paris is in France",
|
||||
"score": 0.95,
|
||||
"created_at": datetime.now(UTC),
|
||||
}
|
||||
mock_result = MagicMock()
|
||||
mock_result.mappings.return_value.all.return_value = [mock_row]
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.agent_memory.services.generate_embedding",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fake_embedding,
|
||||
):
|
||||
results = await retrieve_relevant_memories(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
query="capital of France",
|
||||
limit=5,
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["content"] == "Paris is in France"
|
||||
assert results[0]["score"] == 0.95
|
||||
assert results[0]["agent_id"] == str(agent_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_fallback_on_embedding_failure(self):
|
||||
"""retrieve_relevant_memories falls back to recent memories when embedding fails."""
|
||||
tenant_id = uuid.uuid4()
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
# Create mock memory objects for fallback query
|
||||
mem1 = _make_memory(
|
||||
tenant_id=tenant_id, agent_id=agent_id, content="Recent memory 1"
|
||||
)
|
||||
mem2 = _make_memory(
|
||||
tenant_id=tenant_id, agent_id=agent_id, content="Recent memory 2"
|
||||
)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [mem1, mem2]
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.agent_memory.services.generate_embedding",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
results = await retrieve_relevant_memories(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
query="some query",
|
||||
limit=10,
|
||||
)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0]["content"] == "Recent memory 1"
|
||||
assert results[0]["score"] == 0.0 # fallback has no score
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_with_memory_type_filter(self):
|
||||
"""retrieve_relevant_memories filters by memory_type when provided."""
|
||||
tenant_id = uuid.uuid4()
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
mock_row = {
|
||||
"id": uuid.uuid4(),
|
||||
"agent_id": agent_id,
|
||||
"memory_type": "instruction",
|
||||
"content": "Always be polite",
|
||||
"score": 0.88,
|
||||
"created_at": datetime.now(UTC),
|
||||
}
|
||||
mock_result = MagicMock()
|
||||
mock_result.mappings.return_value.all.return_value = [mock_row]
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.agent_memory.services.generate_embedding",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[0.3] * 768,
|
||||
):
|
||||
results = await retrieve_relevant_memories(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
query="be polite",
|
||||
memory_type="instruction",
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["memory_type"] == "instruction"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_min_score_filter(self):
|
||||
"""retrieve_relevant_memories filters out results below min_score."""
|
||||
tenant_id = uuid.uuid4()
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
# Two rows: one above threshold, one below
|
||||
mock_row_high = {
|
||||
"id": uuid.uuid4(),
|
||||
"agent_id": agent_id,
|
||||
"memory_type": "fact",
|
||||
"content": "High score memory",
|
||||
"score": 0.9,
|
||||
"created_at": datetime.now(UTC),
|
||||
}
|
||||
mock_row_low = {
|
||||
"id": uuid.uuid4(),
|
||||
"agent_id": agent_id,
|
||||
"memory_type": "fact",
|
||||
"content": "Low score memory",
|
||||
"score": 0.3,
|
||||
"created_at": datetime.now(UTC),
|
||||
}
|
||||
mock_result = MagicMock()
|
||||
mock_result.mappings.return_value.all.return_value = [mock_row_high, mock_row_low]
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.agent_memory.services.generate_embedding",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[0.5] * 768,
|
||||
):
|
||||
results = await retrieve_relevant_memories(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
query="test",
|
||||
min_score=0.5,
|
||||
)
|
||||
|
||||
# Only the high-score row should pass the min_score filter
|
||||
assert len(results) == 1
|
||||
assert results[0]["content"] == "High score memory"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_fallback_with_memory_type_filter(self):
|
||||
"""Fallback query also respects memory_type filter."""
|
||||
tenant_id = uuid.uuid4()
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
mem = _make_memory(
|
||||
tenant_id=tenant_id, agent_id=agent_id, memory_type="pattern", content="Pattern A"
|
||||
)
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [mem]
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.agent_memory.services.generate_embedding",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
results = await retrieve_relevant_memories(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
query="test",
|
||||
memory_type="pattern",
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["memory_type"] == "pattern"
|
||||
|
||||
|
||||
class TestDeleteMemory:
|
||||
"""Tests for delete_memory() service function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_memory_success(self):
|
||||
"""delete_memory returns True when memory exists."""
|
||||
tenant_id = uuid.uuid4()
|
||||
memory_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
memory = _make_memory(tenant_id=tenant_id)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = memory
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await delete_memory(db, tenant_id, memory_id)
|
||||
|
||||
assert result is True
|
||||
db.delete.assert_awaited_once_with(memory)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_memory_not_found(self):
|
||||
"""delete_memory returns False when memory does not exist."""
|
||||
tenant_id = uuid.uuid4()
|
||||
memory_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await delete_memory(db, tenant_id, memory_id)
|
||||
|
||||
assert result is False
|
||||
db.delete.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_memory_tenant_isolation(self):
|
||||
"""delete_memory only finds memories within the same tenant."""
|
||||
tenant_a = uuid.uuid4()
|
||||
tenant_b = uuid.uuid4()
|
||||
memory_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
mock_result = MagicMock()
|
||||
# Memory from tenant B should not be found when querying with tenant A
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await delete_memory(db, tenant_a, memory_id)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# ─── Model Tests ───
|
||||
|
||||
|
||||
class TestAgentMemoryModel:
|
||||
"""Tests for the AgentMemory model attributes."""
|
||||
|
||||
def test_model_has_required_fields(self):
|
||||
"""AgentMemory model has tenant_id, agent_id, memory_type, content."""
|
||||
mem = AgentMemory(
|
||||
tenant_id=uuid.uuid4(),
|
||||
agent_id=uuid.uuid4(),
|
||||
memory_type="fact",
|
||||
content="test",
|
||||
)
|
||||
assert mem.tenant_id is not None
|
||||
assert mem.agent_id is not None
|
||||
assert mem.memory_type == "fact"
|
||||
assert mem.content == "test"
|
||||
|
||||
def test_model_default_memory_type(self):
|
||||
"""AgentMemory has default='fact' for memory_type column."""
|
||||
mem = AgentMemory(
|
||||
tenant_id=uuid.uuid4(),
|
||||
agent_id=uuid.uuid4(),
|
||||
content="test",
|
||||
)
|
||||
# The default is set at DB level (default="fact"), so we check the column default
|
||||
col = AgentMemory.__table__.c.memory_type
|
||||
assert col.default.arg == "fact"
|
||||
|
||||
def test_model_table_name(self):
|
||||
"""AgentMemory uses correct table name."""
|
||||
assert AgentMemory.__tablename__ == "agent_memories"
|
||||
|
||||
|
||||
# ─── Route-Layer Tests ───
|
||||
|
||||
|
||||
def _create_agent_memory_app() -> FastAPI:
|
||||
"""Create a minimal FastAPI app with agent_memory router and mocked dependencies."""
|
||||
app = FastAPI()
|
||||
app.include_router(agent_memory_router)
|
||||
|
||||
async def _mock_get_db():
|
||||
db = _mock_session()
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
"role": "admin",
|
||||
"permissions": [],
|
||||
}
|
||||
|
||||
async def _mock_require_permission(permission: str):
|
||||
async def _check():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
return _check
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class TestAgentMemoryRoutes:
|
||||
"""Tests for agent memory API routes."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_memory_route(self):
|
||||
"""POST /api/v1/agent-memory creates a memory."""
|
||||
app = _create_agent_memory_app()
|
||||
|
||||
# Override get_db to return our mock
|
||||
tenant_id = uuid.uuid4()
|
||||
agent_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(user_id),
|
||||
"tenant_id": str(tenant_id),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.agent_memory.services.generate_embedding",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[0.1] * 768,
|
||||
):
|
||||
def _refresh_side_effect(obj):
|
||||
obj.id = uuid.uuid4()
|
||||
obj.created_at = datetime.now(UTC)
|
||||
obj.updated_at = datetime.now(UTC)
|
||||
|
||||
db.refresh.side_effect = _refresh_side_effect
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/agent-memory",
|
||||
json={
|
||||
"agent_id": str(agent_id),
|
||||
"memory_type": "fact",
|
||||
"content": "Route test memory",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["content"] == "Route test memory"
|
||||
assert data["agent_id"] == str(agent_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_memory_invalid_agent_id(self):
|
||||
"""POST /api/v1/agent-memory returns 400 for invalid agent_id UUID."""
|
||||
app = _create_agent_memory_app()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/agent-memory",
|
||||
json={
|
||||
"agent_id": "not-a-uuid",
|
||||
"content": "test",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"]["code"] == "invalid_id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_memories_route(self):
|
||||
"""GET /api/v1/agent-memory/search returns search results."""
|
||||
app = _create_agent_memory_app()
|
||||
tenant_id = uuid.uuid4()
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(tenant_id),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
mock_row = {
|
||||
"id": uuid.uuid4(),
|
||||
"agent_id": agent_id,
|
||||
"memory_type": "fact",
|
||||
"content": "Found memory",
|
||||
"score": 0.92,
|
||||
"created_at": datetime.now(UTC),
|
||||
}
|
||||
mock_result = MagicMock()
|
||||
mock_result.mappings.return_value.all.return_value = [mock_row]
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.agent_memory.services.generate_embedding",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[0.4] * 768,
|
||||
):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/v1/agent-memory/search",
|
||||
params={
|
||||
"agent_id": str(agent_id),
|
||||
"query": "test query",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["content"] == "Found memory"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_memory_route_success(self):
|
||||
"""DELETE /api/v1/agent-memory/{id} returns 204 on success."""
|
||||
app = _create_agent_memory_app()
|
||||
memory_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
memory = _make_memory()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = memory
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.delete(f"/api/v1/agent-memory/{memory_id}")
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_memory_route_not_found(self):
|
||||
"""DELETE /api/v1/agent-memory/{id} returns 404 when not found."""
|
||||
app = _create_agent_memory_app()
|
||||
memory_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.delete(f"/api/v1/agent-memory/{memory_id}")
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["detail"]["code"] == "not_found"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_memories_route(self):
|
||||
"""GET /api/v1/agent-memory lists memories with pagination."""
|
||||
app = _create_agent_memory_app()
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
# Mock count query
|
||||
count_result = MagicMock()
|
||||
count_result.scalar_one.return_value = 1
|
||||
|
||||
# Mock paginated query
|
||||
mem = _make_memory(agent_id=agent_id, content="Listed memory")
|
||||
list_result = MagicMock()
|
||||
list_result.scalars.return_value.all.return_value = [mem]
|
||||
|
||||
# execute is called twice: once for count, once for list
|
||||
db.execute.side_effect = [count_result, list_result]
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/v1/agent-memory",
|
||||
params={"agent_id": str(agent_id)},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["content"] == "Listed memory"
|
||||
|
||||
|
||||
# ─── Tenant Isolation Tests ───
|
||||
|
||||
|
||||
class TestTenantIsolation:
|
||||
"""Tests for tenant_id isolation in agent memory."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_memory_uses_correct_tenant_id(self):
|
||||
"""store_memory creates memory with the provided tenant_id."""
|
||||
tenant_a = uuid.uuid4()
|
||||
tenant_b = uuid.uuid4()
|
||||
agent_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
captured_tenant_ids = []
|
||||
|
||||
def _track_add(obj):
|
||||
captured_tenant_ids.append(obj.tenant_id)
|
||||
|
||||
db.add.side_effect = _track_add
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.agent_memory.services.generate_embedding",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[0.1] * 768,
|
||||
):
|
||||
def _refresh_side_effect(obj):
|
||||
obj.id = uuid.uuid4()
|
||||
obj.created_at = datetime.now(UTC)
|
||||
|
||||
db.refresh.side_effect = _refresh_side_effect
|
||||
|
||||
await store_memory(
|
||||
db=db,
|
||||
tenant_id=tenant_a,
|
||||
agent_id=agent_id,
|
||||
content="Tenant A memory",
|
||||
)
|
||||
|
||||
await store_memory(
|
||||
db=db,
|
||||
tenant_id=tenant_b,
|
||||
agent_id=agent_id,
|
||||
content="Tenant B memory",
|
||||
)
|
||||
|
||||
assert tenant_a in captured_tenant_ids
|
||||
assert tenant_b in captured_tenant_ids
|
||||
assert len(captured_tenant_ids) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_memory_respects_tenant_boundary(self):
|
||||
"""delete_memory does not delete memories from other tenants."""
|
||||
tenant_a = uuid.uuid4()
|
||||
tenant_b = uuid.uuid4()
|
||||
memory_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
# Simulate that the memory belongs to tenant_b, not tenant_a
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await delete_memory(db, tenant_a, memory_id)
|
||||
|
||||
# Should return False because the memory is not in tenant_a
|
||||
assert result is False
|
||||
db.delete.assert_not_awaited()
|
||||
@@ -0,0 +1,723 @@
|
||||
"""Tests for AgentCoordinator subtask management — service layer.
|
||||
|
||||
Uses AsyncMock for all DB operations.
|
||||
No real DB connections required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.plugins.builtins.automation.agent_coordinator import AgentCoordinator
|
||||
from app.plugins.builtins.automation.models import AgentSubtask
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 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 clean_tables(db_setup):
|
||||
"""No-op override of conftest clean_tables."""
|
||||
yield
|
||||
|
||||
|
||||
# ─── Helpers ───
|
||||
|
||||
|
||||
def _make_subtask(
|
||||
*,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
parent_agent_id: uuid.UUID | None = None,
|
||||
child_agent_id: uuid.UUID | None = None,
|
||||
task_description: str = "Do something",
|
||||
status: str = "pending",
|
||||
result: dict | None = None,
|
||||
) -> AgentSubtask:
|
||||
"""Create an AgentSubtask instance with defaults."""
|
||||
return AgentSubtask(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id or uuid.uuid4(),
|
||||
parent_agent_id=parent_agent_id or uuid.uuid4(),
|
||||
child_agent_id=child_agent_id or uuid.uuid4(),
|
||||
task_description=task_description,
|
||||
status=status,
|
||||
result=result or {},
|
||||
created_at=datetime.now(UTC),
|
||||
completed_at=None,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ─── Create Subtask Tests ───
|
||||
|
||||
|
||||
class TestCreateSubtask:
|
||||
"""Tests for AgentCoordinator.create_subtask()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_subtask_success(self):
|
||||
"""create_subtask creates a subtask with pending status."""
|
||||
tenant_id = uuid.uuid4()
|
||||
parent_id = uuid.uuid4()
|
||||
child_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
def _flush_side_effect():
|
||||
# After flush, the subtask should have an ID
|
||||
pass
|
||||
|
||||
db.flush.side_effect = _flush_side_effect
|
||||
|
||||
subtask = await AgentCoordinator.create_subtask(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
parent_agent_id=parent_id,
|
||||
child_agent_id=child_id,
|
||||
task_description="Analyze customer data",
|
||||
)
|
||||
|
||||
assert subtask.tenant_id == tenant_id
|
||||
assert subtask.parent_agent_id == parent_id
|
||||
assert subtask.child_agent_id == child_id
|
||||
assert subtask.task_description == "Analyze customer data"
|
||||
assert subtask.status == "pending"
|
||||
assert subtask.result == {}
|
||||
db.add.assert_called_once()
|
||||
db.flush.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_subtask_default_status_pending(self):
|
||||
"""create_subtask always starts with 'pending' status."""
|
||||
db = _mock_session()
|
||||
|
||||
subtask = await AgentCoordinator.create_subtask(
|
||||
db=db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
parent_agent_id=uuid.uuid4(),
|
||||
child_agent_id=uuid.uuid4(),
|
||||
task_description="test task",
|
||||
)
|
||||
|
||||
assert subtask.status == "pending"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_subtask_default_result_empty(self):
|
||||
"""create_subtask initializes result as empty dict."""
|
||||
db = _mock_session()
|
||||
|
||||
subtask = await AgentCoordinator.create_subtask(
|
||||
db=db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
parent_agent_id=uuid.uuid4(),
|
||||
child_agent_id=uuid.uuid4(),
|
||||
task_description="test task",
|
||||
)
|
||||
|
||||
assert subtask.result == {}
|
||||
|
||||
|
||||
# ─── Wait For Subtask Tests ───
|
||||
|
||||
|
||||
class TestWaitForSubtask:
|
||||
"""Tests for AgentCoordinator.wait_for_subtask()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_subtask_completed(self):
|
||||
"""wait_for_subtask returns when subtask reaches 'completed' status."""
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
subtask = _make_subtask(
|
||||
tenant_id=tenant_id,
|
||||
status="completed",
|
||||
result={"answer": "42"},
|
||||
)
|
||||
subtask.completed_at = datetime.now(UTC)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = subtask
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
# Patch asyncio.sleep to avoid real delays
|
||||
with patch("asyncio.sleep", AsyncMock()):
|
||||
result = await AgentCoordinator.wait_for_subtask(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
subtask_id=subtask.id,
|
||||
poll_interval=0.01,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["result"] == {"answer": "42"}
|
||||
assert result["subtask_id"] == str(subtask.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_subtask_failed(self):
|
||||
"""wait_for_subtask returns when subtask reaches 'failed' status."""
|
||||
tenant_id = uuid.uuid4()
|
||||
subtask_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
subtask = _make_subtask(
|
||||
tenant_id=tenant_id,
|
||||
status="failed",
|
||||
result={"error": "Something went wrong"},
|
||||
)
|
||||
subtask.completed_at = datetime.now(UTC)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = subtask
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
with patch("asyncio.sleep", AsyncMock()):
|
||||
result = await AgentCoordinator.wait_for_subtask(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
subtask_id=subtask_id,
|
||||
poll_interval=0.01,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["result"]["error"] == "Something went wrong"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_subtask_cancelled(self):
|
||||
"""wait_for_subtask returns when subtask reaches 'cancelled' status."""
|
||||
tenant_id = uuid.uuid4()
|
||||
subtask_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
subtask = _make_subtask(
|
||||
tenant_id=tenant_id,
|
||||
status="cancelled",
|
||||
result={"cancelled_by": "user"},
|
||||
)
|
||||
subtask.completed_at = datetime.now(UTC)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = subtask
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
with patch("asyncio.sleep", AsyncMock()):
|
||||
result = await AgentCoordinator.wait_for_subtask(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
subtask_id=subtask_id,
|
||||
poll_interval=0.01,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
assert result["status"] == "cancelled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_subtask_not_found(self):
|
||||
"""wait_for_subtask returns error when subtask does not exist."""
|
||||
db = _mock_session()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
with patch("asyncio.sleep", AsyncMock()):
|
||||
result = await AgentCoordinator.wait_for_subtask(
|
||||
db=db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
subtask_id=uuid.uuid4(),
|
||||
poll_interval=0.01,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "Subtask not found"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_subtask_timeout(self):
|
||||
"""wait_for_subtask returns failed status on timeout."""
|
||||
tenant_id = uuid.uuid4()
|
||||
subtask_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
# Subtask stays pending forever
|
||||
subtask = _make_subtask(tenant_id=tenant_id, status="pending")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = subtask
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
with patch("asyncio.sleep", AsyncMock()):
|
||||
result = await AgentCoordinator.wait_for_subtask(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
subtask_id=subtask_id,
|
||||
poll_interval=0.01,
|
||||
timeout=0.0, # Immediate timeout
|
||||
)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert "Timeout" in result["error"]
|
||||
# Verify the subtask was marked as failed in DB
|
||||
db.execute.assert_awaited() # At least the timeout update
|
||||
db.commit.assert_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_subtask_polls_until_terminal(self):
|
||||
"""wait_for_subtask polls multiple times before reaching terminal state."""
|
||||
tenant_id = uuid.uuid4()
|
||||
subtask_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
pending_subtask = _make_subtask(tenant_id=tenant_id, status="pending")
|
||||
completed_subtask = _make_subtask(
|
||||
tenant_id=tenant_id, status="completed", result={"done": True}
|
||||
)
|
||||
completed_subtask.completed_at = datetime.now(UTC)
|
||||
|
||||
# First poll returns pending, second returns completed
|
||||
mock_pending = MagicMock()
|
||||
mock_pending.scalar_one_or_none.return_value = pending_subtask
|
||||
mock_completed = MagicMock()
|
||||
mock_completed.scalar_one_or_none.return_value = completed_subtask
|
||||
|
||||
db.execute.side_effect = [mock_pending, mock_completed]
|
||||
|
||||
with patch("asyncio.sleep", AsyncMock()):
|
||||
result = await AgentCoordinator.wait_for_subtask(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
subtask_id=subtask_id,
|
||||
poll_interval=0.01,
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert db.execute.await_count == 2 # Polled twice
|
||||
|
||||
|
||||
# ─── Cancel Subtask Tests ───
|
||||
|
||||
|
||||
class TestCancelSubtask:
|
||||
"""Tests for AgentCoordinator.cancel_subtask()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_pending_subtask(self):
|
||||
"""cancel_subtask cancels a pending subtask."""
|
||||
tenant_id = uuid.uuid4()
|
||||
subtask_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
subtask = _make_subtask(tenant_id=tenant_id, status="pending")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = subtask
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await AgentCoordinator.cancel_subtask(db, tenant_id, subtask_id)
|
||||
|
||||
assert result is True
|
||||
assert subtask.status == "cancelled"
|
||||
assert subtask.completed_at is not None
|
||||
assert subtask.result["cancelled_by"] == "coordinator"
|
||||
db.flush.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_running_subtask(self):
|
||||
"""cancel_subtask cancels a running subtask."""
|
||||
db = _mock_session()
|
||||
|
||||
subtask = _make_subtask(status="running")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = subtask
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await AgentCoordinator.cancel_subtask(db, uuid.uuid4(), uuid.uuid4())
|
||||
|
||||
assert result is True
|
||||
assert subtask.status == "cancelled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_completed_subtask_fails(self):
|
||||
"""cancel_subtask returns False for already completed subtask."""
|
||||
db = _mock_session()
|
||||
|
||||
subtask = _make_subtask(status="completed")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = subtask
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await AgentCoordinator.cancel_subtask(db, uuid.uuid4(), uuid.uuid4())
|
||||
|
||||
assert result is False
|
||||
db.flush.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_failed_subtask_fails(self):
|
||||
"""cancel_subtask returns False for already failed subtask."""
|
||||
db = _mock_session()
|
||||
|
||||
subtask = _make_subtask(status="failed")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = subtask
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await AgentCoordinator.cancel_subtask(db, uuid.uuid4(), uuid.uuid4())
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_cancelled_subtask_fails(self):
|
||||
"""cancel_subtask returns False for already cancelled subtask."""
|
||||
db = _mock_session()
|
||||
|
||||
subtask = _make_subtask(status="cancelled")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = subtask
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await AgentCoordinator.cancel_subtask(db, uuid.uuid4(), uuid.uuid4())
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_subtask_not_found(self):
|
||||
"""cancel_subtask returns False when subtask does not exist."""
|
||||
db = _mock_session()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await AgentCoordinator.cancel_subtask(db, uuid.uuid4(), uuid.uuid4())
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# ─── Aggregate Results Tests ───
|
||||
|
||||
|
||||
class TestAggregateResults:
|
||||
"""Tests for AgentCoordinator.aggregate_results()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregate_results_mixed_statuses(self):
|
||||
"""aggregate_results correctly counts subtasks with mixed statuses."""
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
sub1 = _make_subtask(status="completed", result={"output": "result1"})
|
||||
sub2 = _make_subtask(status="failed", result={"error": "error1"})
|
||||
sub3 = _make_subtask(status="pending")
|
||||
sub4 = _make_subtask(status="cancelled")
|
||||
|
||||
mock_results = [sub1, sub2, sub3, sub4]
|
||||
db.execute.side_effect = [
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=st))
|
||||
for st in mock_results
|
||||
]
|
||||
|
||||
result = await AgentCoordinator.aggregate_results(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
subtask_ids=[sub1.id, sub2.id, sub3.id, sub4.id],
|
||||
)
|
||||
|
||||
assert result["total"] == 4
|
||||
assert result["completed"] == 1
|
||||
assert result["failed"] == 1
|
||||
assert result["pending"] == 1
|
||||
assert result["cancelled"] == 1
|
||||
assert len(result["results"]) == 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregate_results_all_completed(self):
|
||||
"""aggregate_results with all completed subtasks."""
|
||||
db = _mock_session()
|
||||
|
||||
sub1 = _make_subtask(status="completed", result={"a": 1})
|
||||
sub2 = _make_subtask(status="completed", result={"b": 2})
|
||||
|
||||
db.execute.side_effect = [
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=sub1)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=sub2)),
|
||||
]
|
||||
|
||||
result = await AgentCoordinator.aggregate_results(
|
||||
db=db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
subtask_ids=[sub1.id, sub2.id],
|
||||
)
|
||||
|
||||
assert result["total"] == 2
|
||||
assert result["completed"] == 2
|
||||
assert result["failed"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregate_results_empty_list(self):
|
||||
"""aggregate_results with empty subtask_ids returns zeros."""
|
||||
db = _mock_session()
|
||||
|
||||
result = await AgentCoordinator.aggregate_results(
|
||||
db=db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
subtask_ids=[],
|
||||
)
|
||||
|
||||
assert result["total"] == 0
|
||||
assert result["completed"] == 0
|
||||
assert result["results"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregate_results_with_not_found(self):
|
||||
"""aggregate_results skips subtasks that don't exist."""
|
||||
db = _mock_session()
|
||||
|
||||
sub1 = _make_subtask(status="completed")
|
||||
|
||||
# First subtask found, second not found
|
||||
db.execute.side_effect = [
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=sub1)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
|
||||
]
|
||||
|
||||
result = await AgentCoordinator.aggregate_results(
|
||||
db=db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
subtask_ids=[sub1.id, uuid.uuid4()],
|
||||
)
|
||||
|
||||
assert result["total"] == 1 # Only the found one
|
||||
assert result["completed"] == 1
|
||||
|
||||
|
||||
# ─── List Subtasks Tests ───
|
||||
|
||||
|
||||
class TestListSubtasks:
|
||||
"""Tests for AgentCoordinator.list_subtasks()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_subtasks_no_filter(self):
|
||||
"""list_subtasks returns all subtasks for a tenant."""
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
sub1 = _make_subtask(tenant_id=tenant_id, status="pending")
|
||||
sub2 = _make_subtask(tenant_id=tenant_id, status="completed")
|
||||
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 2
|
||||
|
||||
list_result = MagicMock()
|
||||
list_result.scalars.return_value.all.return_value = [sub1, sub2]
|
||||
|
||||
db.execute.side_effect = [count_result, list_result]
|
||||
|
||||
subtasks, total = await AgentCoordinator.list_subtasks(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
assert total == 2
|
||||
assert len(subtasks) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_subtasks_filter_by_parent(self):
|
||||
"""list_subtasks filters by parent_agent_id."""
|
||||
tenant_id = uuid.uuid4()
|
||||
parent_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
sub = _make_subtask(tenant_id=tenant_id, parent_agent_id=parent_id)
|
||||
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 1
|
||||
|
||||
list_result = MagicMock()
|
||||
list_result.scalars.return_value.all.return_value = [sub]
|
||||
|
||||
db.execute.side_effect = [count_result, list_result]
|
||||
|
||||
subtasks, total = await AgentCoordinator.list_subtasks(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
parent_agent_id=parent_id,
|
||||
)
|
||||
|
||||
assert total == 1
|
||||
assert subtasks[0].parent_agent_id == parent_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_subtasks_filter_by_child(self):
|
||||
"""list_subtasks filters by child_agent_id."""
|
||||
tenant_id = uuid.uuid4()
|
||||
child_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
sub = _make_subtask(tenant_id=tenant_id, child_agent_id=child_id)
|
||||
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 1
|
||||
|
||||
list_result = MagicMock()
|
||||
list_result.scalars.return_value.all.return_value = [sub]
|
||||
|
||||
db.execute.side_effect = [count_result, list_result]
|
||||
|
||||
subtasks, total = await AgentCoordinator.list_subtasks(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
child_agent_id=child_id,
|
||||
)
|
||||
|
||||
assert total == 1
|
||||
assert subtasks[0].child_agent_id == child_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_subtasks_filter_by_status(self):
|
||||
"""list_subtasks filters by status."""
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
sub = _make_subtask(tenant_id=tenant_id, status="completed")
|
||||
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 1
|
||||
|
||||
list_result = MagicMock()
|
||||
list_result.scalars.return_value.all.return_value = [sub]
|
||||
|
||||
db.execute.side_effect = [count_result, list_result]
|
||||
|
||||
subtasks, total = await AgentCoordinator.list_subtasks(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
status="completed",
|
||||
)
|
||||
|
||||
assert total == 1
|
||||
assert subtasks[0].status == "completed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_subtasks_pagination(self):
|
||||
"""list_subtasks respects limit and offset."""
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 100
|
||||
|
||||
list_result = MagicMock()
|
||||
list_result.scalars.return_value.all.return_value = []
|
||||
|
||||
db.execute.side_effect = [count_result, list_result]
|
||||
|
||||
subtasks, total = await AgentCoordinator.list_subtasks(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
limit=10,
|
||||
offset=20,
|
||||
)
|
||||
|
||||
assert total == 100
|
||||
assert len(subtasks) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_subtasks_empty(self):
|
||||
"""list_subtasks returns empty list when no subtasks exist."""
|
||||
db = _mock_session()
|
||||
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 0
|
||||
|
||||
list_result = MagicMock()
|
||||
list_result.scalars.return_value.all.return_value = []
|
||||
|
||||
db.execute.side_effect = [count_result, list_result]
|
||||
|
||||
subtasks, total = await AgentCoordinator.list_subtasks(
|
||||
db=db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
)
|
||||
|
||||
assert total == 0
|
||||
assert len(subtasks) == 0
|
||||
|
||||
|
||||
# ─── Model Tests ───
|
||||
|
||||
|
||||
class TestAgentSubtaskModel:
|
||||
"""Tests for the AgentSubtask model."""
|
||||
|
||||
def test_model_has_required_fields(self):
|
||||
"""AgentSubtask has tenant_id, parent_agent_id, child_agent_id, task_description, status, result."""
|
||||
subtask = AgentSubtask(
|
||||
tenant_id=uuid.uuid4(),
|
||||
parent_agent_id=uuid.uuid4(),
|
||||
child_agent_id=uuid.uuid4(),
|
||||
task_description="test task",
|
||||
)
|
||||
assert subtask.tenant_id is not None
|
||||
assert subtask.parent_agent_id is not None
|
||||
assert subtask.child_agent_id is not None
|
||||
assert subtask.task_description == "test task"
|
||||
|
||||
def test_model_default_status(self):
|
||||
"""AgentSubtask has default='pending' for status column."""
|
||||
col = AgentSubtask.__table__.c.status
|
||||
assert col.default.arg == "pending"
|
||||
|
||||
def test_model_default_result(self):
|
||||
"""AgentSubtask has default=dict for result column."""
|
||||
col = AgentSubtask.__table__.c.result
|
||||
assert col.default.arg is dict or callable(col.default.arg)
|
||||
|
||||
def test_model_table_name(self):
|
||||
"""AgentSubtask uses correct table name."""
|
||||
assert AgentSubtask.__tablename__ == "agent_subtasks"
|
||||
@@ -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", [])
|
||||
@@ -0,0 +1,892 @@
|
||||
"""Tests for the GraphRAG plugin — service and route layers.
|
||||
|
||||
Uses AsyncMock for all DB operations.
|
||||
No real DB connections required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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.graph_rag.models import EntityRelationship
|
||||
from app.plugins.builtins.graph_rag.routes import router as graph_rag_router
|
||||
from app.plugins.builtins.graph_rag.services import (
|
||||
create_relationship,
|
||||
delete_relationship,
|
||||
traverse_graph,
|
||||
)
|
||||
|
||||
|
||||
# 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_relationship(
|
||||
*,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
source_type: str = "contact",
|
||||
source_id: uuid.UUID | None = None,
|
||||
target_type: str = "company",
|
||||
target_id: uuid.UUID | None = None,
|
||||
relationship_type: str = "works_for",
|
||||
metadata: dict | None = None,
|
||||
owner_id: uuid.UUID | None = None,
|
||||
) -> EntityRelationship:
|
||||
"""Create an EntityRelationship instance with defaults."""
|
||||
return EntityRelationship(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id or uuid.uuid4(),
|
||||
source_type=source_type,
|
||||
source_id=source_id or uuid.uuid4(),
|
||||
target_type=target_type,
|
||||
target_id=target_id or uuid.uuid4(),
|
||||
relationship_type=relationship_type,
|
||||
meta=metadata or {},
|
||||
owner_id=owner_id,
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
def _mock_session() -> AsyncMock:
|
||||
"""Create a mock AsyncSession with common patterns."""
|
||||
session = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
session.flush = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
session.delete = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
session.rollback = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
# ─── Service-Layer Tests ───
|
||||
|
||||
|
||||
class TestCreateRelationship:
|
||||
"""Tests for create_relationship() service function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_relationship_success(self):
|
||||
"""create_relationship creates a new relationship."""
|
||||
tenant_id = uuid.uuid4()
|
||||
source_id = uuid.uuid4()
|
||||
target_id = uuid.uuid4()
|
||||
owner_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
# No duplicate found
|
||||
dup_result = MagicMock()
|
||||
dup_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = dup_result
|
||||
|
||||
def _refresh_side_effect(obj):
|
||||
obj.id = uuid.uuid4()
|
||||
obj.created_at = datetime.now(UTC)
|
||||
obj.updated_at = datetime.now(UTC)
|
||||
|
||||
db.refresh.side_effect = _refresh_side_effect
|
||||
|
||||
result = await create_relationship(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=source_id,
|
||||
target_type="company",
|
||||
target_id=target_id,
|
||||
relationship_type="works_for",
|
||||
metadata={"since": "2024"},
|
||||
owner_id=owner_id,
|
||||
)
|
||||
|
||||
assert result["source_type"] == "contact"
|
||||
assert result["target_type"] == "company"
|
||||
assert result["relationship_type"] == "works_for"
|
||||
assert result["source_id"] == str(source_id)
|
||||
assert result["target_id"] == str(target_id)
|
||||
assert result["metadata"] == {"since": "2024"}
|
||||
assert result["owner_id"] == str(owner_id)
|
||||
assert "id" in result
|
||||
assert "created_at" in result
|
||||
db.add.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_relationship_duplicate(self):
|
||||
"""create_relationship returns error dict when duplicate exists."""
|
||||
tenant_id = uuid.uuid4()
|
||||
source_id = uuid.uuid4()
|
||||
target_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
existing_rel = _make_relationship(
|
||||
tenant_id=tenant_id,
|
||||
source_id=source_id,
|
||||
target_id=target_id,
|
||||
relationship_type="works_for",
|
||||
)
|
||||
dup_result = MagicMock()
|
||||
dup_result.scalar_one_or_none.return_value = existing_rel
|
||||
db.execute.return_value = dup_result
|
||||
|
||||
result = await create_relationship(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=source_id,
|
||||
target_type="company",
|
||||
target_id=target_id,
|
||||
relationship_type="works_for",
|
||||
)
|
||||
|
||||
assert "error" in result
|
||||
assert result["code"] == "duplicate"
|
||||
db.add.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_relationship_with_metadata(self):
|
||||
"""create_relationship stores metadata dict."""
|
||||
db = _mock_session()
|
||||
|
||||
dup_result = MagicMock()
|
||||
dup_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = dup_result
|
||||
|
||||
def _refresh_side_effect(obj):
|
||||
obj.id = uuid.uuid4()
|
||||
obj.created_at = datetime.now(UTC)
|
||||
|
||||
db.refresh.side_effect = _refresh_side_effect
|
||||
|
||||
result = await create_relationship(
|
||||
db=db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
source_type="contact",
|
||||
source_id=uuid.uuid4(),
|
||||
target_type="email",
|
||||
target_id=uuid.uuid4(),
|
||||
relationship_type="has_email",
|
||||
metadata={"verified": True, "primary": True},
|
||||
)
|
||||
|
||||
assert result["metadata"] == {"verified": True, "primary": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_relationship_no_metadata_defaults_to_empty(self):
|
||||
"""create_relationship defaults metadata to empty dict when None."""
|
||||
db = _mock_session()
|
||||
|
||||
dup_result = MagicMock()
|
||||
dup_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = dup_result
|
||||
|
||||
def _refresh_side_effect(obj):
|
||||
obj.id = uuid.uuid4()
|
||||
obj.created_at = datetime.now(UTC)
|
||||
|
||||
db.refresh.side_effect = _refresh_side_effect
|
||||
|
||||
result = await create_relationship(
|
||||
db=db,
|
||||
tenant_id=uuid.uuid4(),
|
||||
source_type="contact",
|
||||
source_id=uuid.uuid4(),
|
||||
target_type="task",
|
||||
target_id=uuid.uuid4(),
|
||||
relationship_type="assigned_to",
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
assert result["metadata"] == {}
|
||||
|
||||
|
||||
class TestTraverseGraph:
|
||||
"""Tests for traverse_graph() service function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_traverse_single_node_no_relationships(self):
|
||||
"""traverse_graph returns just the starting node when no relationships exist."""
|
||||
tenant_id = uuid.uuid4()
|
||||
source_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
# No outgoing or incoming relationships
|
||||
empty_result = MagicMock()
|
||||
empty_result.scalars.return_value.all.return_value = []
|
||||
db.execute.return_value = empty_result
|
||||
|
||||
result = await traverse_graph(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=source_id,
|
||||
max_hops=3,
|
||||
)
|
||||
|
||||
assert result["total_nodes"] == 1
|
||||
assert result["total_edges"] == 0
|
||||
assert result["nodes"][0]["entity_type"] == "contact"
|
||||
assert result["nodes"][0]["entity_id"] == str(source_id)
|
||||
assert result["nodes"][0]["depth"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_traverse_outgoing_relationships(self):
|
||||
"""traverse_graph follows outgoing relationships."""
|
||||
tenant_id = uuid.uuid4()
|
||||
source_id = uuid.uuid4()
|
||||
target_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
rel = _make_relationship(
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=source_id,
|
||||
target_type="company",
|
||||
target_id=target_id,
|
||||
relationship_type="works_for",
|
||||
)
|
||||
|
||||
rel_result = MagicMock()
|
||||
rel_result.scalars.return_value.all.return_value = [rel]
|
||||
|
||||
empty_result = MagicMock()
|
||||
empty_result.scalars.return_value.all.return_value = []
|
||||
|
||||
# First call: outgoing relationships from source
|
||||
# Second call: incoming relationships to source (empty)
|
||||
# Third call: outgoing from target (empty)
|
||||
# Fourth call: incoming to target (empty)
|
||||
db.execute.side_effect = [rel_result, empty_result, empty_result, empty_result]
|
||||
|
||||
result = await traverse_graph(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=source_id,
|
||||
max_hops=3,
|
||||
)
|
||||
|
||||
assert result["total_nodes"] == 2 # source + target
|
||||
assert result["total_edges"] == 1
|
||||
# Check depth of target node
|
||||
target_node = [n for n in result["nodes"] if n["entity_type"] == "company"][0]
|
||||
assert target_node["depth"] == 1
|
||||
assert target_node["path"] == ["works_for"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_traverse_incoming_relationships(self):
|
||||
"""traverse_graph follows incoming relationships (bidirectional)."""
|
||||
tenant_id = uuid.uuid4()
|
||||
source_id = uuid.uuid4()
|
||||
other_source_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
# An incoming relationship: other_source -> source
|
||||
incoming_rel = _make_relationship(
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=other_source_id,
|
||||
target_type="contact",
|
||||
target_id=source_id,
|
||||
relationship_type="knows",
|
||||
)
|
||||
|
||||
empty_result = MagicMock()
|
||||
empty_result.scalars.return_value.all.return_value = []
|
||||
|
||||
incoming_result = MagicMock()
|
||||
incoming_result.scalars.return_value.all.return_value = [incoming_rel]
|
||||
|
||||
# First call: outgoing from source (empty)
|
||||
# Second call: incoming to source (has the rel)
|
||||
# Third call: outgoing from other_source (empty)
|
||||
# Fourth call: incoming to other_source (empty)
|
||||
db.execute.side_effect = [empty_result, incoming_result, empty_result, empty_result]
|
||||
|
||||
result = await traverse_graph(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=source_id,
|
||||
max_hops=3,
|
||||
)
|
||||
|
||||
assert result["total_nodes"] == 2 # source + other_source
|
||||
assert result["total_edges"] == 1
|
||||
# The other source should be found via incoming relationship
|
||||
other_node = [n for n in result["nodes"] if n["entity_id"] == str(other_source_id)][0]
|
||||
assert other_node["depth"] == 1
|
||||
assert "inverse_knows" in other_node["path"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_traverse_max_hops_limit(self):
|
||||
"""traverse_graph respects max_hops limit."""
|
||||
tenant_id = uuid.uuid4()
|
||||
source_id = uuid.uuid4()
|
||||
mid_id = uuid.uuid4()
|
||||
far_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
# source -> mid
|
||||
rel1 = _make_relationship(
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=source_id,
|
||||
target_type="contact",
|
||||
target_id=mid_id,
|
||||
relationship_type="knows",
|
||||
)
|
||||
# mid -> far
|
||||
rel2 = _make_relationship(
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=mid_id,
|
||||
target_type="contact",
|
||||
target_id=far_id,
|
||||
relationship_type="knows",
|
||||
)
|
||||
|
||||
empty_result = MagicMock()
|
||||
empty_result.scalars.return_value.all.return_value = []
|
||||
|
||||
rel1_result = MagicMock()
|
||||
rel1_result.scalars.return_value.all.return_value = [rel1]
|
||||
|
||||
rel2_result = MagicMock()
|
||||
rel2_result.scalars.return_value.all.return_value = [rel2]
|
||||
|
||||
# With max_hops=1: only source and mid should be found
|
||||
# Call 1: outgoing from source -> [rel1]
|
||||
# Call 2: incoming to source -> []
|
||||
# Call 3: outgoing from mid -> [rel2] (but depth=1 >= max_hops=1, so mid's neighbors not queued)
|
||||
# Actually: mid is at depth 1, and max_hops=1 means depth >= 1 stops, so we still query
|
||||
# but the far node won't be added because depth+1 > max_hops... wait, the code checks depth >= max_hops
|
||||
# before querying. So at depth 1 >= max_hops 1, it won't query for mid's neighbors.
|
||||
# So only 2 DB calls: outgoing from source, incoming to source
|
||||
db.execute.side_effect = [rel1_result, empty_result]
|
||||
|
||||
result = await traverse_graph(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=source_id,
|
||||
max_hops=1,
|
||||
)
|
||||
|
||||
assert result["total_nodes"] == 2 # source + mid only
|
||||
# far_id should not be in nodes
|
||||
node_ids = [n["entity_id"] for n in result["nodes"]]
|
||||
assert str(far_id) not in node_ids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_traverse_relationship_types_filter(self):
|
||||
"""traverse_graph filters by relationship_types when provided."""
|
||||
tenant_id = uuid.uuid4()
|
||||
source_id = uuid.uuid4()
|
||||
target_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
# Only relationships matching the filter should be returned
|
||||
rel = _make_relationship(
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=source_id,
|
||||
target_type="company",
|
||||
target_id=target_id,
|
||||
relationship_type="works_for",
|
||||
)
|
||||
|
||||
rel_result = MagicMock()
|
||||
rel_result.scalars.return_value.all.return_value = [rel]
|
||||
|
||||
empty_result = MagicMock()
|
||||
empty_result.scalars.return_value.all.return_value = []
|
||||
|
||||
db.execute.side_effect = [rel_result, empty_result, empty_result, empty_result]
|
||||
|
||||
result = await traverse_graph(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=source_id,
|
||||
max_hops=3,
|
||||
relationship_types=["works_for"],
|
||||
)
|
||||
|
||||
assert result["total_nodes"] == 2
|
||||
assert result["edges"][0]["relationship_type"] == "works_for"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_traverse_avoids_cycles(self):
|
||||
"""traverse_graph does not revisit already-visited nodes."""
|
||||
tenant_id = uuid.uuid4()
|
||||
node_a = uuid.uuid4()
|
||||
node_b = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
# A -> B
|
||||
rel_a_to_b = _make_relationship(
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=node_a,
|
||||
target_type="contact",
|
||||
target_id=node_b,
|
||||
relationship_type="knows",
|
||||
)
|
||||
# B -> A (cycle)
|
||||
rel_b_to_a = _make_relationship(
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=node_b,
|
||||
target_type="contact",
|
||||
target_id=node_a,
|
||||
relationship_type="knows",
|
||||
)
|
||||
|
||||
# From A: outgoing -> [rel_a_to_b], incoming -> []
|
||||
# From B: outgoing -> [rel_b_to_a], incoming -> []
|
||||
# B's outgoing has rel_b_to_a but A is already visited, so edge is added but node is not
|
||||
from_a_out = MagicMock()
|
||||
from_a_out.scalars.return_value.all.return_value = [rel_a_to_b]
|
||||
|
||||
empty = MagicMock()
|
||||
empty.scalars.return_value.all.return_value = []
|
||||
|
||||
from_b_out = MagicMock()
|
||||
from_b_out.scalars.return_value.all.return_value = [rel_b_to_a]
|
||||
|
||||
db.execute.side_effect = [from_a_out, empty, from_b_out, empty]
|
||||
|
||||
result = await traverse_graph(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
source_type="contact",
|
||||
source_id=node_a,
|
||||
max_hops=3,
|
||||
)
|
||||
|
||||
# Only 2 nodes (A and B), but 2 edges (A->B and B->A)
|
||||
assert result["total_nodes"] == 2
|
||||
assert result["total_edges"] == 2
|
||||
|
||||
|
||||
class TestDeleteRelationship:
|
||||
"""Tests for delete_relationship() service function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_relationship_success(self):
|
||||
"""delete_relationship returns True when relationship exists."""
|
||||
tenant_id = uuid.uuid4()
|
||||
rel_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
rel = _make_relationship(tenant_id=tenant_id)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = rel
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await delete_relationship(db, tenant_id, rel_id)
|
||||
|
||||
assert result is True
|
||||
db.delete.assert_awaited_once_with(rel)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_relationship_not_found(self):
|
||||
"""delete_relationship returns False when relationship does not exist."""
|
||||
db = _mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await delete_relationship(db, uuid.uuid4(), uuid.uuid4())
|
||||
|
||||
assert result is False
|
||||
db.delete.assert_not_awaited()
|
||||
|
||||
|
||||
# ─── Model Tests ───
|
||||
|
||||
|
||||
class TestEntityRelationshipModel:
|
||||
"""Tests for the EntityRelationship model."""
|
||||
|
||||
def test_model_has_required_fields(self):
|
||||
"""EntityRelationship has source_type, source_id, target_type, target_id, relationship_type, metadata."""
|
||||
rel = EntityRelationship(
|
||||
tenant_id=uuid.uuid4(),
|
||||
source_type="contact",
|
||||
source_id=uuid.uuid4(),
|
||||
target_type="email",
|
||||
target_id=uuid.uuid4(),
|
||||
relationship_type="has_email",
|
||||
)
|
||||
assert rel.source_type == "contact"
|
||||
assert rel.target_type == "email"
|
||||
assert rel.relationship_type == "has_email"
|
||||
|
||||
def test_model_metadata_default(self):
|
||||
"""EntityRelationship has default=dict for metadata column."""
|
||||
col = EntityRelationship.__table__.c.metadata
|
||||
assert col.default.arg is dict or callable(col.default.arg)
|
||||
|
||||
def test_model_table_name(self):
|
||||
"""EntityRelationship uses correct table name."""
|
||||
assert EntityRelationship.__tablename__ == "entity_relationships"
|
||||
|
||||
|
||||
# ─── Route-Layer Tests ───
|
||||
|
||||
|
||||
def _create_graph_rag_app() -> FastAPI:
|
||||
"""Create a minimal FastAPI app with graph_rag router and mocked dependencies."""
|
||||
app = FastAPI()
|
||||
app.include_router(graph_rag_router)
|
||||
|
||||
async def _mock_get_db():
|
||||
db = _mock_session()
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class TestGraphRAGRoutes:
|
||||
"""Tests for GraphRAG API routes."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_relationship_route(self):
|
||||
"""POST /api/v1/graph/relationships creates a relationship."""
|
||||
app = _create_graph_rag_app()
|
||||
tenant_id = uuid.uuid4()
|
||||
source_id = uuid.uuid4()
|
||||
target_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(user_id),
|
||||
"tenant_id": str(tenant_id),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
# No duplicate
|
||||
dup_result = MagicMock()
|
||||
dup_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = dup_result
|
||||
|
||||
def _refresh_side_effect(obj):
|
||||
obj.id = uuid.uuid4()
|
||||
obj.created_at = datetime.now(UTC)
|
||||
|
||||
db.refresh.side_effect = _refresh_side_effect
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/graph/relationships",
|
||||
json={
|
||||
"source_type": "contact",
|
||||
"source_id": str(source_id),
|
||||
"target_type": "company",
|
||||
"target_id": str(target_id),
|
||||
"relationship_type": "works_for",
|
||||
"metadata": {"since": "2024"},
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["relationship_type"] == "works_for"
|
||||
assert data["source_id"] == str(source_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_relationship_route_duplicate(self):
|
||||
"""POST /api/v1/graph/relationships returns 409 for duplicate."""
|
||||
app = _create_graph_rag_app()
|
||||
tenant_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(tenant_id),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
existing = _make_relationship(tenant_id=tenant_id)
|
||||
dup_result = MagicMock()
|
||||
dup_result.scalar_one_or_none.return_value = existing
|
||||
db.execute.return_value = dup_result
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/graph/relationships",
|
||||
json={
|
||||
"source_type": "contact",
|
||||
"source_id": str(existing.source_id),
|
||||
"target_type": "company",
|
||||
"target_id": str(existing.target_id),
|
||||
"relationship_type": "works_for",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["code"] == "duplicate"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_traverse_route(self):
|
||||
"""POST /api/v1/graph/traverse returns traversal results."""
|
||||
app = _create_graph_rag_app()
|
||||
source_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
# No relationships found
|
||||
empty_result = MagicMock()
|
||||
empty_result.scalars.return_value.all.return_value = []
|
||||
db.execute.return_value = empty_result
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/graph/traverse",
|
||||
json={
|
||||
"source_type": "contact",
|
||||
"source_id": str(source_id),
|
||||
"max_hops": 3,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_nodes"] == 1
|
||||
assert data["nodes"][0]["entity_id"] == str(source_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_relationship_route_success(self):
|
||||
"""DELETE /api/v1/graph/relationships/{id} returns 204 on success."""
|
||||
app = _create_graph_rag_app()
|
||||
rel_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
rel = _make_relationship()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = rel
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.delete(f"/api/v1/graph/relationships/{rel_id}")
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_relationship_route_not_found(self):
|
||||
"""DELETE /api/v1/graph/relationships/{id} returns 404 when not found."""
|
||||
app = _create_graph_rag_app()
|
||||
rel_id = uuid.uuid4()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.delete(f"/api/v1/graph/relationships/{rel_id}")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_relationships_route(self):
|
||||
"""GET /api/v1/graph/relationships lists relationships with filters."""
|
||||
app = _create_graph_rag_app()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
rel = _make_relationship(relationship_type="works_for")
|
||||
count_result = MagicMock()
|
||||
count_result.scalar_one.return_value = 1
|
||||
|
||||
list_result = MagicMock()
|
||||
list_result.scalars.return_value.all.return_value = [rel]
|
||||
|
||||
db.execute.side_effect = [count_result, list_result]
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get(
|
||||
"/api/v1/graph/relationships",
|
||||
params={"relationship_type": "works_for"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["relationship_type"] == "works_for"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_relationship_invalid_uuid(self):
|
||||
"""POST /api/v1/graph/relationships returns 400 for invalid UUID."""
|
||||
app = _create_graph_rag_app()
|
||||
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/graph/relationships",
|
||||
json={
|
||||
"source_type": "contact",
|
||||
"source_id": "not-a-uuid",
|
||||
"target_type": "company",
|
||||
"target_id": str(uuid.uuid4()),
|
||||
"relationship_type": "works_for",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"]["code"] == "invalid_id"
|
||||
+818
-138
@@ -1,157 +1,837 @@
|
||||
"""Tests for marketplace plugin system: signature, quarantine, allowlist."""
|
||||
"""Tests for the Marketplace plugin — service and route layers.
|
||||
|
||||
Uses AsyncMock for all DB operations, httpx, and PluginSignature.
|
||||
No real DB or HTTP connections required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import tempfile
|
||||
import io
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.plugins.signature import PluginSignature
|
||||
from app.plugins.quarantine import (
|
||||
QuarantineError,
|
||||
_check_dangerous_imports,
|
||||
_check_migration_sql,
|
||||
_validate_manifest,
|
||||
from app.plugins.builtins.marketplace.models import MarketplaceListing
|
||||
from app.plugins.builtins.marketplace.routes import router as marketplace_router
|
||||
from app.plugins.builtins.marketplace.services import (
|
||||
download_plugin,
|
||||
fetch_listings,
|
||||
get_categories,
|
||||
get_listing_by_name,
|
||||
install_plugin,
|
||||
verify_plugin,
|
||||
)
|
||||
|
||||
|
||||
class TestPluginSignature:
|
||||
def test_compute_hash(self, tmp_path):
|
||||
"""compute_hash returns a valid SHA-256 hex string."""
|
||||
test_file = tmp_path / "test.zip"
|
||||
test_file.write_bytes(b"test content")
|
||||
h = PluginSignature.compute_hash(test_file)
|
||||
assert len(h) == 64 # SHA-256 hex
|
||||
assert h == hashlib.sha256(b"test content").hexdigest()
|
||||
|
||||
def test_verify_signature_without_pynacl(self, tmp_path):
|
||||
"""verify_signature returns False if PyNaCl is not installed."""
|
||||
test_file = tmp_path / "test.zip"
|
||||
test_file.write_bytes(b"test")
|
||||
# Without PyNaCl installed, returns False
|
||||
result = PluginSignature.verify_signature(test_file, b"sig", b"key")
|
||||
assert result in (False, True) # Depends on whether pynacl is installed
|
||||
# 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
|
||||
|
||||
|
||||
class TestQuarantineValidation:
|
||||
def test_validate_manifest_valid(self, tmp_path):
|
||||
"""_validate_manifest passes for a valid plugin structure."""
|
||||
plugin_dir = tmp_path / "test_plugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "plugin.py").write_text(
|
||||
"from app.plugins.base import BasePlugin\n"
|
||||
"from app.plugins.manifest import PluginManifest\n"
|
||||
"class TestPlugin(BasePlugin):\n"
|
||||
" manifest = PluginManifest(name='test', version='1.0.0', display_name='Test')\n"
|
||||
)
|
||||
result = _validate_manifest(plugin_dir)
|
||||
assert result["has_manifest"] is True
|
||||
|
||||
def test_validate_manifest_missing(self, tmp_path):
|
||||
"""_validate_manifest raises for missing plugin.py."""
|
||||
with pytest.raises(QuarantineError, match="plugin.py or __init__.py"):
|
||||
_validate_manifest(tmp_path)
|
||||
|
||||
def test_validate_manifest_no_manifest(self, tmp_path):
|
||||
"""_validate_manifest raises when PluginManifest is missing."""
|
||||
plugin_dir = tmp_path / "test_plugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "plugin.py").write_text("print('hello')")
|
||||
with pytest.raises(QuarantineError, match="PluginManifest"):
|
||||
_validate_manifest(plugin_dir)
|
||||
|
||||
def test_check_dangerous_imports_clean(self, tmp_path):
|
||||
"""_check_dangerous_imports returns empty for safe code."""
|
||||
plugin_dir = tmp_path / "safe_plugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "plugin.py").write_text(
|
||||
"import logging\n"
|
||||
"from app.plugins.base import BasePlugin\n"
|
||||
)
|
||||
result = _check_dangerous_imports(plugin_dir)
|
||||
assert result == []
|
||||
|
||||
def test_check_dangerous_imports_found(self, tmp_path):
|
||||
"""_check_dangerous_imports detects dangerous patterns."""
|
||||
plugin_dir = tmp_path / "dangerous_plugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "plugin.py").write_text(
|
||||
"import os\n"
|
||||
"os.system('rm -rf /')\n"
|
||||
)
|
||||
result = _check_dangerous_imports(plugin_dir)
|
||||
assert len(result) > 0
|
||||
assert any("os.system" in r for r in result)
|
||||
|
||||
def test_check_migration_sql_no_migrations(self, tmp_path):
|
||||
"""_check_migration_sql returns empty when no migrations dir."""
|
||||
result = _check_migration_sql(tmp_path)
|
||||
assert result == []
|
||||
|
||||
def test_check_migration_sql_valid(self, tmp_path):
|
||||
"""_check_migration_sql passes for valid SQL with tenant_id."""
|
||||
migrations = tmp_path / "migrations"
|
||||
migrations.mkdir()
|
||||
(migrations / "0001_initial.sql").write_text(
|
||||
"CREATE TABLE items (id UUID, tenant_id UUID NOT NULL);\n"
|
||||
)
|
||||
result = _check_migration_sql(tmp_path)
|
||||
assert result == []
|
||||
|
||||
def test_check_migration_sql_missing_tenant_id(self, tmp_path):
|
||||
"""_check_migration_sql detects missing tenant_id."""
|
||||
migrations = tmp_path / "migrations"
|
||||
migrations.mkdir()
|
||||
(migrations / "0001_initial.sql").write_text(
|
||||
"CREATE TABLE items (id UUID);\n"
|
||||
)
|
||||
result = _check_migration_sql(tmp_path)
|
||||
assert len(result) > 0
|
||||
assert "tenant_id" in result[0]
|
||||
|
||||
def test_check_migration_sql_drop_database(self, tmp_path):
|
||||
"""_check_migration_sql detects DROP DATABASE."""
|
||||
migrations = tmp_path / "migrations"
|
||||
migrations.mkdir()
|
||||
(migrations / "0001_initial.sql").write_text(
|
||||
"DROP DATABASE leocrm;\n"
|
||||
)
|
||||
result = _check_migration_sql(tmp_path)
|
||||
assert len(result) > 0
|
||||
assert "DROP" in result[0]
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_tables(db_setup):
|
||||
"""No-op override of conftest clean_tables."""
|
||||
yield
|
||||
|
||||
|
||||
class TestManifestMarketplaceFields:
|
||||
def test_manifest_has_marketplace_fields(self):
|
||||
"""PluginManifest has marketplace fields."""
|
||||
from app.plugins.manifest import PluginManifest
|
||||
m = PluginManifest(
|
||||
name="test",
|
||||
version="1.0.0",
|
||||
display_name="Test",
|
||||
@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_listing(
|
||||
*,
|
||||
name: str = "test_plugin",
|
||||
display_name: str = "Test Plugin",
|
||||
version: str = "1.0.0",
|
||||
download_url: str = "https://example.com/plugin.zip",
|
||||
tags: list[str] | None = None,
|
||||
is_verified: bool = True,
|
||||
signature_public_key: str = "",
|
||||
download_count: int = 0,
|
||||
) -> MarketplaceListing:
|
||||
"""Create a MarketplaceListing instance with defaults."""
|
||||
return MarketplaceListing(
|
||||
id=uuid.uuid4(),
|
||||
name=name,
|
||||
display_name=display_name,
|
||||
description="A test plugin",
|
||||
version=version,
|
||||
author="Test Author",
|
||||
homepage="https://example.com",
|
||||
download_url=download_url,
|
||||
signature_public_key=signature_public_key,
|
||||
icon="",
|
||||
screenshots=[],
|
||||
tags=tags or ["productivity"],
|
||||
price=0.0,
|
||||
is_verified=is_verified,
|
||||
download_count=download_count,
|
||||
min_app_version="0.0.0",
|
||||
license="MIT",
|
||||
min_app_version="1.0.0",
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
assert m.author == "Test Author"
|
||||
assert m.license == "MIT"
|
||||
assert m.min_app_version == "1.0.0"
|
||||
assert m.contract_version == "1.0.0"
|
||||
assert m.hooks == []
|
||||
|
||||
def test_manifest_marketplace_optional_fields(self):
|
||||
"""PluginManifest marketplace fields have defaults."""
|
||||
from app.plugins.manifest import PluginManifest
|
||||
m = PluginManifest(
|
||||
name="test",
|
||||
version="1.0.0",
|
||||
display_name="Test",
|
||||
|
||||
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_valid_zip() -> bytes:
|
||||
"""Create a valid ZIP file in memory."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("plugin.py", "# test plugin\n")
|
||||
zf.writestr("manifest.json", '{"name": "test_plugin"}')
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ─── Service-Layer Tests ───
|
||||
|
||||
|
||||
class TestFetchListings:
|
||||
"""Tests for fetch_listings() service function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_listings_basic(self):
|
||||
"""fetch_listings returns paginated listings."""
|
||||
db = _mock_session()
|
||||
|
||||
listing = _make_listing()
|
||||
|
||||
# Mock count query
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 1
|
||||
|
||||
# Mock paginated query
|
||||
list_result = MagicMock()
|
||||
list_result.scalars.return_value.all.return_value = [listing]
|
||||
|
||||
db.execute.side_effect = [count_result, list_result]
|
||||
|
||||
result = await fetch_listings(db, page=1, page_size=20)
|
||||
|
||||
assert result["total"] == 1
|
||||
assert len(result["listings"]) == 1
|
||||
assert result["listings"][0]["name"] == "test_plugin"
|
||||
assert result["page"] == 1
|
||||
assert result["page_size"] == 20
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_listings_with_search(self):
|
||||
"""fetch_listings filters by search term."""
|
||||
db = _mock_session()
|
||||
|
||||
listing = _make_listing(name="my_cool_plugin", display_name="My Cool Plugin")
|
||||
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 1
|
||||
|
||||
list_result = MagicMock()
|
||||
list_result.scalars.return_value.all.return_value = [listing]
|
||||
|
||||
db.execute.side_effect = [count_result, list_result]
|
||||
|
||||
result = await fetch_listings(db, search="cool")
|
||||
|
||||
assert result["total"] == 1
|
||||
assert result["listings"][0]["display_name"] == "My Cool Plugin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_listings_with_tag_filter(self):
|
||||
"""fetch_listings filters by tags."""
|
||||
db = _mock_session()
|
||||
|
||||
listing = _make_listing(tags=["productivity", "automation"])
|
||||
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 1
|
||||
|
||||
list_result = MagicMock()
|
||||
list_result.scalars.return_value.all.return_value = [listing]
|
||||
|
||||
db.execute.side_effect = [count_result, list_result]
|
||||
|
||||
result = await fetch_listings(db, tags=["productivity"])
|
||||
|
||||
assert result["total"] == 1
|
||||
assert "productivity" in result["listings"][0]["tags"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_listings_empty(self):
|
||||
"""fetch_listings returns empty list when no listings exist."""
|
||||
db = _mock_session()
|
||||
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 0
|
||||
|
||||
list_result = MagicMock()
|
||||
list_result.scalars.return_value.all.return_value = []
|
||||
|
||||
db.execute.side_effect = [count_result, list_result]
|
||||
|
||||
result = await fetch_listings(db)
|
||||
|
||||
assert result["total"] == 0
|
||||
assert len(result["listings"]) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_listings_pagination(self):
|
||||
"""fetch_listings respects page and page_size."""
|
||||
db = _mock_session()
|
||||
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 50
|
||||
|
||||
list_result = MagicMock()
|
||||
list_result.scalars.return_value.all.return_value = []
|
||||
|
||||
db.execute.side_effect = [count_result, list_result]
|
||||
|
||||
result = await fetch_listings(db, page=3, page_size=10)
|
||||
|
||||
assert result["page"] == 3
|
||||
assert result["page_size"] == 10
|
||||
assert result["total"] == 50
|
||||
|
||||
|
||||
class TestGetListingByName:
|
||||
"""Tests for get_listing_by_name() service function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_listing_by_name_found(self):
|
||||
"""get_listing_by_name returns listing when found."""
|
||||
db = _mock_session()
|
||||
listing = _make_listing(name="my_plugin")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = listing
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await get_listing_by_name(db, "my_plugin")
|
||||
|
||||
assert result is not None
|
||||
assert result.name == "my_plugin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_listing_by_name_not_found(self):
|
||||
"""get_listing_by_name returns None when not found."""
|
||||
db = _mock_session()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await get_listing_by_name(db, "nonexistent")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestDownloadPlugin:
|
||||
"""Tests for download_plugin() service function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_plugin_success(self):
|
||||
"""download_plugin downloads and returns a valid ZIP path."""
|
||||
zip_content = _create_valid_zip()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = zip_content
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("app.plugins.builtins.marketplace.services.httpx.AsyncClient", return_value=mock_client):
|
||||
zip_path = await download_plugin("test_plugin", "https://example.com/plugin.zip")
|
||||
|
||||
assert zip_path.exists()
|
||||
assert zipfile.is_zipfile(zip_path)
|
||||
# Cleanup
|
||||
import shutil
|
||||
shutil.rmtree(zip_path.parent, ignore_errors=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_plugin_invalid_zip(self):
|
||||
"""download_plugin raises ValueError for invalid ZIP."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = b"not a zip file"
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("app.plugins.builtins.marketplace.services.httpx.AsyncClient", return_value=mock_client):
|
||||
with pytest.raises(ValueError, match="not a valid ZIP"):
|
||||
await download_plugin("test_plugin", "https://example.com/plugin.zip")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_plugin_size_limit(self):
|
||||
"""download_plugin raises ValueError when ZIP exceeds size limit."""
|
||||
# Create content larger than MARKETPLACE_MAX_ZIP_SIZE
|
||||
from app.plugins.builtins.marketplace.config import MARKETPLACE_MAX_ZIP_SIZE
|
||||
|
||||
large_content = b"x" * (MARKETPLACE_MAX_ZIP_SIZE + 1)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = large_content
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("app.plugins.builtins.marketplace.services.httpx.AsyncClient", return_value=mock_client):
|
||||
with pytest.raises(ValueError, match="too large"):
|
||||
await download_plugin("test_plugin", "https://example.com/plugin.zip")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_plugin_empty_url(self):
|
||||
"""download_plugin raises ValueError for empty download URL."""
|
||||
with pytest.raises(ValueError, match="No download URL"):
|
||||
await download_plugin("test_plugin", "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_plugin_http_error(self):
|
||||
"""download_plugin raises ValueError on HTTP error."""
|
||||
import httpx
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.get = AsyncMock(side_effect=httpx.HTTPError("Connection failed"))
|
||||
|
||||
with patch("app.plugins.builtins.marketplace.services.httpx.AsyncClient", return_value=mock_client):
|
||||
with pytest.raises(ValueError, match="Failed to download"):
|
||||
await download_plugin("test_plugin", "https://example.com/plugin.zip")
|
||||
|
||||
|
||||
class TestVerifyPlugin:
|
||||
"""Tests for verify_plugin() service function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_plugin_missing_signature(self):
|
||||
"""verify_plugin returns False when signature is missing."""
|
||||
zip_path = Path("/tmp/fake.zip")
|
||||
result = await verify_plugin(zip_path, signature=None, public_key=b"some_key")
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_plugin_missing_public_key(self):
|
||||
"""verify_plugin returns False when public_key is missing."""
|
||||
zip_path = Path("/tmp/fake.zip")
|
||||
result = await verify_plugin(zip_path, signature=b"some_sig", public_key=None)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_plugin_both_missing(self):
|
||||
"""verify_plugin returns False when both signature and public_key are missing."""
|
||||
zip_path = Path("/tmp/fake.zip")
|
||||
result = await verify_plugin(zip_path, signature=None, public_key=None)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_plugin_valid_signature(self):
|
||||
"""verify_plugin returns True when PluginSignature.verify_signature succeeds."""
|
||||
zip_path = Path("/tmp/fake.zip")
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.marketplace.services.PluginSignature.verify_signature",
|
||||
return_value=True,
|
||||
) as mock_verify:
|
||||
result = await verify_plugin(
|
||||
zip_path=zip_path,
|
||||
signature=b"valid_sig",
|
||||
public_key=b"valid_key",
|
||||
)
|
||||
assert m.author == ""
|
||||
assert m.homepage == ""
|
||||
assert m.license == "MIT"
|
||||
assert m.price == 0.0
|
||||
assert m.screenshots == []
|
||||
assert m.marketplace_tags == []
|
||||
|
||||
assert result is True
|
||||
mock_verify.assert_called_once_with(
|
||||
zip_path=zip_path,
|
||||
signature=b"valid_sig",
|
||||
public_key=b"valid_key",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_plugin_invalid_signature(self):
|
||||
"""verify_plugin returns False when PluginSignature.verify_signature fails."""
|
||||
zip_path = Path("/tmp/fake.zip")
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.marketplace.services.PluginSignature.verify_signature",
|
||||
return_value=False,
|
||||
):
|
||||
result = await verify_plugin(
|
||||
zip_path=zip_path,
|
||||
signature=b"invalid_sig",
|
||||
public_key=b"valid_key",
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_plugin_exception_returns_false(self):
|
||||
"""verify_plugin returns False when verification raises an exception."""
|
||||
zip_path = Path("/tmp/fake.zip")
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.marketplace.services.PluginSignature.verify_signature",
|
||||
side_effect=Exception("Verification error"),
|
||||
):
|
||||
result = await verify_plugin(
|
||||
zip_path=zip_path,
|
||||
signature=b"sig",
|
||||
public_key=b"key",
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestInstallPlugin:
|
||||
"""Tests for install_plugin() service function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_plugin_not_found(self):
|
||||
"""install_plugin raises ValueError when plugin not in marketplace."""
|
||||
db = _mock_session()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
with pytest.raises(ValueError, match="not found in marketplace"):
|
||||
await install_plugin(db, "nonexistent_plugin")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_plugin_success(self):
|
||||
"""install_plugin downloads, verifies, and installs a plugin."""
|
||||
db = _mock_session()
|
||||
listing = _make_listing(name="test_plugin", signature_public_key="")
|
||||
|
||||
# get_listing_by_name returns listing
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = listing
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
zip_content = _create_valid_zip()
|
||||
|
||||
# Mock download_plugin
|
||||
temp_dir = Path("/tmp/marketplace_test_install")
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
zip_path = temp_dir / "test_plugin.zip"
|
||||
zip_path.write_bytes(zip_content)
|
||||
|
||||
# Mock plugin service
|
||||
mock_service = MagicMock()
|
||||
mock_service.install_plugin_from_zip = AsyncMock(return_value={"success": True})
|
||||
mock_service.activate_plugin = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.marketplace.services.download_plugin", AsyncMock(return_value=zip_path)),
|
||||
patch("app.services.plugin_service.get_plugin_service", return_value=mock_service),
|
||||
patch("app.plugins.builtins.marketplace.services.PluginSignature.compute_hash", return_value="fake_hash"),
|
||||
):
|
||||
result = await install_plugin(db, "test_plugin", activate=False)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["name"] == "test_plugin"
|
||||
assert result["installed"] is True
|
||||
assert result["activated"] is False
|
||||
mock_service.install_plugin_from_zip.assert_awaited_once()
|
||||
|
||||
# Cleanup
|
||||
import shutil
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_plugin_with_activate(self):
|
||||
"""install_plugin activates plugin when activate=True."""
|
||||
db = _mock_session()
|
||||
listing = _make_listing(name="test_plugin")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = listing
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
zip_content = _create_valid_zip()
|
||||
temp_dir = Path("/tmp/marketplace_test_activate")
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
zip_path = temp_dir / "test_plugin.zip"
|
||||
zip_path.write_bytes(zip_content)
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.install_plugin_from_zip = AsyncMock(return_value={"success": True})
|
||||
mock_service.activate_plugin = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("app.plugins.builtins.marketplace.services.download_plugin", AsyncMock(return_value=zip_path)),
|
||||
patch("app.services.plugin_service.get_plugin_service", return_value=mock_service),
|
||||
patch("app.plugins.builtins.marketplace.services.PluginSignature.compute_hash", return_value="fake_hash"),
|
||||
):
|
||||
result = await install_plugin(db, "test_plugin", activate=True)
|
||||
|
||||
assert result["activated"] is True
|
||||
mock_service.activate_plugin.assert_awaited_once()
|
||||
|
||||
import shutil
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_plugin_download_failure(self):
|
||||
"""install_plugin raises ValueError when download fails."""
|
||||
db = _mock_session()
|
||||
listing = _make_listing(name="test_plugin")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = listing
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.marketplace.services.download_plugin",
|
||||
AsyncMock(side_effect=ValueError("Download failed")),
|
||||
):
|
||||
with pytest.raises(ValueError, match="Download failed"):
|
||||
await install_plugin(db, "test_plugin")
|
||||
|
||||
|
||||
class TestGetCategories:
|
||||
"""Tests for get_categories() service function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_categories_returns_unique_tags(self):
|
||||
"""get_categories returns sorted unique tags from all listings."""
|
||||
db = _mock_session()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [
|
||||
["productivity", "automation"],
|
||||
["communication"],
|
||||
["productivity", "ai"],
|
||||
]
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await get_categories(db)
|
||||
|
||||
assert result == ["ai", "automation", "communication", "productivity"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_categories_empty(self):
|
||||
"""get_categories returns empty list when no listings exist."""
|
||||
db = _mock_session()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await get_categories(db)
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_categories_with_none_tags(self):
|
||||
"""get_categories handles None tag values gracefully."""
|
||||
db = _mock_session()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [
|
||||
["productivity"],
|
||||
None,
|
||||
[],
|
||||
]
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
result = await get_categories(db)
|
||||
|
||||
assert result == ["productivity"]
|
||||
|
||||
|
||||
# ─── Model Tests ───
|
||||
|
||||
|
||||
class TestMarketplaceListingModel:
|
||||
"""Tests for the MarketplaceListing model."""
|
||||
|
||||
def test_model_has_required_fields(self):
|
||||
"""MarketplaceListing has name, display_name, version, download_url."""
|
||||
listing = MarketplaceListing(
|
||||
name="test_plugin",
|
||||
display_name="Test Plugin",
|
||||
version="1.0.0",
|
||||
download_url="https://example.com/plugin.zip",
|
||||
)
|
||||
assert listing.name == "test_plugin"
|
||||
assert listing.display_name == "Test Plugin"
|
||||
assert listing.version == "1.0.0"
|
||||
assert listing.download_url == "https://example.com/plugin.zip"
|
||||
|
||||
def test_model_defaults(self):
|
||||
"""MarketplaceListing has correct column defaults."""
|
||||
cols = MarketplaceListing.__table__.c
|
||||
assert cols.price.default.arg == 0.0
|
||||
assert cols.is_verified.default.arg is False
|
||||
assert cols.download_count.default.arg == 0
|
||||
assert cols.license.default.arg == "MIT"
|
||||
assert cols.min_app_version.default.arg == "0.0.0"
|
||||
|
||||
def test_model_table_name(self):
|
||||
"""MarketplaceListing uses correct table name."""
|
||||
assert MarketplaceListing.__tablename__ == "marketplace_listings"
|
||||
|
||||
def test_model_is_global_not_tenant_scoped(self):
|
||||
"""MarketplaceListing does NOT have tenant_id (global table)."""
|
||||
# MarketplaceListing should not inherit TenantMixin
|
||||
assert not hasattr(MarketplaceListing, "tenant_id")
|
||||
|
||||
|
||||
# ─── Route-Layer Tests ───
|
||||
|
||||
|
||||
def _create_marketplace_app() -> FastAPI:
|
||||
"""Create a minimal FastAPI app with marketplace router and mocked dependencies."""
|
||||
app = FastAPI()
|
||||
app.include_router(marketplace_router)
|
||||
|
||||
async def _mock_get_db():
|
||||
db = _mock_session()
|
||||
yield db
|
||||
|
||||
async def _mock_get_current_user():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
async def _mock_require_admin():
|
||||
return {
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"tenant_id": str(uuid.uuid4()),
|
||||
"is_system_admin": True,
|
||||
}
|
||||
|
||||
from app.deps import get_current_user, require_admin
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
||||
app.dependency_overrides[require_admin] = _mock_require_admin
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class TestMarketplaceRoutes:
|
||||
"""Tests for marketplace API routes."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_listings_route(self):
|
||||
"""GET /api/v1/marketplace/listings returns listings."""
|
||||
app = _create_marketplace_app()
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
|
||||
listing = _make_listing()
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 1
|
||||
list_result = MagicMock()
|
||||
list_result.scalars.return_value.all.return_value = [listing]
|
||||
db.execute.side_effect = [count_result, list_result]
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/api/v1/marketplace/listings")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert len(data["listings"]) == 1
|
||||
assert data["listings"][0]["name"] == "test_plugin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_listing_by_name_route(self):
|
||||
"""GET /api/v1/marketplace/listings/{name} returns listing details."""
|
||||
app = _create_marketplace_app()
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
|
||||
listing = _make_listing(name="my_plugin", display_name="My Plugin")
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = listing
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/api/v1/marketplace/listings/my_plugin")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "my_plugin"
|
||||
assert data["display_name"] == "My Plugin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_listing_by_name_not_found_route(self):
|
||||
"""GET /api/v1/marketplace/listings/{name} returns 404 when not found."""
|
||||
app = _create_marketplace_app()
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/api/v1/marketplace/listings/nonexistent")
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["detail"]["code"] == "not_found"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_categories_route(self):
|
||||
"""GET /api/v1/marketplace/categories returns categories."""
|
||||
app = _create_marketplace_app()
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [
|
||||
["productivity", "ai"],
|
||||
["communication"],
|
||||
]
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/api/v1/marketplace/categories")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 3
|
||||
assert "productivity" in data["categories"]
|
||||
assert "ai" in data["categories"]
|
||||
assert "communication" in data["categories"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_plugin_route_no_signature(self):
|
||||
"""POST /api/v1/marketplace/verify/{name} returns signature_valid=False when no signature provided."""
|
||||
app = _create_marketplace_app()
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
|
||||
listing = _make_listing(name="test_plugin", signature_public_key="")
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = listing
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/marketplace/verify/test_plugin",
|
||||
json={"name": "test_plugin"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["signature_valid"] is False
|
||||
assert data["name"] == "test_plugin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_plugin_route_not_found(self):
|
||||
"""POST /api/v1/marketplace/verify/{name} returns 404 when plugin not found."""
|
||||
app = _create_marketplace_app()
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/marketplace/verify/nonexistent",
|
||||
json={"name": "nonexistent"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_plugin_route_not_found(self):
|
||||
"""POST /api/v1/marketplace/install/{name} returns 400 when plugin not found."""
|
||||
app = _create_marketplace_app()
|
||||
db = _mock_session()
|
||||
|
||||
async def _mock_get_db():
|
||||
yield db
|
||||
|
||||
from app.core.db import get_db
|
||||
app.dependency_overrides[get_db] = _mock_get_db
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
db.execute.return_value = mock_result
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/marketplace/install/nonexistent",
|
||||
json={"name": "nonexistent"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"]["code"] == "install_error"
|
||||
|
||||
Reference in New Issue
Block a user