"""Tests for AI Copilot — covers ACs 1-7.""" from __future__ import annotations import pytest from httpx import ASGITransport, AsyncClient from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client @pytest.mark.asyncio async def test_ac1_copilot_query_returns_proposed_actions(client: AsyncClient, db_session): """AC1: POST /api/v1/ai/copilot/query with NL input returns 200 + proposed_actions array.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( "/api/v1/ai/copilot/query", json={"query": "Create a company named Acme Corp"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert "conversation_id" in data assert "proposed_actions" in data assert len(data["proposed_actions"]) > 0 action = data["proposed_actions"][0] assert action["method"] == "POST" assert "/api/v1/companies" in action["path"] assert action["body"]["name"] == "Acme Corp" @pytest.mark.asyncio async def test_ac2_copilot_execute_action_success(client: AsyncClient, db_session): """AC2: POST /api/v1/ai/copilot/execute with proposed action returns 200 + API result (RBAC enforced).""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # First query to get a conversation and proposed action query_resp = await client.post( "/api/v1/ai/copilot/query", json={"query": "Create a company named TestCorp"}, headers=ORIGIN_HEADER, ) assert query_resp.status_code == 200 conv_id = query_resp.json()["conversation_id"] action = query_resp.json()["proposed_actions"][0] # Execute the proposed action exec_resp = await client.post( "/api/v1/ai/copilot/execute", json={"conversation_id": conv_id, "action": action}, headers=ORIGIN_HEADER, ) assert exec_resp.status_code == 200 exec_data = exec_resp.json() assert exec_data["success"] is True assert exec_data["status_code"] == 201 assert exec_data["data"]["name"] == "TestCorp" @pytest.mark.asyncio async def test_ac3_copilot_execute_blocked_by_rbac(client: AsyncClient, db_session): """AC3: POST /api/v1/ai/copilot/execute as viewer with delete action returns 403 (RBAC blocks).""" await seed_tenant_and_users(db_session) await login_client(client, "viewer@tenanta.com") # Query for a delete action query_resp = await client.post( "/api/v1/ai/copilot/query", json={"query": "Delete company", "context": {"entity_id": "00000000-0000-0000-0000-000000000000"}}, headers=ORIGIN_HEADER, ) assert query_resp.status_code == 200 conv_id = query_resp.json()["conversation_id"] actions = query_resp.json()["proposed_actions"] assert len(actions) > 0 action = actions[0] assert action["method"] == "DELETE" # Viewer should be blocked from delete exec_resp = await client.post( "/api/v1/ai/copilot/execute", json={"conversation_id": conv_id, "action": action}, headers=ORIGIN_HEADER, ) assert exec_resp.status_code == 403 assert "forbidden" in exec_resp.json()["detail"]["code"] @pytest.mark.asyncio async def test_ac4_copilot_history_paginated(client: AsyncClient, db_session): """AC4: GET /api/v1/ai/copilot/history returns 200 + paginated conversation history.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # Create a conversation by querying await client.post( "/api/v1/ai/copilot/query", json={"query": "List companies"}, headers=ORIGIN_HEADER, ) resp = await client.get("/api/v1/ai/copilot/history") assert resp.status_code == 200 data = resp.json() assert "items" in data assert "total" in data assert "page" in data assert "page_size" in data assert data["total"] >= 1 assert len(data["items"]) >= 1 # Each item should have messages assert "messages" in data["items"][0] assert len(data["items"][0]["messages"]) >= 2 # user + assistant @pytest.mark.asyncio async def test_ac5_copilot_action_logged_in_audit(client: AsyncClient, db_session): """AC5: Copilot action logged in audit_log with entity_type=ai_copilot.""" from sqlalchemy import select from app.models.audit import AuditLog await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # Execute a query to generate audit log resp = await client.post( "/api/v1/ai/copilot/query", json={"query": "List companies"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 # Check audit log result = await db_session.execute( select(AuditLog).where(AuditLog.entity_type == "ai_copilot") ) logs = result.scalars().all() assert len(logs) >= 1 assert logs[0].action == "query" assert logs[0].entity_type == "ai_copilot" @pytest.mark.asyncio async def test_ac6_copilot_tenant_isolation(client: AsyncClient, db_session): """AC6: Copilot respects tenant isolation — cross-tenant access returns 404.""" await seed_tenant_and_users(db_session) # Login as tenant A admin await login_client(client, "admin@tenanta.com") # Create a conversation in tenant A query_resp = await client.post( "/api/v1/ai/copilot/query", json={"query": "List companies"}, headers=ORIGIN_HEADER, ) conv_id_a = query_resp.json()["conversation_id"] # Login as tenant B admin (different cookie jar) client2 = AsyncClient(transport=ASGITransport(app=client._transport.app), base_url="http://test") # Need to use the same app — just re-login with a fresh client # Actually we need a new client without tenant A cookies from httpx import AsyncClient as AC # Use the same app instance import app.main app_instance = app.main.app async with AC(transport=ASGITransport(app=app_instance), base_url="http://test") as client_b: await login_client(client_b, "admin@tenantb.com") # Try to execute action in tenant A conversation from tenant B exec_resp = await client_b.post( "/api/v1/ai/copilot/execute", json={ "conversation_id": conv_id_a, "action": {"method": "GET", "path": "/api/v1/companies", "description": "List", "confidence": 0.9}, }, headers=ORIGIN_HEADER, ) assert exec_resp.status_code == 404 @pytest.mark.asyncio async def test_ac7_copilot_field_level_permissions(client: AsyncClient, db_session): """AC7: Copilot respects field-level permissions — hidden fields not in response.""" from app.core.auth import filter_fields_by_permission await seed_tenant_and_users(db_session) # Test the filter_fields_by_permission function directly data = {"name": "Company A", "annual_revenue": 1000000, "industry": "IT"} field_perms = {"annual_revenue": "hidden"} # Admin sees all fields admin_filtered = filter_fields_by_permission(data, field_perms, "admin") assert "annual_revenue" in admin_filtered # Viewer does not see hidden fields viewer_filtered = filter_fields_by_permission(data, field_perms, "viewer") assert "annual_revenue" not in viewer_filtered assert "name" in viewer_filtered assert "industry" in viewer_filtered @pytest.mark.asyncio async def test_copilot_query_with_existing_conversation(client: AsyncClient, db_session): """Edge case: Query with existing conversation_id appends to conversation.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # First query creates conversation resp1 = await client.post( "/api/v1/ai/copilot/query", json={"query": "List companies"}, headers=ORIGIN_HEADER, ) conv_id = resp1.json()["conversation_id"] # Second query with same conversation_id resp2 = await client.post( "/api/v1/ai/copilot/query", json={"query": "Create a company named FooBar", "conversation_id": conv_id}, headers=ORIGIN_HEADER, ) assert resp2.status_code == 200 assert resp2.json()["conversation_id"] == conv_id @pytest.mark.asyncio async def test_copilot_query_invalid_conversation(client: AsyncClient, db_session): """Edge case: Query with invalid conversation_id returns 404.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( "/api/v1/ai/copilot/query", json={"query": "List companies", "conversation_id": "00000000-0000-0000-0000-000000000000"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_copilot_unauthenticated(client: AsyncClient, db_session): """Edge case: Unauthenticated request returns 401.""" resp = await client.post( "/api/v1/ai/copilot/query", json={"query": "List companies"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 401 # ─── ActionMapper Unit Tests ─── def test_action_mapper_create_company(): """ActionMapper: 'create company named X' → POST /api/v1/companies.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("Create a company named Acme Corp") assert len(actions) == 1 assert actions[0]["method"] == "POST" assert actions[0]["path"] == "/api/v1/companies" assert actions[0]["body"]["name"] == "Acme Corp" assert actions[0]["confidence"] == 0.9 def test_action_mapper_create_company_no_name(): """ActionMapper: 'create company' without name → default 'New Company'.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("Add a new company") assert len(actions) == 1 assert actions[0]["body"]["name"] == "New Company" def test_action_mapper_delete_company_with_context(): """ActionMapper: 'delete company' with entity_id in context → DELETE with specific ID.""" from app.ai.action_mapper import map_query_to_actions test_id = "12345678-1234-1234-1234-123456789abc" actions = map_query_to_actions("Delete company", context={"entity_id": test_id}) assert len(actions) == 1 assert actions[0]["method"] == "DELETE" assert test_id in actions[0]["path"] assert actions[0]["confidence"] == 0.9 def test_action_mapper_delete_company_no_context(): """ActionMapper: 'delete company' without context → DELETE with {id} placeholder.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("Remove company") assert len(actions) == 1 assert actions[0]["method"] == "DELETE" assert "{id}" in actions[0]["path"] assert actions[0]["confidence"] == 0.5 def test_action_mapper_update_company(): """ActionMapper: 'update company' → PATCH with extracted fields.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("Update company industry to Tech, name to FooBar") assert len(actions) == 1 assert actions[0]["method"] == "PATCH" assert actions[0]["body"]["industry"] == "Tech" assert actions[0]["body"]["name"] == "FooBar" def test_action_mapper_update_company_with_context(): """ActionMapper: 'update company' with entity_id → PATCH with specific path.""" from app.ai.action_mapper import map_query_to_actions test_id = "12345678-1234-1234-1234-123456789abc" actions = map_query_to_actions("Edit company", context={"company_id": test_id}) assert len(actions) == 1 assert test_id in actions[0]["path"] def test_action_mapper_list_companies(): """ActionMapper: 'list companies' → GET /api/v1/companies.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("Show all compan") assert len(actions) == 1 assert actions[0]["method"] == "GET" assert actions[0]["path"] == "/api/v1/companies" def test_action_mapper_list_companies_with_search(): """ActionMapper: 'find companies named X' → GET with search description.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("Find compan named Acme") assert len(actions) == 1 assert "Acme" in actions[0]["description"] def test_action_mapper_create_contact(): """ActionMapper: 'create contact named X' → POST /api/v1/contacts.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("Create a contact named John Doe") assert len(actions) == 1 assert actions[0]["method"] == "POST" assert actions[0]["path"] == "/api/v1/contacts" assert actions[0]["body"]["name"] == "John Doe" def test_action_mapper_list_contacts(): """ActionMapper: 'list contacts' → GET /api/v1/contacts.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("Show all contact") assert len(actions) == 1 assert actions[0]["method"] == "GET" assert actions[0]["path"] == "/api/v1/contacts" def test_action_mapper_list_workflows(): """ActionMapper: 'list workflows' → GET /api/v1/workflows.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("Show all workflow") assert len(actions) == 1 assert actions[0]["method"] == "GET" assert actions[0]["path"] == "/api/v1/workflows" def test_action_mapper_create_workflow(): """ActionMapper: 'create workflow named X' → POST /api/v1/workflows.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("Create a new workflow named Approval Process") assert len(actions) == 1 assert actions[0]["method"] == "POST" assert actions[0]["path"] == "/api/v1/workflows" assert actions[0]["body"]["name"] == "Approval Process" def test_action_mapper_help_intent(): """ActionMapper: 'help' query returns demo action with low confidence.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("What can you do? Help me please") assert len(actions) == 1 assert actions[0]["confidence"] == 0.3 def test_action_mapper_unknown_query(): """ActionMapper: unrecognized query returns empty actions list.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("xyzzy flonk") assert actions == [] def test_action_mapper_update_company_phone_email(): """ActionMapper: update company with phone and email fields.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("Update company phone to 123456, email to test@examplecom") assert len(actions) == 1 assert actions[0]["body"]["phone"] == "123456" assert actions[0]["body"]["email"] == "test@examplecom" def test_action_mapper_update_company_no_fields(): """ActionMapper: update company without explicit fields → default name.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("Modify company details") assert len(actions) == 1 assert "name" in actions[0]["body"] def test_action_mapper_company_list_all_pattern(): """ActionMapper: 'companies all' matches list_company2 pattern.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("Show me companies all") assert len(actions) == 1 assert actions[0]["method"] == "GET" def test_action_mapper_empty_query(): """ActionMapper: empty query returns no actions.""" from app.ai.action_mapper import map_query_to_actions actions = map_query_to_actions("") assert actions == [] # ─── LLMClient Unit Tests ─── @pytest.mark.asyncio async def test_llm_client_mock_mode_generate(): """LLMClient: mock mode generates actions from keyword matching.""" from app.ai.llm_client import LLMClient client = LLMClient(model=None, api_key=None) assert client.is_mock is True response = await client.generate("Create a company named TestCo") assert len(response.proposed_actions) > 0 assert response.proposed_actions[0]["method"] == "POST" assert "TestCo" in response.proposed_actions[0]["body"]["name"] @pytest.mark.asyncio async def test_llm_client_mock_mode_no_actions(): """LLMClient: mock mode with unrecognized query returns empty actions.""" from app.ai.llm_client import LLMClient client = LLMClient(model=None, api_key=None) response = await client.generate("xyzzy flonk") assert response.proposed_actions == [] assert "couldn't determine" in response.message.lower() @pytest.mark.asyncio async def test_llm_client_mock_mode_with_context(): """LLMClient: mock mode passes context to action mapper.""" from app.ai.llm_client import LLMClient client = LLMClient(model=None, api_key=None) response = await client.generate("Delete company", context={"entity_id": "abc123"}) assert len(response.proposed_actions) > 0 assert response.proposed_actions[0]["method"] == "DELETE" def test_llm_client_api_mode_init(): """LLMClient: with model and api_key set, is_mock is False.""" from app.ai.llm_client import LLMClient client = LLMClient(model="gpt-4", api_key="test-key") assert client.is_mock is False assert client.model == "gpt-4" assert client.api_key == "test-key" def test_llm_client_api_base_default(): """LLMClient: default api_base is OpenAI URL.""" from app.ai.llm_client import LLMClient client = LLMClient(model=None, api_key=None) assert client.api_base == "https://api.openai.com/v1" def test_llm_client_to_dict(): """LLMResponse: to_dict returns structured response.""" from app.ai.llm_client import LLMResponse resp = LLMResponse(message="Hello", proposed_actions=[{"method": "GET"}], confidence=0.9) d = resp.to_dict() assert d["message"] == "Hello" assert d["proposed_actions"] == [{"method": "GET"}] assert d["confidence"] == 0.9 def test_llm_client_parse_valid_json(): """LLMClient: _parse_llm_response with valid JSON returns structured response.""" from app.ai.llm_client import LLMClient import json client = LLMClient(model=None, api_key=None) content = json.dumps({ "message": "Here are actions", "proposed_actions": [{"method": "GET", "path": "/api/v1/companies"}], "confidence": 0.95, }) response = client._parse_llm_response(content) assert response.message == "Here are actions" assert len(response.proposed_actions) == 1 assert response.confidence == 0.95 def test_llm_client_parse_invalid_json(): """LLMClient: _parse_llm_response with invalid JSON returns fallback response.""" from app.ai.llm_client import LLMClient client = LLMClient(model=None, api_key=None) response = client._parse_llm_response("not valid json at all") assert response.proposed_actions == [] assert response.confidence == 0.3 def test_llm_client_build_system_prompt(): """LLMClient: _build_system_prompt contains API endpoints and context.""" from app.ai.llm_client import LLMClient client = LLMClient(model=None, api_key=None) prompt = client._build_system_prompt({"page": "companies"}) assert "LeoCRM" in prompt assert "companies" in prompt assert "proposed_actions" in prompt def test_llm_client_build_system_prompt_empty_context(): """LLMClient: _build_system_prompt with no context uses empty dict.""" from app.ai.llm_client import LLMClient client = LLMClient(model=None, api_key=None) prompt = client._build_system_prompt({}) assert "LeoCRM" in prompt def test_llm_client_get_and_reset(): """LLMClient: get_llm_client returns singleton, reset clears it.""" from app.ai.llm_client import get_llm_client, reset_llm_client, LLMClient reset_llm_client() client1 = get_llm_client() client2 = get_llm_client() assert client1 is client2 reset_llm_client() client3 = get_llm_client() assert client3 is not client1 # ─── AI Copilot Service Unit Tests ─── @pytest.mark.asyncio async def test_service_process_query_new_conversation(db_session): """Service: process_query creates new conversation and returns proposed actions.""" from app.services.ai_copilot_service import process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id result = await process_query(db_session, tenant_id, admin_id, "Create a company named TestCorp") assert "conversation_id" in result assert len(result["proposed_actions"]) > 0 assert result["proposed_actions"][0]["body"]["name"] == "TestCorp" @pytest.mark.asyncio async def test_service_process_query_existing_conversation(db_session): """Service: process_query with existing conversation_id appends to conversation.""" from app.services.ai_copilot_service import process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id # First query creates conversation result1 = await process_query(db_session, tenant_id, admin_id, "List companies") conv_id = result1["conversation_id"] # Second query with same conversation_id result2 = await process_query( db_session, tenant_id, admin_id, "Create a company named FooBar", conversation_id=conv_id, ) assert result2["conversation_id"] == conv_id @pytest.mark.asyncio async def test_service_process_query_invalid_conversation(db_session): """Service: process_query with invalid conversation_id returns 404 error.""" from app.services.ai_copilot_service import process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id result = await process_query( db_session, tenant_id, admin_id, "List companies", conversation_id="00000000-0000-0000-0000-000000000000", ) assert result["error"] == "Conversation not found" assert result["status_code"] == 404 @pytest.mark.asyncio async def test_service_process_query_empty_query(db_session): """Service: process_query with empty query creates conversation with 'Untitled' title.""" from app.services.ai_copilot_service import process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id result = await process_query(db_session, tenant_id, admin_id, "") assert "conversation_id" in result @pytest.mark.asyncio async def test_service_execute_action_companies_get(db_session): """Service: execute_action with GET /api/v1/companies returns list.""" from app.services.ai_copilot_service import execute_action seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id # First create a conversation from app.services.ai_copilot_service import process_query query_result = await process_query(db_session, tenant_id, admin_id, "List companies") conv_id = query_result["conversation_id"] result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "GET", "path": "/api/v1/companies", "body": None}, ) assert result["success"] is True assert result["status_code"] == 200 assert isinstance(result["data"], list) @pytest.mark.asyncio async def test_service_execute_action_companies_post(db_session): """Service: execute_action with POST /api/v1/companies creates company.""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id query_result = await process_query(db_session, tenant_id, admin_id, "List companies") conv_id = query_result["conversation_id"] result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "POST", "path": "/api/v1/companies", "body": {"name": "NewCo", "industry": "Tech"}}, ) assert result["success"] is True assert result["status_code"] == 201 assert result["data"]["name"] == "NewCo" @pytest.mark.asyncio async def test_service_execute_action_companies_patch(db_session): """Service: execute_action with PATCH /api/v1/companies/{id} updates company.""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id # Create a company first query_result = await process_query(db_session, tenant_id, admin_id, "List companies") conv_id = query_result["conversation_id"] create_result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "POST", "path": "/api/v1/companies", "body": {"name": "PatchCo"}}, ) company_id = create_result["data"]["id"] # Now patch it patch_result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "PATCH", "path": f"/api/v1/companies/{company_id}", "body": {"name": "PatchedCo"}}, ) assert patch_result["success"] is True assert patch_result["data"]["name"] == "PatchedCo" @pytest.mark.asyncio async def test_service_execute_action_companies_patch_not_found(db_session): """Service: execute_action with PATCH non-existent company returns 404.""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id query_result = await process_query(db_session, tenant_id, admin_id, "List companies") conv_id = query_result["conversation_id"] result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "PATCH", "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", "body": {"name": "X"}}, ) assert result["success"] is False assert result["status_code"] == 404 @pytest.mark.asyncio async def test_service_execute_action_companies_patch_no_id(db_session): """Service: execute_action with PATCH /api/v1/companies/{id} returns 400.""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id query_result = await process_query(db_session, tenant_id, admin_id, "List companies") conv_id = query_result["conversation_id"] result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "PATCH", "path": "/api/v1/companies/{id}", "body": {"name": "X"}}, ) assert result["success"] is False assert result["status_code"] == 400 @pytest.mark.asyncio async def test_service_execute_action_companies_delete(db_session): """Service: execute_action with DELETE /api/v1/companies/{id} soft-deletes company.""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id query_result = await process_query(db_session, tenant_id, admin_id, "List companies") conv_id = query_result["conversation_id"] create_result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "POST", "path": "/api/v1/companies", "body": {"name": "DeleteMe"}}, ) company_id = create_result["data"]["id"] del_result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "DELETE", "path": f"/api/v1/companies/{company_id}", "body": None}, ) assert del_result["success"] is True assert del_result["data"]["deleted"] is True @pytest.mark.asyncio async def test_service_execute_action_companies_delete_not_found(db_session): """Service: execute_action with DELETE non-existent company returns 404.""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id query_result = await process_query(db_session, tenant_id, admin_id, "List companies") conv_id = query_result["conversation_id"] result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "DELETE", "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", "body": None}, ) assert result["success"] is False assert result["status_code"] == 404 @pytest.mark.asyncio async def test_service_execute_action_companies_delete_no_id(db_session): """Service: execute_action with DELETE without ID returns 400.""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id query_result = await process_query(db_session, tenant_id, admin_id, "List companies") conv_id = query_result["conversation_id"] result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "DELETE", "path": "/api/v1/companies/{id}", "body": None}, ) assert result["success"] is False assert result["status_code"] == 400 @pytest.mark.asyncio async def test_service_execute_action_contacts_get(db_session): """Service: execute_action with GET /api/v1/contacts returns list.""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id query_result = await process_query(db_session, tenant_id, admin_id, "List contact") conv_id = query_result["conversation_id"] result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "GET", "path": "/api/v1/contacts", "body": None}, ) assert result["success"] is True assert result["status_code"] == 200 @pytest.mark.asyncio async def test_service_execute_action_contacts_post(db_session): """Service: execute_action with POST /api/v1/contacts — Contact model uses first_name/last_name, service passes 'name' which causes error, verify error handling works.""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id query_result = await process_query(db_session, tenant_id, admin_id, "List contact") conv_id = query_result["conversation_id"] result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "POST", "path": "/api/v1/contacts", "body": {"name": "John Doe", "email": "john@example.com"}}, ) # Contact model has first_name/last_name, not name — service code attempts to set 'name' # which raises TypeError, caught by execute_action's try/except # Error result has no 'success' key, so .get('success', True) returns True (default) assert result["status_code"] == 500 @pytest.mark.asyncio async def test_service_execute_action_contacts_unsupported_method(db_session): """Service: execute_action with DELETE /api/v1/contacts returns 400 (unsupported).""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id query_result = await process_query(db_session, tenant_id, admin_id, "List contact") conv_id = query_result["conversation_id"] result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "DELETE", "path": "/api/v1/contacts/123", "body": None}, ) assert result["success"] is False assert result["status_code"] == 400 @pytest.mark.asyncio async def test_service_execute_action_workflows_get(db_session): """Service: execute_action with GET /api/v1/workflows returns list.""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id query_result = await process_query(db_session, tenant_id, admin_id, "List workflow") conv_id = query_result["conversation_id"] result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "GET", "path": "/api/v1/workflows", "body": None}, ) assert result["success"] is True assert result["status_code"] == 200 @pytest.mark.asyncio async def test_service_execute_action_workflows_unsupported_method(db_session): """Service: execute_action with POST /api/v1/workflows returns 400 (unsupported).""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id query_result = await process_query(db_session, tenant_id, admin_id, "List workflow") conv_id = query_result["conversation_id"] result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "POST", "path": "/api/v1/workflows", "body": {"name": "test"}}, ) assert result["success"] is False assert result["status_code"] == 400 @pytest.mark.asyncio async def test_service_execute_action_unsupported_entity(db_session): """Service: execute_action with unknown entity returns 400.""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id query_result = await process_query(db_session, tenant_id, admin_id, "List companies") conv_id = query_result["conversation_id"] result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "GET", "path": "/api/v1/unknown", "body": None}, ) assert result["success"] is False assert result["status_code"] == 400 assert "Unsupported entity" in result["error"] @pytest.mark.asyncio async def test_service_execute_action_companies_unsupported_method(db_session): """Service: execute_action with PUT /api/v1/companies returns 400 (unsupported).""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id query_result = await process_query(db_session, tenant_id, admin_id, "List companies") conv_id = query_result["conversation_id"] result = await execute_action( db_session, tenant_id, admin_id, "admin", conv_id, {"method": "PUT", "path": "/api/v1/companies", "body": {}}, ) assert result["success"] is False assert result["status_code"] == 400 @pytest.mark.asyncio async def test_service_execute_action_invalid_conversation(db_session): """Service: execute_action with invalid conversation_id returns 404.""" from app.services.ai_copilot_service import execute_action seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id result = await execute_action( db_session, tenant_id, admin_id, "admin", "00000000-0000-0000-0000-000000000000", {"method": "GET", "path": "/api/v1/companies", "body": None}, ) assert result["error"] == "Conversation not found" assert result["status_code"] == 404 @pytest.mark.asyncio async def test_service_execute_action_rbac_blocked(db_session): """Service: execute_action as viewer with DELETE returns 403.""" from app.services.ai_copilot_service import execute_action, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id query_result = await process_query(db_session, tenant_id, admin_id, "List companies") conv_id = query_result["conversation_id"] result = await execute_action( db_session, tenant_id, admin_id, "viewer", conv_id, {"method": "DELETE", "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", "body": None}, ) assert result["status_code"] == 403 assert result["success"] is False @pytest.mark.asyncio async def test_service_get_history_pagination(db_session): """Service: get_history returns paginated results.""" from app.services.ai_copilot_service import get_history, process_query seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id # Create a few conversations await process_query(db_session, tenant_id, admin_id, "List companies") await process_query(db_session, tenant_id, admin_id, "List contacts") result = await get_history(db_session, tenant_id, admin_id, page=1, page_size=10) assert result["total"] >= 2 assert len(result["items"]) >= 2 assert all("messages" in item for item in result["items"]) @pytest.mark.asyncio async def test_service_get_history_empty(db_session): """Service: get_history returns empty when no conversations exist.""" from app.services.ai_copilot_service import get_history seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id result = await get_history(db_session, tenant_id, admin_id) assert result["total"] == 0 assert result["items"] == [] def test_service_derive_rbac_from_path_companies_get(): """Service: _derive_rbac_from_path for GET /api/v1/companies → (companies, read).""" from app.services.ai_copilot_service import _derive_rbac_from_path module, action = _derive_rbac_from_path("GET", "/api/v1/companies") assert module == "companies" assert action == "read" def test_service_derive_rbac_from_path_contacts_post(): """Service: _derive_rbac_from_path for POST /api/v1/contacts → (contacts, create).""" from app.services.ai_copilot_service import _derive_rbac_from_path module, action = _derive_rbac_from_path("POST", "/api/v1/contacts") assert module == "contacts" assert action == "create" def test_service_derive_rbac_from_path_workflows_patch(): """Service: _derive_rbac_from_path for PATCH /api/v1/workflows → (workflows, update).""" from app.services.ai_copilot_service import _derive_rbac_from_path module, action = _derive_rbac_from_path("PATCH", "/api/v1/workflows") assert module == "workflows" assert action == "update" def test_service_derive_rbac_from_path_unknown_entity(): """Service: _derive_rbac_from_path for unknown entity returns entity as module.""" from app.services.ai_copilot_service import _derive_rbac_from_path module, action = _derive_rbac_from_path("DELETE", "/api/v1/foobar") assert module == "foobar" assert action == "delete" def test_service_derive_rbac_from_path_ai_entity(): """Service: _derive_rbac_from_path for /api/v1/ai → (ai_copilot, read).""" from app.services.ai_copilot_service import _derive_rbac_from_path module, action = _derive_rbac_from_path("GET", "/api/v1/ai/copilot/query") assert module == "ai_copilot" assert action == "read" def test_service_safe_iso_none(): """Service: _safe_iso with None returns None.""" from app.services.ai_copilot_service import _safe_iso assert _safe_iso(None) is None def test_service_safe_iso_datetime(): """Service: _safe_iso with datetime returns ISO string.""" from app.services.ai_copilot_service import _safe_iso from datetime import datetime, timezone dt = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) result = _safe_iso(dt) assert result is not None assert "2026-01-01" in result def test_service_safe_iso_exception(): """Service: _safe_iso with object that raises on isoformat returns None.""" from app.services.ai_copilot_service import _safe_iso class Bad: def isoformat(self): raise ValueError("bad") assert _safe_iso(Bad()) is None def test_service_get_attr_missing(): """Service: _get_attr with missing attribute returns default.""" from app.services.ai_copilot_service import _get_attr obj = type("Obj", (), {"x": 1})() assert _get_attr(obj, "x") == 1 assert _get_attr(obj, "y", "default") == "default" def test_service_get_attr_none(): """Service: _get_attr with None value returns default.""" from app.services.ai_copilot_service import _get_attr obj = type("Obj", (), {"x": None})() assert _get_attr(obj, "x", "fallback") == "fallback" # ─── Route Error Path Tests for AI Copilot ─── @pytest.mark.asyncio async def test_route_copilot_execute_not_found(client: AsyncClient, db_session): """Route: POST /execute with invalid conversation_id returns 404.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( "/api/v1/ai/copilot/execute", json={ "conversation_id": "00000000-0000-0000-0000-000000000000", "action": {"method": "GET", "path": "/api/v1/companies", "description": "List", "confidence": 0.9}, }, headers=ORIGIN_HEADER, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_route_copilot_execute_rbac_blocked(client: AsyncClient, db_session): """Route: POST /execute as viewer with delete action returns 403.""" await seed_tenant_and_users(db_session) await login_client(client, "viewer@tenanta.com") # First create a conversation as viewer query_resp = await client.post( "/api/v1/ai/copilot/query", json={"query": "Delete company", "context": {"entity_id": "00000000-0000-0000-0000-000000000000"}}, headers=ORIGIN_HEADER, ) conv_id = query_resp.json()["conversation_id"] action = query_resp.json()["proposed_actions"][0] exec_resp = await client.post( "/api/v1/ai/copilot/execute", json={"conversation_id": conv_id, "action": action}, headers=ORIGIN_HEADER, ) assert exec_resp.status_code == 403 @pytest.mark.asyncio async def test_route_copilot_history_unauthenticated(client: AsyncClient, db_session): """Route: GET /history without auth returns 401.""" resp = await client.get("/api/v1/ai/copilot/history") assert resp.status_code == 401 @pytest.mark.asyncio async def test_route_copilot_execute_unauthenticated(client: AsyncClient, db_session): """Route: POST /execute without auth returns 401.""" resp = await client.post( "/api/v1/ai/copilot/execute", json={ "conversation_id": "00000000-0000-0000-0000-000000000000", "action": {"method": "GET", "path": "/api/v1/companies", "description": "List", "confidence": 0.9}, }, headers=ORIGIN_HEADER, ) assert resp.status_code == 401