2026-06-29 02:44:13 +02:00
|
|
|
"""Tests for AI Copilot — covers ACs 1-7."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-06-29 17:43:56 +02:00
|
|
|
from datetime import UTC
|
|
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
import pytest
|
|
|
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
|
|
2026-06-29 17:43:56 +02:00
|
|
|
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
2026-06-29 02:44:13 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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",
|
2026-06-29 17:43:56 +02:00
|
|
|
json={
|
|
|
|
|
"query": "Delete company",
|
|
|
|
|
"context": {"entity_id": "00000000-0000-0000-0000-000000000000"},
|
|
|
|
|
},
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
result = await db_session.execute(select(AuditLog).where(AuditLog.entity_type == "ai_copilot"))
|
2026-06-29 02:44:13 +02:00
|
|
|
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)
|
2026-06-29 17:43:56 +02:00
|
|
|
AsyncClient(transport=ASGITransport(app=client._transport.app), base_url="http://test")
|
2026-06-29 02:44:13 +02:00
|
|
|
# Need to use the same app — just re-login with a fresh client
|
|
|
|
|
# Actually we need a new client without tenant A cookies
|
2026-06-29 17:43:56 +02:00
|
|
|
from httpx import AsyncClient as AC # noqa: N817
|
|
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
# Use the same app instance
|
|
|
|
|
import app.main
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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,
|
2026-06-29 17:43:56 +02:00
|
|
|
"action": {
|
|
|
|
|
"method": "GET",
|
|
|
|
|
"path": "/api/v1/companies",
|
|
|
|
|
"description": "List",
|
|
|
|
|
"confidence": 0.9,
|
|
|
|
|
},
|
2026-06-29 02:44:13 +02:00
|
|
|
},
|
|
|
|
|
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 ───
|
|
|
|
|
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
actions = map_query_to_actions("")
|
|
|
|
|
assert actions == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── LLMClient Unit Tests ───
|
|
|
|
|
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
@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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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."""
|
|
|
|
|
import json
|
2026-06-29 17:43:56 +02:00
|
|
|
|
|
|
|
|
from app.ai.llm_client import LLMClient
|
|
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
client = LLMClient(model=None, api_key=None)
|
2026-06-29 17:43:56 +02:00
|
|
|
content = json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"message": "Here are actions",
|
|
|
|
|
"proposed_actions": [{"method": "GET", "path": "/api/v1/companies"}],
|
|
|
|
|
"confidence": 0.95,
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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."""
|
2026-06-29 17:43:56 +02:00
|
|
|
from app.ai.llm_client import get_llm_client, reset_llm_client
|
|
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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 ───
|
|
|
|
|
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
@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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
"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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
seed = await seed_tenant_and_users(db_session)
|
|
|
|
|
tenant_id = seed["tenant_a"].id
|
|
|
|
|
admin_id = seed["admin_a"].id
|
|
|
|
|
|
|
|
|
|
result = await process_query(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"List companies",
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
query_result = await process_query(db_session, tenant_id, admin_id, "List companies")
|
|
|
|
|
conv_id = query_result["conversation_id"]
|
|
|
|
|
|
|
|
|
|
result = await execute_action(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
{"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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
|
|
|
|
{
|
|
|
|
|
"method": "POST",
|
|
|
|
|
"path": "/api/v1/companies",
|
|
|
|
|
"body": {"name": "NewCo", "industry": "Tech"},
|
|
|
|
|
},
|
2026-06-29 02:44:13 +02:00
|
|
|
)
|
|
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
{"method": "POST", "path": "/api/v1/companies", "body": {"name": "PatchCo"}},
|
|
|
|
|
)
|
|
|
|
|
company_id = create_result["data"]["id"]
|
|
|
|
|
|
|
|
|
|
# Now patch it
|
|
|
|
|
patch_result = await execute_action(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
|
|
|
|
{
|
|
|
|
|
"method": "PATCH",
|
|
|
|
|
"path": f"/api/v1/companies/{company_id}",
|
|
|
|
|
"body": {"name": "PatchedCo"},
|
|
|
|
|
},
|
2026-06-29 02:44:13 +02:00
|
|
|
)
|
|
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
|
|
|
|
{
|
|
|
|
|
"method": "PATCH",
|
|
|
|
|
"path": "/api/v1/companies/00000000-0000-0000-0000-000000000000",
|
|
|
|
|
"body": {"name": "X"},
|
|
|
|
|
},
|
2026-06-29 02:44:13 +02:00
|
|
|
)
|
|
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
{"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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
{"method": "POST", "path": "/api/v1/companies", "body": {"name": "DeleteMe"}},
|
|
|
|
|
)
|
|
|
|
|
company_id = create_result["data"]["id"]
|
|
|
|
|
|
|
|
|
|
del_result = await execute_action(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
{"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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
|
|
|
|
{
|
|
|
|
|
"method": "DELETE",
|
|
|
|
|
"path": "/api/v1/companies/00000000-0000-0000-0000-000000000000",
|
|
|
|
|
"body": None,
|
|
|
|
|
},
|
2026-06-29 02:44:13 +02:00
|
|
|
)
|
|
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
{"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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
{"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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
|
|
|
|
{
|
|
|
|
|
"method": "POST",
|
|
|
|
|
"path": "/api/v1/contacts",
|
|
|
|
|
"body": {"name": "John Doe", "email": "john@example.com"},
|
|
|
|
|
},
|
2026-06-29 02:44:13 +02:00
|
|
|
)
|
|
|
|
|
# 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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
{"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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
{"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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
{"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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
{"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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
|
|
|
|
conv_id,
|
2026-06-29 02:44:13 +02:00
|
|
|
{"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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
seed = await seed_tenant_and_users(db_session)
|
|
|
|
|
tenant_id = seed["tenant_a"].id
|
|
|
|
|
admin_id = seed["admin_a"].id
|
|
|
|
|
|
|
|
|
|
result = await execute_action(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"admin",
|
2026-06-29 02:44:13 +02:00
|
|
|
"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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db_session,
|
|
|
|
|
tenant_id,
|
|
|
|
|
admin_id,
|
|
|
|
|
"viewer",
|
|
|
|
|
conv_id,
|
|
|
|
|
{
|
|
|
|
|
"method": "DELETE",
|
|
|
|
|
"path": "/api/v1/companies/00000000-0000-0000-0000-000000000000",
|
|
|
|
|
"body": None,
|
|
|
|
|
},
|
2026-06-29 02:44:13 +02:00
|
|
|
)
|
|
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
assert _safe_iso(None) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_service_safe_iso_datetime():
|
|
|
|
|
"""Service: _safe_iso with datetime returns ISO string."""
|
2026-06-29 17:43:56 +02:00
|
|
|
from datetime import datetime
|
|
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
from app.services.ai_copilot_service import _safe_iso
|
2026-06-29 17:43:56 +02:00
|
|
|
|
|
|
|
|
dt = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
class Bad:
|
|
|
|
|
def isoformat(self):
|
|
|
|
|
raise ValueError("bad")
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
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
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
obj = type("Obj", (), {"x": None})()
|
|
|
|
|
assert _get_attr(obj, "x", "fallback") == "fallback"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Route Error Path Tests for AI Copilot ───
|
|
|
|
|
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 02:44:13 +02:00
|
|
|
@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",
|
2026-06-29 17:43:56 +02:00
|
|
|
"action": {
|
|
|
|
|
"method": "GET",
|
|
|
|
|
"path": "/api/v1/companies",
|
|
|
|
|
"description": "List",
|
|
|
|
|
"confidence": 0.9,
|
|
|
|
|
},
|
2026-06-29 02:44:13 +02:00
|
|
|
},
|
|
|
|
|
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",
|
2026-06-29 17:43:56 +02:00
|
|
|
json={
|
|
|
|
|
"query": "Delete company",
|
|
|
|
|
"context": {"entity_id": "00000000-0000-0000-0000-000000000000"},
|
|
|
|
|
},
|
2026-06-29 02:44:13 +02:00
|
|
|
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",
|
2026-06-29 17:43:56 +02:00
|
|
|
"action": {
|
|
|
|
|
"method": "GET",
|
|
|
|
|
"path": "/api/v1/companies",
|
|
|
|
|
"description": "List",
|
|
|
|
|
"confidence": 0.9,
|
|
|
|
|
},
|
2026-06-29 02:44:13 +02:00
|
|
|
},
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 401
|