219 lines
8.8 KiB
Python
219 lines
8.8 KiB
Python
"""Unit tests for CrmAgent — plan, gather, reason, report, peer_bus, sweep."""
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from agent_service.agents.crm_agent import CrmAgent, CRM_TOOLS
|
|
from agent_service.agents.base_agent import AgentDirective, AgentReport, SweepReport
|
|
|
|
|
|
def _directive(intent='', context=None):
|
|
return AgentDirective(
|
|
directive_id='crm-d1', user_id='1', intent=intent,
|
|
context=context or {}, agent_name='crm_agent',
|
|
)
|
|
|
|
|
|
def _make_agent():
|
|
agent = CrmAgent(odoo=MagicMock(), llm=MagicMock())
|
|
agent._ct = MagicMock()
|
|
agent._ct.get_pipeline_summary = AsyncMock(return_value={
|
|
'total_opportunities': 10, 'weighted_pipeline': 50000.0,
|
|
})
|
|
agent._ct.get_leads = AsyncMock(return_value=[])
|
|
agent._ct.get_opportunities = AsyncMock(return_value=[])
|
|
agent._ct.get_won_lost_analysis = AsyncMock(return_value={'won_count': 5, 'lost_count': 2})
|
|
agent._ct.update_lead_stage = AsyncMock(return_value=True)
|
|
agent._ct.assign_lead = AsyncMock(return_value=True)
|
|
agent._ct.log_activity = AsyncMock(return_value=True)
|
|
agent._ct.post_chatter_note = AsyncMock(return_value=True)
|
|
return agent
|
|
|
|
|
|
# ── Meta ────────────────────────────────────────────────────────────────────
|
|
|
|
def test_tool_count():
|
|
assert len(CRM_TOOLS) <= 8
|
|
|
|
def test_agent_name():
|
|
assert CrmAgent.name == 'crm_agent'
|
|
|
|
|
|
# ── _plan ───────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plan_pipeline_intent():
|
|
agent = _make_agent()
|
|
plan = await agent._plan(_directive(intent='show pipeline summary'))
|
|
assert plan['fetch_pipeline'] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plan_leads_intent():
|
|
agent = _make_agent()
|
|
plan = await agent._plan(_directive(intent='list my leads'))
|
|
assert plan['fetch_leads'] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plan_opportunities_intent():
|
|
agent = _make_agent()
|
|
plan = await agent._plan(_directive(intent='show opportunities'))
|
|
assert plan['fetch_opportunities'] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plan_won_lost_intent():
|
|
agent = _make_agent()
|
|
plan = await agent._plan(_directive(intent='show won and lost analysis'))
|
|
assert plan['fetch_won_lost'] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plan_propagates_user_id():
|
|
agent = _make_agent()
|
|
plan = await agent._plan(_directive(context={'user_id': 7}))
|
|
assert plan['user_id'] == 7
|
|
|
|
|
|
# ── _gather ─────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gather_pipeline_fetched_by_default():
|
|
agent = _make_agent()
|
|
ctx = {'plan': {'fetch_pipeline': True, 'fetch_leads': False,
|
|
'fetch_opportunities': False, 'fetch_won_lost': False, 'user_id': None}}
|
|
data = await agent._gather(ctx)
|
|
assert 'pipeline' in data
|
|
agent._ct.get_pipeline_summary.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gather_leads_when_requested():
|
|
agent = _make_agent()
|
|
agent._ct.get_leads = AsyncMock(return_value=[{'id': 1, 'name': 'Lead A'}])
|
|
ctx = {'plan': {'fetch_pipeline': False, 'fetch_leads': True,
|
|
'fetch_opportunities': False, 'fetch_won_lost': False, 'user_id': None}}
|
|
data = await agent._gather(ctx)
|
|
assert 'leads' in data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gather_won_lost_when_requested():
|
|
agent = _make_agent()
|
|
ctx = {'plan': {'fetch_pipeline': False, 'fetch_leads': False,
|
|
'fetch_opportunities': False, 'fetch_won_lost': True, 'user_id': None}}
|
|
data = await agent._gather(ctx)
|
|
assert 'won_lost' in data
|
|
|
|
|
|
# ── _reason ─────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reason_low_pipeline_escalates():
|
|
agent = _make_agent()
|
|
agent._gathered_data = {'pipeline': {'weighted_pipeline': 500.0, 'total_opportunities': 2}}
|
|
analysis = await agent._reason({})
|
|
assert len(analysis['escalations']) == 1
|
|
assert 'Low weighted pipeline' in analysis['escalations'][0]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reason_healthy_pipeline_no_escalation():
|
|
agent = _make_agent()
|
|
agent._gathered_data = {'pipeline': {'weighted_pipeline': 100000.0, 'total_opportunities': 20}}
|
|
analysis = await agent._reason({})
|
|
assert analysis['escalations'] == []
|
|
|
|
|
|
# ── _report ─────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_report_includes_pipeline_stats():
|
|
agent = _make_agent()
|
|
agent._gathered_data = {
|
|
'pipeline': {'total_opportunities': 15, 'weighted_pipeline': 75000.0}
|
|
}
|
|
agent._escalations_list = []
|
|
report = await agent._report({})
|
|
assert isinstance(report, AgentReport)
|
|
assert '15' in report.summary
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_report_includes_won_lost():
|
|
agent = _make_agent()
|
|
agent._gathered_data = {'won_lost': {'won_count': 8, 'lost_count': 3}}
|
|
agent._escalations_list = []
|
|
report = await agent._report({})
|
|
assert '8' in report.summary
|
|
assert '3' in report.summary
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_report_fallback_message():
|
|
agent = _make_agent()
|
|
agent._gathered_data = {}
|
|
agent._escalations_list = []
|
|
report = await agent._report({})
|
|
assert 'crm' in report.summary.lower() or 'complete' in report.summary.lower()
|
|
|
|
|
|
# ── _dispatch_tool ───────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_get_pipeline_summary():
|
|
agent = _make_agent()
|
|
await agent._dispatch_tool('get_pipeline_summary', {})
|
|
agent._ct.get_pipeline_summary.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_update_lead_stage():
|
|
agent = _make_agent()
|
|
await agent._dispatch_tool('update_lead_stage', {'lead_id': 1, 'stage_id': 3})
|
|
agent._ct.update_lead_stage.assert_awaited_once_with(lead_id=1, stage_id=3)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_unknown_tool_raises():
|
|
agent = _make_agent()
|
|
with pytest.raises(ValueError, match='Unknown tool'):
|
|
await agent._dispatch_tool('nonexistent', {})
|
|
|
|
|
|
# ── handle_peer_request ──────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_peer_pipeline_summary():
|
|
agent = _make_agent()
|
|
result = await agent.handle_peer_request('pipeline_summary', {}, 'dir-1')
|
|
assert 'total_opportunities' in result or 'weighted_pipeline' in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_peer_opportunities():
|
|
agent = _make_agent()
|
|
agent._ct.get_opportunities = AsyncMock(return_value=[{'id': 1}])
|
|
result = await agent.handle_peer_request('opportunities', {'user_id': 5}, 'dir-1')
|
|
assert 'opportunities' in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_peer_unknown_returns_error():
|
|
agent = _make_agent()
|
|
result = await agent.handle_peer_request('bad_type', {}, 'dir-1')
|
|
assert 'error' in result
|
|
|
|
|
|
# ── sweep ────────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sweep_low_pipeline_generates_finding():
|
|
agent = _make_agent()
|
|
agent._ct.get_pipeline_summary = AsyncMock(return_value={'weighted_pipeline': 100.0})
|
|
result = await agent.sweep()
|
|
assert isinstance(result, SweepReport)
|
|
assert len(result.findings) == 1
|
|
assert result.findings[0]['type'] == 'low_pipeline'
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sweep_healthy_pipeline_no_findings():
|
|
agent = _make_agent()
|
|
agent._ct.get_pipeline_summary = AsyncMock(return_value={'weighted_pipeline': 99999.0})
|
|
result = await agent.sweep()
|
|
assert result.findings == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sweep_handles_exception():
|
|
agent = _make_agent()
|
|
agent._ct.get_pipeline_summary = AsyncMock(side_effect=Exception('network error'))
|
|
result = await agent.sweep()
|
|
assert isinstance(result, SweepReport)
|
|
assert result.error is not None
|