chore: fix all ruff lint errors + format — 0 errors, 306 tests pass

This commit is contained in:
leocrm-bot
2026-06-29 17:43:56 +02:00
parent 316f323ff4
commit a2452cc04b
81 changed files with 2317 additions and 1128 deletions
+247 -47
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
from datetime import UTC
import pytest
from httpx import ASGITransport, AsyncClient
from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
@@ -68,7 +70,10 @@ async def test_ac3_copilot_execute_blocked_by_rbac(client: AsyncClient, db_sessi
# 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"}},
json={
"query": "Delete company",
"context": {"entity_id": "00000000-0000-0000-0000-000000000000"},
},
headers=ORIGIN_HEADER,
)
assert query_resp.status_code == 200
@@ -119,6 +124,7 @@ async def test_ac4_copilot_history_paginated(client: AsyncClient, db_session):
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)
@@ -133,9 +139,7 @@ async def test_ac5_copilot_action_logged_in_audit(client: AsyncClient, db_sessio
assert resp.status_code == 200
# Check audit log
result = await db_session.execute(
select(AuditLog).where(AuditLog.entity_type == "ai_copilot")
)
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"
@@ -158,12 +162,14 @@ async def test_ac6_copilot_tenant_isolation(client: AsyncClient, db_session):
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")
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
from httpx import AsyncClient as AC # noqa: N817
# 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:
@@ -174,7 +180,12 @@ async def test_ac6_copilot_tenant_isolation(client: AsyncClient, db_session):
"/api/v1/ai/copilot/execute",
json={
"conversation_id": conv_id_a,
"action": {"method": "GET", "path": "/api/v1/companies", "description": "List", "confidence": 0.9},
"action": {
"method": "GET",
"path": "/api/v1/companies",
"description": "List",
"confidence": 0.9,
},
},
headers=ORIGIN_HEADER,
)
@@ -254,9 +265,11 @@ async def test_copilot_unauthenticated(client: AsyncClient, db_session):
# ─── 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"
@@ -268,6 +281,7 @@ def test_action_mapper_create_company():
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"
@@ -276,6 +290,7 @@ def test_action_mapper_create_company_no_name():
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
@@ -287,6 +302,7 @@ def test_action_mapper_delete_company_with_context():
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"
@@ -297,6 +313,7 @@ def test_action_mapper_delete_company_no_context():
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"
@@ -307,6 +324,7 @@ def test_action_mapper_update_company():
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
@@ -316,6 +334,7 @@ def test_action_mapper_update_company_with_context():
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"
@@ -325,6 +344,7 @@ def test_action_mapper_list_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"]
@@ -333,6 +353,7 @@ def test_action_mapper_list_companies_with_search():
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"
@@ -343,6 +364,7 @@ def test_action_mapper_create_contact():
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"
@@ -352,6 +374,7 @@ def test_action_mapper_list_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"
@@ -361,6 +384,7 @@ def test_action_mapper_list_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"
@@ -371,6 +395,7 @@ def test_action_mapper_create_workflow():
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
@@ -379,6 +404,7 @@ def test_action_mapper_help_intent():
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 == []
@@ -386,6 +412,7 @@ def test_action_mapper_unknown_query():
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"
@@ -395,6 +422,7 @@ def test_action_mapper_update_company_phone_email():
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"]
@@ -403,6 +431,7 @@ def test_action_mapper_update_company_no_fields():
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"
@@ -411,16 +440,19 @@ def test_action_mapper_company_list_all_pattern():
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
@@ -434,6 +466,7 @@ async def test_llm_client_mock_mode_generate():
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")
@@ -445,6 +478,7 @@ async def test_llm_client_mock_mode_no_actions():
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"})
@@ -455,6 +489,7 @@ async def test_llm_client_mock_mode_with_context():
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"
@@ -464,6 +499,7 @@ def test_llm_client_api_mode_init():
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"
@@ -471,6 +507,7 @@ def test_llm_client_api_base_default():
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"
@@ -480,14 +517,18 @@ def test_llm_client_to_dict():
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
from app.ai.llm_client import LLMClient
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,
})
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
@@ -497,6 +538,7 @@ def test_llm_client_parse_valid_json():
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 == []
@@ -506,6 +548,7 @@ def test_llm_client_parse_invalid_json():
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
@@ -516,6 +559,7 @@ def test_llm_client_build_system_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
@@ -523,7 +567,8 @@ def test_llm_client_build_system_prompt_empty_context():
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
from app.ai.llm_client import get_llm_client, reset_llm_client
reset_llm_client()
client1 = get_llm_client()
client2 = get_llm_client()
@@ -535,10 +580,12 @@ def test_llm_client_get_and_reset():
# ─── 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
@@ -553,6 +600,7 @@ async def test_service_process_query_new_conversation(db_session):
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
@@ -563,7 +611,9 @@ async def test_service_process_query_existing_conversation(db_session):
# Second query with same conversation_id
result2 = await process_query(
db_session, tenant_id, admin_id,
db_session,
tenant_id,
admin_id,
"Create a company named FooBar",
conversation_id=conv_id,
)
@@ -574,12 +624,16 @@ async def test_service_process_query_existing_conversation(db_session):
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",
db_session,
tenant_id,
admin_id,
"List companies",
conversation_id="00000000-0000-0000-0000-000000000000",
)
assert result["error"] == "Conversation not found"
@@ -590,6 +644,7 @@ async def test_service_process_query_invalid_conversation(db_session):
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
@@ -602,17 +657,23 @@ async def test_service_process_query_empty_query(db_session):
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,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "GET", "path": "/api/v1/companies", "body": None},
)
assert result["success"] is True
@@ -624,6 +685,7 @@ async def test_service_execute_action_companies_get(db_session):
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
@@ -632,8 +694,16 @@ async def test_service_execute_action_companies_post(db_session):
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"}},
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
@@ -644,6 +714,7 @@ async def test_service_execute_action_companies_post(db_session):
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
@@ -653,15 +724,27 @@ async def test_service_execute_action_companies_patch(db_session):
conv_id = query_result["conversation_id"]
create_result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
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"}},
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"
@@ -671,6 +754,7 @@ async def test_service_execute_action_companies_patch(db_session):
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
@@ -679,8 +763,16 @@ async def test_service_execute_action_companies_patch_not_found(db_session):
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"}},
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
@@ -690,6 +782,7 @@ async def test_service_execute_action_companies_patch_not_found(db_session):
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
@@ -698,7 +791,11 @@ async def test_service_execute_action_companies_patch_no_id(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "PATCH", "path": "/api/v1/companies/{id}", "body": {"name": "X"}},
)
assert result["success"] is False
@@ -709,6 +806,7 @@ async def test_service_execute_action_companies_patch_no_id(db_session):
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
@@ -717,13 +815,21 @@ async def test_service_execute_action_companies_delete(db_session):
conv_id = query_result["conversation_id"]
create_result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
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,
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
@@ -734,6 +840,7 @@ async def test_service_execute_action_companies_delete(db_session):
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
@@ -742,8 +849,16 @@ async def test_service_execute_action_companies_delete_not_found(db_session):
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},
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
@@ -753,6 +868,7 @@ async def test_service_execute_action_companies_delete_not_found(db_session):
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
@@ -761,7 +877,11 @@ async def test_service_execute_action_companies_delete_no_id(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "DELETE", "path": "/api/v1/companies/{id}", "body": None},
)
assert result["success"] is False
@@ -772,6 +892,7 @@ async def test_service_execute_action_companies_delete_no_id(db_session):
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
@@ -780,7 +901,11 @@ async def test_service_execute_action_contacts_get(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "GET", "path": "/api/v1/contacts", "body": None},
)
assert result["success"] is True
@@ -792,6 +917,7 @@ 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
@@ -800,8 +926,16 @@ async def test_service_execute_action_contacts_post(db_session):
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"}},
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
@@ -813,6 +947,7 @@ async def test_service_execute_action_contacts_post(db_session):
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
@@ -821,7 +956,11 @@ async def test_service_execute_action_contacts_unsupported_method(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "DELETE", "path": "/api/v1/contacts/123", "body": None},
)
assert result["success"] is False
@@ -832,6 +971,7 @@ async def test_service_execute_action_contacts_unsupported_method(db_session):
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
@@ -840,7 +980,11 @@ async def test_service_execute_action_workflows_get(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "GET", "path": "/api/v1/workflows", "body": None},
)
assert result["success"] is True
@@ -851,6 +995,7 @@ async def test_service_execute_action_workflows_get(db_session):
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
@@ -859,7 +1004,11 @@ async def test_service_execute_action_workflows_unsupported_method(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "POST", "path": "/api/v1/workflows", "body": {"name": "test"}},
)
assert result["success"] is False
@@ -870,6 +1019,7 @@ async def test_service_execute_action_workflows_unsupported_method(db_session):
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
@@ -878,7 +1028,11 @@ async def test_service_execute_action_unsupported_entity(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "GET", "path": "/api/v1/unknown", "body": None},
)
assert result["success"] is False
@@ -890,6 +1044,7 @@ async def test_service_execute_action_unsupported_entity(db_session):
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
@@ -898,7 +1053,11 @@ async def test_service_execute_action_companies_unsupported_method(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "PUT", "path": "/api/v1/companies", "body": {}},
)
assert result["success"] is False
@@ -909,12 +1068,16 @@ async def test_service_execute_action_companies_unsupported_method(db_session):
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",
db_session,
tenant_id,
admin_id,
"admin",
"00000000-0000-0000-0000-000000000000",
{"method": "GET", "path": "/api/v1/companies", "body": None},
)
@@ -926,6 +1089,7 @@ async def test_service_execute_action_invalid_conversation(db_session):
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
@@ -934,8 +1098,16 @@ async def test_service_execute_action_rbac_blocked(db_session):
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},
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
@@ -945,6 +1117,7 @@ async def test_service_execute_action_rbac_blocked(db_session):
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
@@ -963,6 +1136,7 @@ async def test_service_get_history_pagination(db_session):
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
@@ -975,6 +1149,7 @@ async def test_service_get_history_empty(db_session):
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"
@@ -983,6 +1158,7 @@ def test_service_derive_rbac_from_path_companies_get():
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"
@@ -991,6 +1167,7 @@ def test_service_derive_rbac_from_path_contacts_post():
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"
@@ -999,6 +1176,7 @@ def test_service_derive_rbac_from_path_workflows_patch():
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"
@@ -1007,6 +1185,7 @@ def test_service_derive_rbac_from_path_unknown_entity():
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"
@@ -1015,14 +1194,17 @@ def test_service_derive_rbac_from_path_ai_entity():
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 datetime import datetime
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)
dt = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
result = _safe_iso(dt)
assert result is not None
assert "2026-01-01" in result
@@ -1031,15 +1213,18 @@ def test_service_safe_iso_datetime():
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"
@@ -1048,12 +1233,14 @@ def test_service_get_attr_missing():
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."""
@@ -1064,7 +1251,12 @@ async def test_route_copilot_execute_not_found(client: AsyncClient, db_session):
"/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},
"action": {
"method": "GET",
"path": "/api/v1/companies",
"description": "List",
"confidence": 0.9,
},
},
headers=ORIGIN_HEADER,
)
@@ -1080,7 +1272,10 @@ async def test_route_copilot_execute_rbac_blocked(client: AsyncClient, db_sessio
# 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"}},
json={
"query": "Delete company",
"context": {"entity_id": "00000000-0000-0000-0000-000000000000"},
},
headers=ORIGIN_HEADER,
)
conv_id = query_resp.json()["conversation_id"]
@@ -1108,7 +1303,12 @@ async def test_route_copilot_execute_unauthenticated(client: AsyncClient, db_ses
"/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},
"action": {
"method": "GET",
"path": "/api/v1/companies",
"description": "List",
"confidence": 0.9,
},
},
headers=ORIGIN_HEADER,
)