chore: fix all ruff lint errors + format — 0 errors, 306 tests pass
This commit is contained in:
+27
-19
@@ -7,8 +7,6 @@ Auth helpers talk to the HTTP API (integration tests).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
@@ -24,23 +22,22 @@ from sqlalchemy.ext.asyncio import (
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.db import Base, reset_engine_for_testing, close_engine
|
||||
from app.core.auth import hash_password
|
||||
from app.core.db import Base, close_engine, reset_engine_for_testing
|
||||
from app.main import create_app
|
||||
from app.models.ai_conversation import AIConversation, AIMessage # noqa: F401
|
||||
from app.models.company import Company
|
||||
from app.models.contact import CompanyContact, Contact # noqa: F401
|
||||
from app.models.plugin import Plugin, PluginMigration # noqa: F401
|
||||
from app.models.role import Role
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User, UserTenant
|
||||
from app.models.role import Role
|
||||
from app.models.company import Company
|
||||
from app.models.contact import Contact, CompanyContact
|
||||
from app.models.plugin import Plugin, PluginMigration
|
||||
from app.models.ai_conversation import AIConversation, AIMessage
|
||||
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
||||
# Import plugin models so Base.metadata.create_all includes their tables
|
||||
from app.plugins.builtins.tags.models import Tag, TagAssignment
|
||||
from app.plugins.builtins.permissions.models import Permission, ShareLink
|
||||
from app.plugins.builtins.entity_links.models import EntityLink
|
||||
from app.main import create_app
|
||||
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401
|
||||
from app.plugins.builtins.entity_links.models import EntityLink # noqa: F401
|
||||
from app.plugins.builtins.permissions.models import Permission, ShareLink # noqa: F401
|
||||
from app.plugins.builtins.tags.models import Tag, TagAssignment # noqa: F401
|
||||
|
||||
# Import plugin models so Base.metadata.create_all includes their tables
|
||||
|
||||
TEST_DB_URL = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test"
|
||||
|
||||
@@ -50,6 +47,7 @@ def _get_sync_engine():
|
||||
Uses postgres superuser because leocrm user doesn't own the public schema.
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
return create_engine(
|
||||
"postgresql+psycopg2://postgres@localhost:5432/leocrm_test",
|
||||
echo=False,
|
||||
@@ -94,7 +92,11 @@ def clean_tables(db_setup):
|
||||
sync_eng = _get_sync_engine()
|
||||
with sync_eng.connect() as conn:
|
||||
# TRUNCATE all tables with CASCADE — fast and reliable isolation
|
||||
conn.execute(text("TRUNCATE TABLE entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"))
|
||||
conn.execute(
|
||||
text(
|
||||
"TRUNCATE TABLE entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
sync_eng.dispose()
|
||||
yield
|
||||
@@ -125,7 +127,9 @@ async def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSessio
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(session_factory: async_sessionmaker[AsyncSession]) -> AsyncGenerator[AsyncSession, None]:
|
||||
async def db_session(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Database session for direct DB operations in tests."""
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
@@ -260,7 +264,9 @@ async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def login_client(client: AsyncClient, email: str, password: str = "TestPass123!") -> dict[str, str]:
|
||||
async def login_client(
|
||||
client: AsyncClient, email: str, password: str = "TestPass123!"
|
||||
) -> dict[str, str]:
|
||||
"""Login via HTTP API and return cookies dict."""
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
@@ -271,7 +277,9 @@ async def login_client(client: AsyncClient, email: str, password: str = "TestPas
|
||||
return dict(resp.cookies)
|
||||
|
||||
|
||||
async def get_auth_client(client: AsyncClient, email: str, password: str = "TestPass123!") -> AsyncClient:
|
||||
async def get_auth_client(
|
||||
client: AsyncClient, email: str, password: str = "TestPass123!"
|
||||
) -> AsyncClient:
|
||||
"""Return a client that's logged in."""
|
||||
await login_client(client, email, password)
|
||||
return client
|
||||
|
||||
+247
-47
@@ -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,
|
||||
)
|
||||
|
||||
+12
-5
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from httpx import 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
|
||||
@@ -21,8 +21,9 @@ class TestAuthLogin:
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "leocrm_session" in resp.headers.get("set-cookie", "").lower() or \
|
||||
"leocrm_session" in str(resp.cookies)
|
||||
assert "leocrm_session" in resp.headers.get(
|
||||
"set-cookie", ""
|
||||
).lower() or "leocrm_session" in str(resp.cookies)
|
||||
data = resp.json()
|
||||
assert data["email"] == "admin@tenanta.com"
|
||||
assert data["role"] == "admin"
|
||||
@@ -64,7 +65,9 @@ class TestAuthMe:
|
||||
class TestAuthLogout:
|
||||
"""AC 5: logout."""
|
||||
|
||||
async def test_logout_returns_200_and_invalidates_session(self, client: AsyncClient, db_session):
|
||||
async def test_logout_returns_200_and_invalidates_session(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
"""AC 5: POST /api/v1/auth/logout -> 200, session invalidated."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
@@ -104,6 +107,7 @@ class TestPasswordReset:
|
||||
async def test_password_reset_confirm_valid_token(self, client: AsyncClient, db_session):
|
||||
"""AC 7: POST /api/v1/auth/password-reset/confirm valid token -> 200."""
|
||||
from app.services.auth_service import auth_service
|
||||
|
||||
await seed_tenant_and_users(db_session)
|
||||
raw_token = await auth_service.get_password_reset_token_raw(db_session, "admin@tenanta.com")
|
||||
await db_session.commit()
|
||||
@@ -119,6 +123,7 @@ class TestPasswordReset:
|
||||
async def test_password_reset_confirm_expired_token(self, client: AsyncClient, db_session):
|
||||
"""AC 8: POST /api/v1/auth/password-reset/confirm expired token -> 400."""
|
||||
from app.services.auth_service import auth_service
|
||||
|
||||
await seed_tenant_and_users(db_session)
|
||||
raw_token = await auth_service.create_expired_reset_token(db_session, "admin@tenanta.com")
|
||||
await db_session.commit()
|
||||
@@ -147,8 +152,10 @@ class TestSwitchTenant:
|
||||
initial_tenant = resp.json()["tenant_id"]
|
||||
|
||||
# Get tenant B ID
|
||||
from app.models.tenant import Tenant
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
q = select(Tenant).where(Tenant.slug == "tenant-b")
|
||||
result = await db_session.execute(q)
|
||||
tenant_b = result.scalar_one()
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from httpx import 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
|
||||
@@ -186,7 +186,7 @@ class TestCompanyDelete:
|
||||
json={"first_name": "John", "last_name": "Doe", "company_ids": [company_id]},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
contact_id = cont_resp.json()["id"]
|
||||
cont_resp.json()["id"]
|
||||
# Verify link exists
|
||||
detail_resp = await client.get(f"/api/v1/companies/{company_id}", headers=ORIGIN_HEADER)
|
||||
assert len(detail_resp.json()["contacts"]) == 1
|
||||
@@ -318,7 +318,9 @@ class TestCompanyAuditAndSoftDelete:
|
||||
async def test_audit_log_on_company_create(self, client: AsyncClient, db_session):
|
||||
"""AC 23: Audit log entry on every company/contact mutation."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
await client.post(
|
||||
|
||||
+11
-4
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from httpx import 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
|
||||
@@ -29,7 +29,9 @@ class TestContactList:
|
||||
class TestContactCreate:
|
||||
"""AC 15: Create contact with company_ids array -> N:M links."""
|
||||
|
||||
async def test_create_contact_with_company_ids_returns_201(self, client: AsyncClient, db_session):
|
||||
async def test_create_contact_with_company_ids_returns_201(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
"""AC 15: POST /api/v1/contacts mit company_ids array -> 201 + N:M links."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
@@ -132,12 +134,17 @@ class TestContactDelete:
|
||||
names = [f"{item['first_name']} {item['last_name']}" for item in list_resp.json()["items"]]
|
||||
assert "Delete Me" not in names
|
||||
|
||||
async def test_delete_contact_gdpr_hard_delete_returns_204(self, client: AsyncClient, db_session):
|
||||
async def test_delete_contact_gdpr_hard_delete_returns_204(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
"""AC 19: DELETE /api/v1/contacts/{id}?gdpr=true -> 204, hard-delete + deletion_log."""
|
||||
import uuid as uuid_mod
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.audit import DeletionLog
|
||||
from app.models.contact import Contact
|
||||
import uuid as uuid_mod
|
||||
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
create_resp = await client.post(
|
||||
|
||||
+22
-14
@@ -7,16 +7,16 @@ import uuid
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
|
||||
from app.core.db import reset_engine_for_testing, close_engine
|
||||
from app.core.db import close_engine, reset_engine_for_testing
|
||||
from app.core.event_bus import get_event_bus
|
||||
from app.core.service_container import get_container
|
||||
from app.main import create_app
|
||||
from app.plugins.registry import reset_registry_for_testing
|
||||
from app.plugins.builtins.entity_links import EntityLinksPlugin
|
||||
from app.plugins.registry import reset_registry_for_testing
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||
from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@@ -240,14 +240,18 @@ async def test_event_cleanup_on_company_deleted(authed_client: AsyncClient):
|
||||
|
||||
# Publish company.deleted event
|
||||
event_bus = get_event_bus()
|
||||
await event_bus.publish("company.deleted", {
|
||||
"entity_id": str(company_id),
|
||||
"company_id": str(company_id),
|
||||
"tenant_id": str(tenant_id),
|
||||
})
|
||||
await event_bus.publish(
|
||||
"company.deleted",
|
||||
{
|
||||
"entity_id": str(company_id),
|
||||
"company_id": str(company_id),
|
||||
"tenant_id": str(tenant_id),
|
||||
},
|
||||
)
|
||||
|
||||
# Allow async event handler to complete
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Verify link is cleaned up
|
||||
@@ -279,14 +283,18 @@ async def test_event_cleanup_on_contact_deleted(authed_client: AsyncClient):
|
||||
|
||||
# Publish contact.deleted event
|
||||
event_bus = get_event_bus()
|
||||
await event_bus.publish("contact.deleted", {
|
||||
"entity_id": str(contact_id),
|
||||
"contact_id": str(contact_id),
|
||||
"tenant_id": str(tenant_id),
|
||||
})
|
||||
await event_bus.publish(
|
||||
"contact.deleted",
|
||||
{
|
||||
"entity_id": str(contact_id),
|
||||
"contact_id": str(contact_id),
|
||||
"tenant_id": str(tenant_id),
|
||||
},
|
||||
)
|
||||
|
||||
# Allow async event handler to complete
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Verify link is cleaned up
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import pytest
|
||||
from httpx import 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
|
||||
|
||||
CSV_COMPANIES = """name,industry,phone,email,website,description
|
||||
ImportCorp,IT,123456,import@example.com,https://import.example,Imported company
|
||||
|
||||
+35
-16
@@ -2,16 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client
|
||||
from app.core.notifications import create_notification
|
||||
from app.models.notification import Notification
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -23,22 +21,31 @@ class TestNotifications:
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
# Create some notifications for admin_a
|
||||
await create_notification(
|
||||
db_session, seed["tenant_a"].id, seed["admin_a"].id,
|
||||
"info", "Read Notif", "Already read",
|
||||
db_session,
|
||||
seed["tenant_a"].id,
|
||||
seed["admin_a"].id,
|
||||
"info",
|
||||
"Read Notif",
|
||||
"Already read",
|
||||
)
|
||||
await db_session.flush()
|
||||
# Mark the first one as read
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select
|
||||
|
||||
q = select(Notification).where(Notification.title == "Read Notif")
|
||||
result = await db_session.execute(q)
|
||||
first_notif = result.scalar_one()
|
||||
first_notif.read_at = datetime.now(timezone.utc)
|
||||
first_notif.read_at = datetime.now(UTC)
|
||||
await db_session.flush()
|
||||
|
||||
# Create an unread one
|
||||
await create_notification(
|
||||
db_session, seed["tenant_a"].id, seed["admin_a"].id,
|
||||
"info", "Unread Notif", "Not read yet",
|
||||
db_session,
|
||||
seed["tenant_a"].id,
|
||||
seed["admin_a"].id,
|
||||
"info",
|
||||
"Unread Notif",
|
||||
"Not read yet",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -64,8 +71,12 @@ class TestNotifications:
|
||||
"""AC 25: PATCH /api/v1/notifications/{id}/read -> 200."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
notif = await create_notification(
|
||||
db_session, seed["tenant_a"].id, seed["admin_a"].id,
|
||||
"info", "Test Notif", "Test body",
|
||||
db_session,
|
||||
seed["tenant_a"].id,
|
||||
seed["admin_a"].id,
|
||||
"info",
|
||||
"Test Notif",
|
||||
"Test body",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -81,12 +92,20 @@ class TestNotifications:
|
||||
"""AC 26: GET /api/v1/notifications/unread-count -> 200 + integer count."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
await create_notification(
|
||||
db_session, seed["tenant_a"].id, seed["admin_a"].id,
|
||||
"info", "Unread 1", "Body 1",
|
||||
db_session,
|
||||
seed["tenant_a"].id,
|
||||
seed["admin_a"].id,
|
||||
"info",
|
||||
"Unread 1",
|
||||
"Body 1",
|
||||
)
|
||||
await create_notification(
|
||||
db_session, seed["tenant_a"].id, seed["admin_a"].id,
|
||||
"info", "Unread 2", "Body 2",
|
||||
db_session,
|
||||
seed["tenant_a"].id,
|
||||
seed["admin_a"].id,
|
||||
"info",
|
||||
"Unread 2",
|
||||
"Body 2",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
+13
-10
@@ -3,20 +3,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
|
||||
from app.core.db import reset_engine_for_testing, close_engine
|
||||
from app.core.db import close_engine, reset_engine_for_testing
|
||||
from app.core.service_container import get_container
|
||||
from app.main import create_app
|
||||
from app.plugins.registry import reset_registry_for_testing
|
||||
from app.plugins.builtins.permissions import PermissionsPlugin
|
||||
from app.plugins.registry import reset_registry_for_testing
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||
from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@@ -182,7 +182,7 @@ async def test_expired_share_link(authed_client: AsyncClient):
|
||||
file_id = str(uuid.uuid4())
|
||||
|
||||
# Create link with expiry in the past
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
past = datetime.now(UTC) - timedelta(hours=1)
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/share-link",
|
||||
json={"expires_at": past.isoformat(), "access_level": "download"},
|
||||
@@ -235,21 +235,23 @@ async def test_revoke_share_link(authed_client: AsyncClient):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_403_for_unauthorized_user(plugin_client: AsyncClient, db_session: AsyncSession):
|
||||
async def test_permission_403_for_unauthorized_user(
|
||||
plugin_client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
"""AC14: Folder permissions enforced: user without read → 403.
|
||||
|
||||
The permissions plugin does not intercept file access (no DMS file system yet).
|
||||
We test the permission check helper directly: a user with no permission record
|
||||
gets denied (False), which translates to 403 at the route layer.
|
||||
"""
|
||||
from app.plugins.builtins.permissions.routes import check_user_file_permission
|
||||
from app.core.db import get_session_factory
|
||||
from app.plugins.builtins.permissions.routes import check_user_file_permission
|
||||
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
await login_client(plugin_client, "admin@tenanta.com")
|
||||
|
||||
# Install + activate permissions plugin
|
||||
registry = reset_registry_for_testing()
|
||||
reset_registry_for_testing()
|
||||
# We need to set up the registry properly
|
||||
# Since plugin_client fixture wasn't used, manually install
|
||||
resp = await plugin_client.post("/api/v1/plugins/permissions/install", headers=ORIGIN_HEADER)
|
||||
@@ -270,6 +272,7 @@ async def test_permission_403_for_unauthorized_user(plugin_client: AsyncClient,
|
||||
|
||||
# Grant read to admin
|
||||
from app.plugins.builtins.permissions.models import Permission
|
||||
|
||||
perm = Permission(
|
||||
tenant_id=tenant_id,
|
||||
file_id=file_id,
|
||||
@@ -422,7 +425,7 @@ async def test_public_access_post_expired(authed_client: AsyncClient):
|
||||
"""POST /api/public/share/{token} with expired link → 410."""
|
||||
client, seed = authed_client
|
||||
file_id = str(uuid.uuid4())
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
past = datetime.now(UTC) - timedelta(hours=1)
|
||||
resp = await client.post(
|
||||
f"/api/v1/dms/files/{file_id}/share-link",
|
||||
json={"expires_at": past.isoformat(), "access_level": "download"},
|
||||
|
||||
+163
-52
@@ -3,31 +3,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import text, select, inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||
|
||||
from app.core.db import Base, reset_engine_for_testing, close_engine, get_engine
|
||||
from app.core.db import close_engine, reset_engine_for_testing
|
||||
from app.core.event_bus import get_event_bus
|
||||
from app.core.service_container import get_container
|
||||
from app.main import create_app
|
||||
from app.models.plugin import Plugin as PluginModel, PluginMigration
|
||||
from app.models.plugin import Plugin as PluginModel
|
||||
from app.models.plugin import PluginMigration
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest
|
||||
from app.plugins.registry import get_registry, reset_registry_for_testing
|
||||
from app.plugins.migration_runner import MigrationRunner, MigrationValidationError
|
||||
from app.plugins.builtins.test_sample import TestSamplePlugin
|
||||
from app.plugins.manifest import PluginManifest
|
||||
from app.plugins.migration_runner import MigrationRunner, MigrationValidationError
|
||||
from app.plugins.registry import get_registry, reset_registry_for_testing
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||
from tests.conftest import TEST_DB_URL, seed_tenant_and_users, login_client, ORIGIN_HEADER
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
# ─── Bad Plugin for AC11 (migration without tenant_id) ───
|
||||
|
||||
|
||||
class BadMigrationPlugin(BasePlugin):
|
||||
"""Plugin with a migration that creates a table WITHOUT tenant_id."""
|
||||
|
||||
@@ -46,6 +46,7 @@ class BadMigrationPlugin(BasePlugin):
|
||||
|
||||
# ─── Fixtures ───
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def plugin_app(engine: AsyncEngine, redis_client):
|
||||
"""FastAPI app with plugin registry initialized for testing."""
|
||||
@@ -82,7 +83,7 @@ async def plugin_client(plugin_app) -> AsyncClient:
|
||||
@pytest_asyncio.fixture
|
||||
async def authed_plugin_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient:
|
||||
"""Authenticated admin client for plugin tests."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(plugin_client, "admin@tenanta.com")
|
||||
return plugin_client
|
||||
|
||||
@@ -98,6 +99,7 @@ async def db_session_for_plugins(engine: AsyncEngine) -> AsyncSession:
|
||||
|
||||
# ─── AC1: GET /api/v1/plugins → 200 + list of plugins with status ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac01_list_plugins(authed_plugin_client: AsyncClient):
|
||||
"""AC1: GET /api/v1/plugins returns 200 with plugin list and status."""
|
||||
@@ -119,10 +121,15 @@ async def test_ac01_list_plugins(authed_plugin_client: AsyncClient):
|
||||
|
||||
# ─── AC2: POST /api/v1/plugins/{name}/install → 200, status=installed, migrations run ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac02_install_plugin(authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession):
|
||||
async def test_ac02_install_plugin(
|
||||
authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""AC2: Install plugin runs migrations and sets status=installed."""
|
||||
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER)
|
||||
resp = await authed_plugin_client.post(
|
||||
"/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "test_sample"
|
||||
@@ -132,22 +139,29 @@ async def test_ac02_install_plugin(authed_plugin_client: AsyncClient, db_session
|
||||
|
||||
# Verify migration table was created (tenant_id column exists)
|
||||
result = await db_session_for_plugins.execute(
|
||||
text("SELECT column_name FROM information_schema.columns WHERE table_name = 'plugin_test_data' AND column_name = 'tenant_id'")
|
||||
text(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'plugin_test_data' AND column_name = 'tenant_id'"
|
||||
)
|
||||
)
|
||||
assert result.fetchone() is not None, "plugin_test_data table should have tenant_id column"
|
||||
|
||||
|
||||
# ─── AC3: POST /api/v1/plugins/{name}/activate → 200, status=active, routes registered ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac03_activate_plugin(authed_plugin_client: AsyncClient):
|
||||
"""AC3: Activate plugin sets status=active and registers event listeners."""
|
||||
# Install first
|
||||
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER)
|
||||
resp = await authed_plugin_client.post(
|
||||
"/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Activate
|
||||
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER)
|
||||
resp = await authed_plugin_client.post(
|
||||
"/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "test_sample"
|
||||
@@ -157,6 +171,7 @@ async def test_ac03_activate_plugin(authed_plugin_client: AsyncClient):
|
||||
|
||||
# ─── AC4: POST /api/v1/plugins/{name}/deactivate → 200, status=inactive, routes unregistered ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac04_deactivate_plugin(authed_plugin_client: AsyncClient):
|
||||
"""AC4: Deactivate plugin sets status=inactive and unregisters event listeners."""
|
||||
@@ -165,7 +180,9 @@ async def test_ac04_deactivate_plugin(authed_plugin_client: AsyncClient):
|
||||
await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER)
|
||||
|
||||
# Deactivate
|
||||
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER)
|
||||
resp = await authed_plugin_client.post(
|
||||
"/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "test_sample"
|
||||
@@ -175,8 +192,11 @@ async def test_ac04_deactivate_plugin(authed_plugin_client: AsyncClient):
|
||||
|
||||
# ─── AC5: DELETE /api/v1/plugins/{name} → 200, plugin removed ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac05_uninstall_plugin(authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession):
|
||||
async def test_ac05_uninstall_plugin(
|
||||
authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""AC5: Uninstall plugin removes DB record."""
|
||||
# Install first
|
||||
await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER)
|
||||
@@ -197,6 +217,7 @@ async def test_ac05_uninstall_plugin(authed_plugin_client: AsyncClient, db_sessi
|
||||
|
||||
# ─── AC6: DELETE /api/v1/plugins/{name}?remove_data=true → 200, plugin tables dropped ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac06_uninstall_remove_data(authed_plugin_client: AsyncClient, engine: AsyncEngine):
|
||||
"""AC6: Uninstall with remove_data=true drops plugin tables."""
|
||||
@@ -207,6 +228,7 @@ async def test_ac06_uninstall_remove_data(authed_plugin_client: AsyncClient, eng
|
||||
def _check_table(sync_conn):
|
||||
insp = inspect(sync_conn)
|
||||
return "plugin_test_data" in insp.get_table_names()
|
||||
|
||||
async with engine.connect() as conn:
|
||||
table_exists_before = await conn.run_sync(_check_table)
|
||||
assert table_exists_before, "plugin_test_data table should exist after install"
|
||||
@@ -223,11 +245,14 @@ async def test_ac06_uninstall_remove_data(authed_plugin_client: AsyncClient, eng
|
||||
# Verify table is gone
|
||||
async with engine.connect() as conn:
|
||||
table_exists_after = await conn.run_sync(_check_table)
|
||||
assert not table_exists_after, "plugin_test_data table should be dropped after uninstall with remove_data=true"
|
||||
assert (
|
||||
not table_exists_after
|
||||
), "plugin_test_data table should be dropped after uninstall with remove_data=true"
|
||||
|
||||
|
||||
# ─── AC7: GET /api/v1/plugins/manifest → 200 + manifest schema documentation ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac07_manifest_schema(authed_plugin_client: AsyncClient):
|
||||
"""AC7: GET /api/v1/plugins/manifest returns schema documentation."""
|
||||
@@ -250,6 +275,7 @@ async def test_ac07_manifest_schema(authed_plugin_client: AsyncClient):
|
||||
|
||||
# ─── AC8: Plugin activation registers event listeners on event bus ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac08_activation_registers_event_listeners(
|
||||
authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession
|
||||
@@ -281,6 +307,7 @@ async def test_ac08_activation_registers_event_listeners(
|
||||
|
||||
# ─── AC9: Plugin deactivation unregisters event listeners ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac09_deactivation_unregisters_event_listeners(authed_plugin_client: AsyncClient):
|
||||
"""AC9: Deactivating a plugin unregisters its event listeners from the event bus."""
|
||||
@@ -297,11 +324,15 @@ async def test_ac09_deactivation_unregisters_event_listeners(authed_plugin_clien
|
||||
|
||||
# Publish an event — handler should NOT be called after deactivation
|
||||
initial_log_count = len(plugin.event_log)
|
||||
await event_bus.publish("company.created", {"company_id": "test-456", "name": "After Deactivate"})
|
||||
await event_bus.publish(
|
||||
"company.created", {"company_id": "test-456", "name": "After Deactivate"}
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Event log should not have grown
|
||||
assert len(plugin.event_log) == initial_log_count, "Event handler should not be called after deactivation"
|
||||
assert (
|
||||
len(plugin.event_log) == initial_log_count
|
||||
), "Event handler should not be called after deactivation"
|
||||
|
||||
# Check event bus no longer has the plugin's handlers
|
||||
handlers = event_bus._handlers.get("company.created", [])
|
||||
@@ -312,11 +343,16 @@ async def test_ac09_deactivation_unregisters_event_listeners(authed_plugin_clien
|
||||
|
||||
# ─── AC10: Plugin migration creates tables with tenant_id column ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac10_migration_creates_tenant_id(authed_plugin_client: AsyncClient, engine: AsyncEngine):
|
||||
async def test_ac10_migration_creates_tenant_id(
|
||||
authed_plugin_client: AsyncClient, engine: AsyncEngine
|
||||
):
|
||||
"""AC10: Plugin migration creates tables that have tenant_id column."""
|
||||
# Install the test_sample plugin which has a migration creating plugin_test_data
|
||||
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER)
|
||||
resp = await authed_plugin_client.post(
|
||||
"/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify the table has tenant_id column
|
||||
@@ -335,6 +371,7 @@ async def test_ac10_migration_creates_tenant_id(authed_plugin_client: AsyncClien
|
||||
|
||||
# ─── AC11: Plugin migration validator rejects tables without tenant_id ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac11_validator_rejects_no_tenant_id(authed_plugin_client: AsyncClient):
|
||||
"""AC11: Migration validator rejects tables created without tenant_id column."""
|
||||
@@ -357,11 +394,16 @@ async def test_ac11_validator_rejects_no_tenant_id(authed_plugin_client: AsyncCl
|
||||
|
||||
# ─── AC12: Plugin DB migrations tracked in plugin_migrations table ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac12_migrations_tracked(authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession):
|
||||
async def test_ac12_migrations_tracked(
|
||||
authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""AC12: Plugin migrations are tracked in plugin_migrations table."""
|
||||
# Install the test_sample plugin
|
||||
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER)
|
||||
resp = await authed_plugin_client.post(
|
||||
"/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Check plugin_migrations table has a record
|
||||
@@ -376,17 +418,22 @@ async def test_ac12_migrations_tracked(authed_plugin_client: AsyncClient, db_ses
|
||||
|
||||
# ─── AC13: Activating already-active plugin → idempotent (200, no error) ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac13_activate_already_active(authed_plugin_client: AsyncClient):
|
||||
"""AC13: Activating an already-active plugin is idempotent (returns 200, no error)."""
|
||||
# Install and activate
|
||||
await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER)
|
||||
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER)
|
||||
resp = await authed_plugin_client.post(
|
||||
"/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "active"
|
||||
|
||||
# Activate again — should be idempotent
|
||||
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER)
|
||||
resp = await authed_plugin_client.post(
|
||||
"/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "active"
|
||||
@@ -396,6 +443,7 @@ async def test_ac13_activate_already_active(authed_plugin_client: AsyncClient):
|
||||
|
||||
# ─── AC14: Deactivating inactive plugin → idempotent (200) ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ac14_deactivate_already_inactive(authed_plugin_client: AsyncClient):
|
||||
"""AC14: Deactivating an already-inactive plugin is idempotent (returns 200, no error)."""
|
||||
@@ -404,12 +452,16 @@ async def test_ac14_deactivate_already_inactive(authed_plugin_client: AsyncClien
|
||||
await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER)
|
||||
|
||||
# Deactivate
|
||||
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER)
|
||||
resp = await authed_plugin_client.post(
|
||||
"/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "inactive"
|
||||
|
||||
# Deactivate again — should be idempotent
|
||||
resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER)
|
||||
resp = await authed_plugin_client.post(
|
||||
"/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "inactive"
|
||||
@@ -419,6 +471,7 @@ async def test_ac14_deactivate_already_inactive(authed_plugin_client: AsyncClien
|
||||
|
||||
# ─── Additional Tests: Direct MigrationRunner Unit Tests ───
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_discover_builtins(engine: AsyncEngine):
|
||||
"""Test that registry can discover built-in plugins."""
|
||||
@@ -431,7 +484,9 @@ async def test_registry_discover_builtins(engine: AsyncEngine):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_list_plugins_mixed_states(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_registry_list_plugins_mixed_states(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test listing plugins in various states (discovered, installed, active, inactive)."""
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, None)
|
||||
@@ -464,7 +519,9 @@ async def test_registry_list_plugins_mixed_states(engine: AsyncEngine, db_sessio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_install_idempotent(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_registry_install_idempotent(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test that installing an already-installed plugin is idempotent."""
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, None)
|
||||
@@ -500,7 +557,9 @@ async def test_registry_not_found_errors(engine: AsyncEngine, db_session_for_plu
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_activate_without_install(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_registry_activate_without_install(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test that activating without install raises error."""
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, None)
|
||||
@@ -511,7 +570,9 @@ async def test_registry_activate_without_install(engine: AsyncEngine, db_session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_runner_drop_tables(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_migration_runner_drop_tables(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test MigrationRunner.drop_plugin_tables removes tables and migration records."""
|
||||
runner = MigrationRunner(engine)
|
||||
|
||||
@@ -545,7 +606,9 @@ async def test_migration_runner_drop_tables(engine: AsyncEngine, db_session_for_
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_runner_file_not_found(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_migration_runner_file_not_found(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test MigrationRunner raises FileNotFoundError for missing migration file."""
|
||||
runner = MigrationRunner(engine)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
@@ -592,7 +655,7 @@ def test_plugin_manifest_validation():
|
||||
assert m2.name == "myplugin"
|
||||
|
||||
# Invalid name with special chars
|
||||
with pytest.raises(Exception):
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
PluginManifest(name="my-plugin!", version="1.0.0", display_name="Test")
|
||||
|
||||
|
||||
@@ -606,6 +669,7 @@ def test_base_plugin_repr():
|
||||
|
||||
def test_base_plugin_no_manifest_error():
|
||||
"""Test that BasePlugin without manifest raises ValueError."""
|
||||
|
||||
class NoManifestPlugin(BasePlugin):
|
||||
pass
|
||||
|
||||
@@ -623,10 +687,14 @@ def test_base_plugin_name_version_properties():
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_plugin_on_install_default():
|
||||
"""Test default on_install returns None (no-op)."""
|
||||
|
||||
class MinimalPlugin(BasePlugin):
|
||||
manifest = PluginManifest(
|
||||
name="minimal", version="1.0.0", display_name="Minimal",
|
||||
name="minimal",
|
||||
version="1.0.0",
|
||||
display_name="Minimal",
|
||||
)
|
||||
|
||||
plugin = MinimalPlugin()
|
||||
result = await plugin.on_install(None, None)
|
||||
assert result is None
|
||||
@@ -635,10 +703,14 @@ async def test_base_plugin_on_install_default():
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_plugin_on_uninstall_default():
|
||||
"""Test default on_uninstall returns None (no-op)."""
|
||||
|
||||
class MinimalPlugin(BasePlugin):
|
||||
manifest = PluginManifest(
|
||||
name="minimal2", version="1.0.0", display_name="Minimal",
|
||||
name="minimal2",
|
||||
version="1.0.0",
|
||||
display_name="Minimal",
|
||||
)
|
||||
|
||||
plugin = MinimalPlugin()
|
||||
result = await plugin.on_uninstall(None, None)
|
||||
assert result is None
|
||||
@@ -653,11 +725,15 @@ def test_base_plugin_get_routes_empty():
|
||||
|
||||
def test_base_plugin_make_event_handler_noop():
|
||||
"""Test that _make_event_handler creates noop for unknown events."""
|
||||
|
||||
class MinimalPlugin(BasePlugin):
|
||||
manifest = PluginManifest(
|
||||
name="minimal3", version="1.0.0", display_name="Minimal",
|
||||
name="minimal3",
|
||||
version="1.0.0",
|
||||
display_name="Minimal",
|
||||
events=["unknown.event"],
|
||||
)
|
||||
|
||||
plugin = MinimalPlugin()
|
||||
handler = plugin._make_event_handler("unknown.event")
|
||||
assert handler is not None
|
||||
@@ -669,6 +745,7 @@ def test_base_plugin_make_event_handler_noop():
|
||||
async def test_service_manifest_schema():
|
||||
"""Test plugin service get_manifest_schema returns dict."""
|
||||
from app.services.plugin_service import PluginService
|
||||
|
||||
service = PluginService()
|
||||
schema = service.get_manifest_schema()
|
||||
assert isinstance(schema, dict)
|
||||
@@ -694,10 +771,13 @@ async def test_uninstall_inactive_plugin(engine: AsyncEngine, db_session_for_plu
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_activate_with_app_routes(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_registry_activate_with_app_routes(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test that activating a plugin with a FastAPI app registers routes."""
|
||||
from fastapi import FastAPI, APIRouter
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||
from fastapi import APIRouter, FastAPI
|
||||
|
||||
from app.plugins.manifest import PluginManifest
|
||||
|
||||
# Create a plugin with a route
|
||||
class RoutePlugin(BasePlugin):
|
||||
@@ -707,11 +787,14 @@ async def test_registry_activate_with_app_routes(engine: AsyncEngine, db_session
|
||||
display_name="Route Plugin",
|
||||
events=["test.event"],
|
||||
)
|
||||
|
||||
def get_routes(self) -> list:
|
||||
test_router = APIRouter()
|
||||
|
||||
@test_router.get("/api/v1/route-plugin/test")
|
||||
async def test_endpoint():
|
||||
return {"status": "ok"}
|
||||
|
||||
return [test_router]
|
||||
|
||||
app = FastAPI()
|
||||
@@ -737,7 +820,9 @@ async def test_registry_activate_with_app_routes(engine: AsyncEngine, db_session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_uninstall_with_remove_data(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_registry_uninstall_with_remove_data(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test registry uninstall with remove_data drops tables."""
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, None)
|
||||
@@ -763,7 +848,9 @@ async def test_registry_not_initialized_errors():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_deactivate_already_inactive_direct(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_registry_deactivate_already_inactive_direct(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test registry.deactivate is idempotent when already inactive."""
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, None)
|
||||
@@ -785,9 +872,12 @@ async def test_registry_deactivate_already_inactive_direct(engine: AsyncEngine,
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_service_install_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_plugin_service_install_error_handling(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test plugin service raises ValueError for unknown plugin."""
|
||||
from app.services.plugin_service import PluginService
|
||||
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, None)
|
||||
service = PluginService(registry=registry)
|
||||
@@ -797,9 +887,12 @@ async def test_plugin_service_install_error_handling(engine: AsyncEngine, db_ses
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_service_activate_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_plugin_service_activate_error_handling(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test plugin service raises ValueError when activating uninstalled plugin."""
|
||||
from app.services.plugin_service import PluginService
|
||||
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, None)
|
||||
registry.register_plugin(TestSamplePlugin())
|
||||
@@ -810,9 +903,12 @@ async def test_plugin_service_activate_error_handling(engine: AsyncEngine, db_se
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_service_deactivate_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_plugin_service_deactivate_error_handling(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test plugin service raises ValueError for deactivating unknown plugin."""
|
||||
from app.services.plugin_service import PluginService
|
||||
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, None)
|
||||
service = PluginService(registry=registry)
|
||||
@@ -822,9 +918,12 @@ async def test_plugin_service_deactivate_error_handling(engine: AsyncEngine, db_
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_service_uninstall_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_plugin_service_uninstall_error_handling(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test plugin service raises ValueError for uninstalling unknown plugin."""
|
||||
from app.services.plugin_service import PluginService
|
||||
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, None)
|
||||
service = PluginService(registry=registry)
|
||||
@@ -842,7 +941,9 @@ async def test_migration_runner_split_sql_dollar_quotes():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_list_plugins_db_only_record(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_registry_list_plugins_db_only_record(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test listing plugins includes DB-only records (installed but not discovered)."""
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, None)
|
||||
@@ -869,11 +970,15 @@ async def test_registry_list_plugins_db_only_record(engine: AsyncEngine, db_sess
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_plugin_on_event_fallback():
|
||||
"""Test that on_event fallback handler works."""
|
||||
|
||||
class FallbackPlugin(BasePlugin):
|
||||
manifest = PluginManifest(
|
||||
name="fallback", version="1.0.0", display_name="Fallback",
|
||||
name="fallback",
|
||||
version="1.0.0",
|
||||
display_name="Fallback",
|
||||
events=["custom.event"],
|
||||
)
|
||||
|
||||
async def on_event(self, payload: dict[str, Any]) -> None:
|
||||
self.event_received = payload
|
||||
|
||||
@@ -884,7 +989,9 @@ async def test_base_plugin_on_event_fallback():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_runner_valid_migration(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_migration_runner_valid_migration(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test MigrationRunner directly with a valid migration (has tenant_id)."""
|
||||
runner = MigrationRunner(engine)
|
||||
record = await runner.run_migration(
|
||||
@@ -899,7 +1006,9 @@ async def test_migration_runner_valid_migration(engine: AsyncEngine, db_session_
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_runner_invalid_migration(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_migration_runner_invalid_migration(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test MigrationRunner directly rejects migration without tenant_id."""
|
||||
runner = MigrationRunner(engine)
|
||||
with pytest.raises(MigrationValidationError) as exc_info:
|
||||
@@ -912,7 +1021,9 @@ async def test_migration_runner_invalid_migration(engine: AsyncEngine, db_sessio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_runner_idempotent(engine: AsyncEngine, db_session_for_plugins: AsyncSession):
|
||||
async def test_migration_runner_idempotent(
|
||||
engine: AsyncEngine, db_session_for_plugins: AsyncSession
|
||||
):
|
||||
"""Test MigrationRunner skips already-applied migrations."""
|
||||
runner = MigrationRunner(engine)
|
||||
# Run migration
|
||||
|
||||
+40
-17
@@ -7,16 +7,15 @@ import uuid
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
|
||||
from app.core.db import Base, reset_engine_for_testing, close_engine
|
||||
from app.core.db import close_engine, reset_engine_for_testing
|
||||
from app.core.service_container import get_container
|
||||
from app.main import create_app
|
||||
from app.plugins.registry import get_registry, reset_registry_for_testing
|
||||
from app.plugins.builtins.tags import TagsPlugin
|
||||
from app.plugins.registry import reset_registry_for_testing
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||
from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@@ -48,7 +47,7 @@ async def plugin_client(plugin_app) -> AsyncClient:
|
||||
@pytest_asyncio.fixture
|
||||
async def authed_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient:
|
||||
"""Authenticated admin client with seeded data."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(plugin_client, "admin@tenanta.com")
|
||||
# Install + activate the tags plugin
|
||||
resp = await plugin_client.post("/api/v1/plugins/tags/install", headers=ORIGIN_HEADER)
|
||||
@@ -91,7 +90,7 @@ async def test_list_tags_with_counts(authed_client: AsyncClient):
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
tag_id = resp.json()["id"]
|
||||
resp.json()["id"]
|
||||
|
||||
# List tags — should show entity_count=0
|
||||
resp = await authed_client.get("/api/v1/tags", headers=ORIGIN_HEADER)
|
||||
@@ -302,11 +301,15 @@ async def test_update_tag_invalid_id(authed_client: AsyncClient):
|
||||
async def test_update_tag_duplicate_name(authed_client: AsyncClient):
|
||||
"""PATCH /api/v1/tags/{id} with existing name → 409."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "TagA", "color": "#AAAAAA"}, headers=ORIGIN_HEADER,
|
||||
"/api/v1/tags",
|
||||
json={"name": "TagA", "color": "#AAAAAA"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "TagB", "color": "#BBBBBB"}, headers=ORIGIN_HEADER,
|
||||
"/api/v1/tags",
|
||||
json={"name": "TagB", "color": "#BBBBBB"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
tag_b_id = resp.json()["id"]
|
||||
@@ -322,7 +325,9 @@ async def test_update_tag_duplicate_name(authed_client: AsyncClient):
|
||||
async def test_update_tag_color_only(authed_client: AsyncClient):
|
||||
"""PATCH /api/v1/tags/{id} with only color → 200."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "ColorTag", "color": "#CCCCCC"}, headers=ORIGIN_HEADER,
|
||||
"/api/v1/tags",
|
||||
json={"name": "ColorTag", "color": "#CCCCCC"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
resp = await authed_client.patch(
|
||||
@@ -352,7 +357,9 @@ async def test_delete_tag_invalid_id(authed_client: AsyncClient):
|
||||
async def test_assign_tag_invalid_entity_type(authed_client: AsyncClient):
|
||||
"""POST /api/v1/tags/assign with invalid entity_type → 400."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "ETTag", "color": "#EEEEEE"}, headers=ORIGIN_HEADER,
|
||||
"/api/v1/tags",
|
||||
json={"name": "ETTag", "color": "#EEEEEE"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
resp = await authed_client.post(
|
||||
@@ -368,7 +375,11 @@ async def test_assign_tag_not_found(authed_client: AsyncClient):
|
||||
"""POST /api/v1/tags/assign with nonexistent tag → 404."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/assign",
|
||||
json={"tag_id": str(uuid.uuid4()), "entity_type": "company", "entity_id": str(uuid.uuid4())},
|
||||
json={
|
||||
"tag_id": str(uuid.uuid4()),
|
||||
"entity_type": "company",
|
||||
"entity_id": str(uuid.uuid4()),
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -378,7 +389,9 @@ async def test_assign_tag_not_found(authed_client: AsyncClient):
|
||||
async def test_assign_tag_already_assigned(authed_client: AsyncClient):
|
||||
"""POST /api/v1/tags/assign twice → already_assigned=True."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "DupAssign", "color": "#FFFFFF"}, headers=ORIGIN_HEADER,
|
||||
"/api/v1/tags",
|
||||
json={"name": "DupAssign", "color": "#FFFFFF"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
entity_id = str(uuid.uuid4())
|
||||
@@ -413,7 +426,9 @@ async def test_assign_tag_invalid_ids(authed_client: AsyncClient):
|
||||
async def test_unassign_tag_not_found(authed_client: AsyncClient):
|
||||
"""DELETE /api/v1/tags/assign with nonexistent assignment → 404."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "Unassign404", "color": "#ABABAB"}, headers=ORIGIN_HEADER,
|
||||
"/api/v1/tags",
|
||||
json={"name": "Unassign404", "color": "#ABABAB"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
resp = await authed_client.request(
|
||||
@@ -429,7 +444,9 @@ async def test_unassign_tag_not_found(authed_client: AsyncClient):
|
||||
async def test_bulk_assign_invalid_entity_type(authed_client: AsyncClient):
|
||||
"""POST /api/v1/tags/bulk-assign with invalid entity_type → 400."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "BulkBad", "color": "#000001"}, headers=ORIGIN_HEADER,
|
||||
"/api/v1/tags",
|
||||
json={"name": "BulkBad", "color": "#000001"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
resp = await authed_client.post(
|
||||
@@ -445,7 +462,11 @@ async def test_bulk_assign_tag_not_found(authed_client: AsyncClient):
|
||||
"""POST /api/v1/tags/bulk-assign with nonexistent tag → 404."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags/bulk-assign",
|
||||
json={"tag_ids": [str(uuid.uuid4())], "entity_type": "file", "entity_id": str(uuid.uuid4())},
|
||||
json={
|
||||
"tag_ids": [str(uuid.uuid4())],
|
||||
"entity_type": "file",
|
||||
"entity_id": str(uuid.uuid4()),
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -455,7 +476,9 @@ async def test_bulk_assign_tag_not_found(authed_client: AsyncClient):
|
||||
async def test_bulk_assign_already_assigned(authed_client: AsyncClient):
|
||||
"""POST /api/v1/tags/bulk-assign twice → already_assigned populated."""
|
||||
resp = await authed_client.post(
|
||||
"/api/v1/tags", json={"name": "BulkDup1", "color": "#001100"}, headers=ORIGIN_HEADER,
|
||||
"/api/v1/tags",
|
||||
json={"name": "BulkDup1", "color": "#001100"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
tag_id = resp.json()["id"]
|
||||
entity_id = str(uuid.uuid4())
|
||||
|
||||
+14
-5
@@ -5,12 +5,10 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.company import Company
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -60,6 +58,7 @@ class TestUserManagement:
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
# Get viewer user ID
|
||||
from app.models.user import User
|
||||
|
||||
q = select(User).where(User.email == "viewer@tenanta.com")
|
||||
result = await db_session.execute(q)
|
||||
viewer = result.scalar_one()
|
||||
@@ -76,6 +75,7 @@ class TestUserManagement:
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
from app.models.user import User
|
||||
|
||||
q = select(User).where(User.email == "viewer@tenanta.com")
|
||||
result = await db_session.execute(q)
|
||||
viewer = result.scalar_one()
|
||||
@@ -102,7 +102,9 @@ class TestRoleManagement:
|
||||
assert "permissions" in data["items"][0]
|
||||
assert "field_permissions" in data["items"][0]
|
||||
|
||||
async def test_create_role_with_custom_permissions_returns_201(self, client: AsyncClient, db_session):
|
||||
async def test_create_role_with_custom_permissions_returns_201(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
"""AC 16: POST /api/v1/roles with custom permissions -> 201."""
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
@@ -110,7 +112,9 @@ class TestRoleManagement:
|
||||
"/api/v1/roles",
|
||||
json={
|
||||
"name": "manager",
|
||||
"permissions": {"companies": {"read": True, "create": True, "update": True, "delete": True}},
|
||||
"permissions": {
|
||||
"companies": {"read": True, "create": True, "update": True, "delete": True}
|
||||
},
|
||||
"field_permissions": {"annual_revenue": "read"},
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
@@ -172,6 +176,7 @@ class TestFieldPermissions:
|
||||
# Create a user with sales_rep role
|
||||
from app.core.auth import hash_password
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
sales_user = User(
|
||||
tenant_id=seed["tenant_a"].id,
|
||||
email="sales@tenanta.com",
|
||||
@@ -213,6 +218,7 @@ class TestAuditLog:
|
||||
|
||||
# Check audit_log table
|
||||
from sqlalchemy import select as sel
|
||||
|
||||
q = sel(AuditLog).where(AuditLog.entity_type == "company", AuditLog.action == "create")
|
||||
result = await db_session.execute(q)
|
||||
entries = result.scalars().all()
|
||||
@@ -230,6 +236,7 @@ class TestAuditLog:
|
||||
assert resp.status_code == 200
|
||||
|
||||
from sqlalchemy import select as sel
|
||||
|
||||
q = sel(AuditLog).where(AuditLog.entity_type == "company", AuditLog.action == "update")
|
||||
result = await db_session.execute(q)
|
||||
entries = result.scalars().all()
|
||||
@@ -246,6 +253,7 @@ class TestAuditLog:
|
||||
assert resp.status_code == 204
|
||||
|
||||
from sqlalchemy import select as sel
|
||||
|
||||
q = sel(AuditLog).where(AuditLog.entity_type == "company", AuditLog.action == "delete")
|
||||
result = await db_session.execute(q)
|
||||
entries = result.scalars().all()
|
||||
@@ -275,6 +283,7 @@ class TestNotificationOnAssign:
|
||||
|
||||
# Check notification was created for the new user
|
||||
from sqlalchemy import select as sel
|
||||
|
||||
q = sel(Notification).where(Notification.user_id == new_user_id)
|
||||
result = await db_session.execute(q)
|
||||
notifs = result.scalars().all()
|
||||
|
||||
+456
-245
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user