""" Integration tests for portal agent CRUD endpoints (PRTA-02). Tests: create (all fields / minimal fields), list (tenant isolation), get, update, delete. Proves all Agent Designer fields are stored and retrievable. """ from __future__ import annotations import uuid import pytest import pytest_asyncio from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from shared.api.portal import portal_router from shared.db import get_session def make_app(session: AsyncSession) -> FastAPI: """Build a FastAPI test app with the portal router and session override.""" app = FastAPI() app.include_router(portal_router) async def override_get_session(): # type: ignore[return] yield session app.dependency_overrides[get_session] = override_get_session return app @pytest_asyncio.fixture async def client(db_session: AsyncSession): # type: ignore[no-untyped-def] """HTTP client wired to the portal API with the test DB session.""" app = make_app(db_session) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: yield c @pytest_asyncio.fixture async def tenant_id(client: AsyncClient) -> str: """Create a tenant and return its ID for agent tests.""" slug = f"agent-test-{uuid.uuid4().hex[:8]}" resp = await client.post( "/api/portal/tenants", json={"name": f"Agent Test Tenant {slug}", "slug": slug}, ) assert resp.status_code == 201 return resp.json()["id"] @pytest_asyncio.fixture async def second_tenant_id(client: AsyncClient) -> str: """Create a second tenant for isolation tests.""" slug = f"agent-test2-{uuid.uuid4().hex[:8]}" resp = await client.post( "/api/portal/tenants", json={"name": f"Agent Test Tenant 2 {slug}", "slug": slug}, ) assert resp.status_code == 201 return resp.json()["id"] @pytest.mark.asyncio class TestAgentCreate: async def test_create_agent_all_fields_returns_201( self, client: AsyncClient, tenant_id: str ) -> None: """All Agent Designer fields are accepted and returned.""" resp = await client.post( f"/api/portal/tenants/{tenant_id}/agents", json={ "name": "Mara", "role": "Customer Support Lead", "persona": "Professional, empathetic, solution-oriented.", "system_prompt": "You are a helpful customer support agent named Mara.", "model_preference": "quality", "tool_assignments": ["zendesk_ticket_create", "knowledge_base_search"], "escalation_rules": [ {"condition": "billing_dispute AND attempts > 2", "action": "handoff_human"}, {"condition": "sentiment < -0.7", "action": "handoff_human"}, ], "is_active": True, }, ) assert resp.status_code == 201 data = resp.json() assert data["name"] == "Mara" assert data["role"] == "Customer Support Lead" assert data["persona"] == "Professional, empathetic, solution-oriented." assert data["system_prompt"] == "You are a helpful customer support agent named Mara." assert data["model_preference"] == "quality" assert data["tool_assignments"] == ["zendesk_ticket_create", "knowledge_base_search"] assert len(data["escalation_rules"]) == 2 assert data["is_active"] is True assert data["tenant_id"] == tenant_id assert "id" in data assert "created_at" in data async def test_create_agent_minimal_fields_returns_201_with_defaults( self, client: AsyncClient, tenant_id: str ) -> None: """Creating with only name + role uses sensible defaults.""" resp = await client.post( f"/api/portal/tenants/{tenant_id}/agents", json={"name": "Sam", "role": "Sales Representative"}, ) assert resp.status_code == 201 data = resp.json() assert data["name"] == "Sam" assert data["role"] == "Sales Representative" assert data["persona"] == "" assert data["system_prompt"] == "" assert data["model_preference"] == "quality" assert data["tool_assignments"] == [] assert data["escalation_rules"] == [] assert data["is_active"] is True async def test_create_agent_invalid_tenant_returns_404( self, client: AsyncClient ) -> None: resp = await client.post( f"/api/portal/tenants/{uuid.uuid4()}/agents", json={"name": "Ghost", "role": "Nobody"}, ) assert resp.status_code == 404 async def test_create_agent_missing_name_returns_422( self, client: AsyncClient, tenant_id: str ) -> None: resp = await client.post( f"/api/portal/tenants/{tenant_id}/agents", json={"role": "Some Role"}, ) assert resp.status_code == 422 async def test_create_agent_missing_role_returns_422( self, client: AsyncClient, tenant_id: str ) -> None: resp = await client.post( f"/api/portal/tenants/{tenant_id}/agents", json={"name": "Some Name"}, ) assert resp.status_code == 422 async def test_create_agent_invalid_model_preference_returns_422( self, client: AsyncClient, tenant_id: str ) -> None: resp = await client.post( f"/api/portal/tenants/{tenant_id}/agents", json={"name": "Bad Model", "role": "Tester", "model_preference": "super-mega-mode"}, ) assert resp.status_code == 422 @pytest.mark.asyncio class TestAgentList: async def test_list_agents_returns_only_that_tenants_agents( self, client: AsyncClient, tenant_id: str, second_tenant_id: str ) -> None: """Agents from one tenant must NOT appear in another tenant's list.""" # Create agent for tenant 1 await client.post( f"/api/portal/tenants/{tenant_id}/agents", json={"name": "Tenant1 Agent", "role": "Worker"}, ) # Create agent for tenant 2 await client.post( f"/api/portal/tenants/{second_tenant_id}/agents", json={"name": "Tenant2 Agent", "role": "Worker"}, ) resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents") assert resp.status_code == 200 agents = resp.json() names = [a["name"] for a in agents] assert "Tenant1 Agent" in names assert "Tenant2 Agent" not in names for agent in agents: assert agent["tenant_id"] == tenant_id async def test_list_agents_empty_for_new_tenant( self, client: AsyncClient, tenant_id: str ) -> None: resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents") assert resp.status_code == 200 assert resp.json() == [] async def test_list_agents_invalid_tenant_returns_404( self, client: AsyncClient ) -> None: resp = await client.get(f"/api/portal/tenants/{uuid.uuid4()}/agents") assert resp.status_code == 404 @pytest.mark.asyncio class TestAgentGet: async def test_get_agent_by_id( self, client: AsyncClient, tenant_id: str ) -> None: create_resp = await client.post( f"/api/portal/tenants/{tenant_id}/agents", json={"name": "Get Me", "role": "Fetcher"}, ) agent_id = create_resp.json()["id"] resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}") assert resp.status_code == 200 assert resp.json()["id"] == agent_id assert resp.json()["name"] == "Get Me" async def test_get_agent_not_found_returns_404( self, client: AsyncClient, tenant_id: str ) -> None: resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents/{uuid.uuid4()}") assert resp.status_code == 404 async def test_get_agent_wrong_tenant_returns_404( self, client: AsyncClient, tenant_id: str, second_tenant_id: str ) -> None: """Agent from tenant1 should not be accessible via tenant2's path.""" create_resp = await client.post( f"/api/portal/tenants/{tenant_id}/agents", json={"name": "Secret Agent", "role": "Spy"}, ) agent_id = create_resp.json()["id"] resp = await client.get(f"/api/portal/tenants/{second_tenant_id}/agents/{agent_id}") assert resp.status_code == 404 @pytest.mark.asyncio class TestAgentUpdate: async def test_update_agent_persona_and_system_prompt( self, client: AsyncClient, tenant_id: str ) -> None: create_resp = await client.post( f"/api/portal/tenants/{tenant_id}/agents", json={"name": "Updatable", "role": "Updater"}, ) agent_id = create_resp.json()["id"] resp = await client.put( f"/api/portal/tenants/{tenant_id}/agents/{agent_id}", json={ "persona": "New persona description", "system_prompt": "New system prompt content", }, ) assert resp.status_code == 200 data = resp.json() assert data["persona"] == "New persona description" assert data["system_prompt"] == "New system prompt content" assert data["name"] == "Updatable" # unchanged async def test_update_agent_tool_assignments( self, client: AsyncClient, tenant_id: str ) -> None: create_resp = await client.post( f"/api/portal/tenants/{tenant_id}/agents", json={"name": "Tool Updater", "role": "Tester", "tool_assignments": ["tool_a"]}, ) agent_id = create_resp.json()["id"] resp = await client.put( f"/api/portal/tenants/{tenant_id}/agents/{agent_id}", json={"tool_assignments": ["tool_a", "tool_b", "tool_c"]}, ) assert resp.status_code == 200 assert resp.json()["tool_assignments"] == ["tool_a", "tool_b", "tool_c"] async def test_update_agent_escalation_rules( self, client: AsyncClient, tenant_id: str ) -> None: create_resp = await client.post( f"/api/portal/tenants/{tenant_id}/agents", json={"name": "Escalation Test", "role": "Support"}, ) agent_id = create_resp.json()["id"] rules = [{"condition": "urgent", "action": "escalate_to_manager"}] resp = await client.put( f"/api/portal/tenants/{tenant_id}/agents/{agent_id}", json={"escalation_rules": rules}, ) assert resp.status_code == 200 assert resp.json()["escalation_rules"] == rules async def test_update_agent_not_found_returns_404( self, client: AsyncClient, tenant_id: str ) -> None: resp = await client.put( f"/api/portal/tenants/{tenant_id}/agents/{uuid.uuid4()}", json={"name": "Ghost"}, ) assert resp.status_code == 404 @pytest.mark.asyncio class TestAgentDelete: async def test_delete_agent_returns_204( self, client: AsyncClient, tenant_id: str ) -> None: create_resp = await client.post( f"/api/portal/tenants/{tenant_id}/agents", json={"name": "Delete Me", "role": "Doomed"}, ) agent_id = create_resp.json()["id"] resp = await client.delete(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}") assert resp.status_code == 204 async def test_deleted_agent_is_gone( self, client: AsyncClient, tenant_id: str ) -> None: create_resp = await client.post( f"/api/portal/tenants/{tenant_id}/agents", json={"name": "Gone Soon", "role": "Temporary"}, ) agent_id = create_resp.json()["id"] await client.delete(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}") get_resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}") assert get_resp.status_code == 404 async def test_delete_agent_not_found_returns_404( self, client: AsyncClient, tenant_id: str ) -> None: resp = await client.delete(f"/api/portal/tenants/{tenant_id}/agents/{uuid.uuid4()}") assert resp.status_code == 404 @pytest.mark.asyncio class TestAgentDesignerFields: """ Explicitly prove all Agent Designer fields are stored and retrievable. This is the end-to-end round-trip test for PRTA-02. """ async def test_all_agent_designer_fields_stored_and_retrievable( self, client: AsyncClient, tenant_id: str ) -> None: payload = { "name": "Mara Chen", "role": "Head of Customer Experience", "persona": ( "Empathetic, data-driven, and relentlessly focused on customer success. " "Fluent in English, Spanish, and Mandarin. " "Never escalates without first attempting resolution." ), "system_prompt": ( "You are Mara Chen, Head of Customer Experience at Acme Corp. " "Your goal is to resolve customer issues efficiently and with empathy." ), "model_preference": "quality", "tool_assignments": [ "zendesk_ticket_create", "zendesk_ticket_update", "knowledge_base_search", "calendar_book", "send_email", ], "escalation_rules": [ {"condition": "billing_dispute AND resolution_attempts > 2", "action": "handoff_human"}, {"condition": "customer_sentiment < -0.7", "action": "handoff_human"}, {"condition": "legal_threat_detected", "action": "handoff_legal_team"}, ], "is_active": True, } create_resp = await client.post(f"/api/portal/tenants/{tenant_id}/agents", json=payload) assert create_resp.status_code == 201 agent_id = create_resp.json()["id"] # Fetch and verify all fields round-trip correctly get_resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}") assert get_resp.status_code == 200 data = get_resp.json() assert data["name"] == payload["name"] assert data["role"] == payload["role"] assert data["persona"] == payload["persona"] assert data["system_prompt"] == payload["system_prompt"] assert data["model_preference"] == payload["model_preference"] assert data["tool_assignments"] == payload["tool_assignments"] assert data["escalation_rules"] == payload["escalation_rules"] assert data["is_active"] == payload["is_active"] assert data["tenant_id"] == tenant_id