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

This commit is contained in:
leocrm-bot
2026-06-29 17:43:56 +02:00
parent 316f323ff4
commit a2452cc04b
81 changed files with 2317 additions and 1128 deletions
+27 -19
View File
@@ -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
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
from datetime import UTC
import pytest
from httpx import ASGITransport, AsyncClient
from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
@@ -68,7 +70,10 @@ async def test_ac3_copilot_execute_blocked_by_rbac(client: AsyncClient, db_sessi
# Query for a delete action
query_resp = await client.post(
"/api/v1/ai/copilot/query",
json={"query": "Delete company", "context": {"entity_id": "00000000-0000-0000-0000-000000000000"}},
json={
"query": "Delete company",
"context": {"entity_id": "00000000-0000-0000-0000-000000000000"},
},
headers=ORIGIN_HEADER,
)
assert query_resp.status_code == 200
@@ -119,6 +124,7 @@ async def test_ac4_copilot_history_paginated(client: AsyncClient, db_session):
async def test_ac5_copilot_action_logged_in_audit(client: AsyncClient, db_session):
"""AC5: Copilot action logged in audit_log with entity_type=ai_copilot."""
from sqlalchemy import select
from app.models.audit import AuditLog
await seed_tenant_and_users(db_session)
@@ -133,9 +139,7 @@ async def test_ac5_copilot_action_logged_in_audit(client: AsyncClient, db_sessio
assert resp.status_code == 200
# Check audit log
result = await db_session.execute(
select(AuditLog).where(AuditLog.entity_type == "ai_copilot")
)
result = await db_session.execute(select(AuditLog).where(AuditLog.entity_type == "ai_copilot"))
logs = result.scalars().all()
assert len(logs) >= 1
assert logs[0].action == "query"
@@ -158,12 +162,14 @@ async def test_ac6_copilot_tenant_isolation(client: AsyncClient, db_session):
conv_id_a = query_resp.json()["conversation_id"]
# Login as tenant B admin (different cookie jar)
client2 = AsyncClient(transport=ASGITransport(app=client._transport.app), base_url="http://test")
AsyncClient(transport=ASGITransport(app=client._transport.app), base_url="http://test")
# Need to use the same app — just re-login with a fresh client
# Actually we need a new client without tenant A cookies
from httpx import AsyncClient as AC
from httpx import AsyncClient as AC # noqa: N817
# Use the same app instance
import app.main
app_instance = app.main.app
async with AC(transport=ASGITransport(app=app_instance), base_url="http://test") as client_b:
@@ -174,7 +180,12 @@ async def test_ac6_copilot_tenant_isolation(client: AsyncClient, db_session):
"/api/v1/ai/copilot/execute",
json={
"conversation_id": conv_id_a,
"action": {"method": "GET", "path": "/api/v1/companies", "description": "List", "confidence": 0.9},
"action": {
"method": "GET",
"path": "/api/v1/companies",
"description": "List",
"confidence": 0.9,
},
},
headers=ORIGIN_HEADER,
)
@@ -254,9 +265,11 @@ async def test_copilot_unauthenticated(client: AsyncClient, db_session):
# ─── ActionMapper Unit Tests ───
def test_action_mapper_create_company():
"""ActionMapper: 'create company named X' → POST /api/v1/companies."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Create a company named Acme Corp")
assert len(actions) == 1
assert actions[0]["method"] == "POST"
@@ -268,6 +281,7 @@ def test_action_mapper_create_company():
def test_action_mapper_create_company_no_name():
"""ActionMapper: 'create company' without name → default 'New Company'."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Add a new company")
assert len(actions) == 1
assert actions[0]["body"]["name"] == "New Company"
@@ -276,6 +290,7 @@ def test_action_mapper_create_company_no_name():
def test_action_mapper_delete_company_with_context():
"""ActionMapper: 'delete company' with entity_id in context → DELETE with specific ID."""
from app.ai.action_mapper import map_query_to_actions
test_id = "12345678-1234-1234-1234-123456789abc"
actions = map_query_to_actions("Delete company", context={"entity_id": test_id})
assert len(actions) == 1
@@ -287,6 +302,7 @@ def test_action_mapper_delete_company_with_context():
def test_action_mapper_delete_company_no_context():
"""ActionMapper: 'delete company' without context → DELETE with {id} placeholder."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Remove company")
assert len(actions) == 1
assert actions[0]["method"] == "DELETE"
@@ -297,6 +313,7 @@ def test_action_mapper_delete_company_no_context():
def test_action_mapper_update_company():
"""ActionMapper: 'update company' → PATCH with extracted fields."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Update company industry to Tech, name to FooBar")
assert len(actions) == 1
assert actions[0]["method"] == "PATCH"
@@ -307,6 +324,7 @@ def test_action_mapper_update_company():
def test_action_mapper_update_company_with_context():
"""ActionMapper: 'update company' with entity_id → PATCH with specific path."""
from app.ai.action_mapper import map_query_to_actions
test_id = "12345678-1234-1234-1234-123456789abc"
actions = map_query_to_actions("Edit company", context={"company_id": test_id})
assert len(actions) == 1
@@ -316,6 +334,7 @@ def test_action_mapper_update_company_with_context():
def test_action_mapper_list_companies():
"""ActionMapper: 'list companies' → GET /api/v1/companies."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Show all compan")
assert len(actions) == 1
assert actions[0]["method"] == "GET"
@@ -325,6 +344,7 @@ def test_action_mapper_list_companies():
def test_action_mapper_list_companies_with_search():
"""ActionMapper: 'find companies named X' → GET with search description."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Find compan named Acme")
assert len(actions) == 1
assert "Acme" in actions[0]["description"]
@@ -333,6 +353,7 @@ def test_action_mapper_list_companies_with_search():
def test_action_mapper_create_contact():
"""ActionMapper: 'create contact named X' → POST /api/v1/contacts."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Create a contact named John Doe")
assert len(actions) == 1
assert actions[0]["method"] == "POST"
@@ -343,6 +364,7 @@ def test_action_mapper_create_contact():
def test_action_mapper_list_contacts():
"""ActionMapper: 'list contacts' → GET /api/v1/contacts."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Show all contact")
assert len(actions) == 1
assert actions[0]["method"] == "GET"
@@ -352,6 +374,7 @@ def test_action_mapper_list_contacts():
def test_action_mapper_list_workflows():
"""ActionMapper: 'list workflows' → GET /api/v1/workflows."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Show all workflow")
assert len(actions) == 1
assert actions[0]["method"] == "GET"
@@ -361,6 +384,7 @@ def test_action_mapper_list_workflows():
def test_action_mapper_create_workflow():
"""ActionMapper: 'create workflow named X' → POST /api/v1/workflows."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Create a new workflow named Approval Process")
assert len(actions) == 1
assert actions[0]["method"] == "POST"
@@ -371,6 +395,7 @@ def test_action_mapper_create_workflow():
def test_action_mapper_help_intent():
"""ActionMapper: 'help' query returns demo action with low confidence."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("What can you do? Help me please")
assert len(actions) == 1
assert actions[0]["confidence"] == 0.3
@@ -379,6 +404,7 @@ def test_action_mapper_help_intent():
def test_action_mapper_unknown_query():
"""ActionMapper: unrecognized query returns empty actions list."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("xyzzy flonk")
assert actions == []
@@ -386,6 +412,7 @@ def test_action_mapper_unknown_query():
def test_action_mapper_update_company_phone_email():
"""ActionMapper: update company with phone and email fields."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Update company phone to 123456, email to test@examplecom")
assert len(actions) == 1
assert actions[0]["body"]["phone"] == "123456"
@@ -395,6 +422,7 @@ def test_action_mapper_update_company_phone_email():
def test_action_mapper_update_company_no_fields():
"""ActionMapper: update company without explicit fields → default name."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Modify company details")
assert len(actions) == 1
assert "name" in actions[0]["body"]
@@ -403,6 +431,7 @@ def test_action_mapper_update_company_no_fields():
def test_action_mapper_company_list_all_pattern():
"""ActionMapper: 'companies all' matches list_company2 pattern."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("Show me companies all")
assert len(actions) == 1
assert actions[0]["method"] == "GET"
@@ -411,16 +440,19 @@ def test_action_mapper_company_list_all_pattern():
def test_action_mapper_empty_query():
"""ActionMapper: empty query returns no actions."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions("")
assert actions == []
# ─── LLMClient Unit Tests ───
@pytest.mark.asyncio
async def test_llm_client_mock_mode_generate():
"""LLMClient: mock mode generates actions from keyword matching."""
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
assert client.is_mock is True
@@ -434,6 +466,7 @@ async def test_llm_client_mock_mode_generate():
async def test_llm_client_mock_mode_no_actions():
"""LLMClient: mock mode with unrecognized query returns empty actions."""
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
response = await client.generate("xyzzy flonk")
@@ -445,6 +478,7 @@ async def test_llm_client_mock_mode_no_actions():
async def test_llm_client_mock_mode_with_context():
"""LLMClient: mock mode passes context to action mapper."""
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
response = await client.generate("Delete company", context={"entity_id": "abc123"})
@@ -455,6 +489,7 @@ async def test_llm_client_mock_mode_with_context():
def test_llm_client_api_mode_init():
"""LLMClient: with model and api_key set, is_mock is False."""
from app.ai.llm_client import LLMClient
client = LLMClient(model="gpt-4", api_key="test-key")
assert client.is_mock is False
assert client.model == "gpt-4"
@@ -464,6 +499,7 @@ def test_llm_client_api_mode_init():
def test_llm_client_api_base_default():
"""LLMClient: default api_base is OpenAI URL."""
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
assert client.api_base == "https://api.openai.com/v1"
@@ -471,6 +507,7 @@ def test_llm_client_api_base_default():
def test_llm_client_to_dict():
"""LLMResponse: to_dict returns structured response."""
from app.ai.llm_client import LLMResponse
resp = LLMResponse(message="Hello", proposed_actions=[{"method": "GET"}], confidence=0.9)
d = resp.to_dict()
assert d["message"] == "Hello"
@@ -480,14 +517,18 @@ def test_llm_client_to_dict():
def test_llm_client_parse_valid_json():
"""LLMClient: _parse_llm_response with valid JSON returns structured response."""
from app.ai.llm_client import LLMClient
import json
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
content = json.dumps({
"message": "Here are actions",
"proposed_actions": [{"method": "GET", "path": "/api/v1/companies"}],
"confidence": 0.95,
})
content = json.dumps(
{
"message": "Here are actions",
"proposed_actions": [{"method": "GET", "path": "/api/v1/companies"}],
"confidence": 0.95,
}
)
response = client._parse_llm_response(content)
assert response.message == "Here are actions"
assert len(response.proposed_actions) == 1
@@ -497,6 +538,7 @@ def test_llm_client_parse_valid_json():
def test_llm_client_parse_invalid_json():
"""LLMClient: _parse_llm_response with invalid JSON returns fallback response."""
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
response = client._parse_llm_response("not valid json at all")
assert response.proposed_actions == []
@@ -506,6 +548,7 @@ def test_llm_client_parse_invalid_json():
def test_llm_client_build_system_prompt():
"""LLMClient: _build_system_prompt contains API endpoints and context."""
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
prompt = client._build_system_prompt({"page": "companies"})
assert "LeoCRM" in prompt
@@ -516,6 +559,7 @@ def test_llm_client_build_system_prompt():
def test_llm_client_build_system_prompt_empty_context():
"""LLMClient: _build_system_prompt with no context uses empty dict."""
from app.ai.llm_client import LLMClient
client = LLMClient(model=None, api_key=None)
prompt = client._build_system_prompt({})
assert "LeoCRM" in prompt
@@ -523,7 +567,8 @@ def test_llm_client_build_system_prompt_empty_context():
def test_llm_client_get_and_reset():
"""LLMClient: get_llm_client returns singleton, reset clears it."""
from app.ai.llm_client import get_llm_client, reset_llm_client, LLMClient
from app.ai.llm_client import get_llm_client, reset_llm_client
reset_llm_client()
client1 = get_llm_client()
client2 = get_llm_client()
@@ -535,10 +580,12 @@ def test_llm_client_get_and_reset():
# ─── AI Copilot Service Unit Tests ───
@pytest.mark.asyncio
async def test_service_process_query_new_conversation(db_session):
"""Service: process_query creates new conversation and returns proposed actions."""
from app.services.ai_copilot_service import process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -553,6 +600,7 @@ async def test_service_process_query_new_conversation(db_session):
async def test_service_process_query_existing_conversation(db_session):
"""Service: process_query with existing conversation_id appends to conversation."""
from app.services.ai_copilot_service import process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -563,7 +611,9 @@ async def test_service_process_query_existing_conversation(db_session):
# Second query with same conversation_id
result2 = await process_query(
db_session, tenant_id, admin_id,
db_session,
tenant_id,
admin_id,
"Create a company named FooBar",
conversation_id=conv_id,
)
@@ -574,12 +624,16 @@ async def test_service_process_query_existing_conversation(db_session):
async def test_service_process_query_invalid_conversation(db_session):
"""Service: process_query with invalid conversation_id returns 404 error."""
from app.services.ai_copilot_service import process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
result = await process_query(
db_session, tenant_id, admin_id, "List companies",
db_session,
tenant_id,
admin_id,
"List companies",
conversation_id="00000000-0000-0000-0000-000000000000",
)
assert result["error"] == "Conversation not found"
@@ -590,6 +644,7 @@ async def test_service_process_query_invalid_conversation(db_session):
async def test_service_process_query_empty_query(db_session):
"""Service: process_query with empty query creates conversation with 'Untitled' title."""
from app.services.ai_copilot_service import process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -602,17 +657,23 @@ async def test_service_process_query_empty_query(db_session):
async def test_service_execute_action_companies_get(db_session):
"""Service: execute_action with GET /api/v1/companies returns list."""
from app.services.ai_copilot_service import execute_action
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
# First create a conversation
from app.services.ai_copilot_service import process_query
query_result = await process_query(db_session, tenant_id, admin_id, "List companies")
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "GET", "path": "/api/v1/companies", "body": None},
)
assert result["success"] is True
@@ -624,6 +685,7 @@ async def test_service_execute_action_companies_get(db_session):
async def test_service_execute_action_companies_post(db_session):
"""Service: execute_action with POST /api/v1/companies creates company."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -632,8 +694,16 @@ async def test_service_execute_action_companies_post(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
{"method": "POST", "path": "/api/v1/companies", "body": {"name": "NewCo", "industry": "Tech"}},
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{
"method": "POST",
"path": "/api/v1/companies",
"body": {"name": "NewCo", "industry": "Tech"},
},
)
assert result["success"] is True
assert result["status_code"] == 201
@@ -644,6 +714,7 @@ async def test_service_execute_action_companies_post(db_session):
async def test_service_execute_action_companies_patch(db_session):
"""Service: execute_action with PATCH /api/v1/companies/{id} updates company."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -653,15 +724,27 @@ async def test_service_execute_action_companies_patch(db_session):
conv_id = query_result["conversation_id"]
create_result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "POST", "path": "/api/v1/companies", "body": {"name": "PatchCo"}},
)
company_id = create_result["data"]["id"]
# Now patch it
patch_result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
{"method": "PATCH", "path": f"/api/v1/companies/{company_id}", "body": {"name": "PatchedCo"}},
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{
"method": "PATCH",
"path": f"/api/v1/companies/{company_id}",
"body": {"name": "PatchedCo"},
},
)
assert patch_result["success"] is True
assert patch_result["data"]["name"] == "PatchedCo"
@@ -671,6 +754,7 @@ async def test_service_execute_action_companies_patch(db_session):
async def test_service_execute_action_companies_patch_not_found(db_session):
"""Service: execute_action with PATCH non-existent company returns 404."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -679,8 +763,16 @@ async def test_service_execute_action_companies_patch_not_found(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
{"method": "PATCH", "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", "body": {"name": "X"}},
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{
"method": "PATCH",
"path": "/api/v1/companies/00000000-0000-0000-0000-000000000000",
"body": {"name": "X"},
},
)
assert result["success"] is False
assert result["status_code"] == 404
@@ -690,6 +782,7 @@ async def test_service_execute_action_companies_patch_not_found(db_session):
async def test_service_execute_action_companies_patch_no_id(db_session):
"""Service: execute_action with PATCH /api/v1/companies/{id} returns 400."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -698,7 +791,11 @@ async def test_service_execute_action_companies_patch_no_id(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "PATCH", "path": "/api/v1/companies/{id}", "body": {"name": "X"}},
)
assert result["success"] is False
@@ -709,6 +806,7 @@ async def test_service_execute_action_companies_patch_no_id(db_session):
async def test_service_execute_action_companies_delete(db_session):
"""Service: execute_action with DELETE /api/v1/companies/{id} soft-deletes company."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -717,13 +815,21 @@ async def test_service_execute_action_companies_delete(db_session):
conv_id = query_result["conversation_id"]
create_result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "POST", "path": "/api/v1/companies", "body": {"name": "DeleteMe"}},
)
company_id = create_result["data"]["id"]
del_result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "DELETE", "path": f"/api/v1/companies/{company_id}", "body": None},
)
assert del_result["success"] is True
@@ -734,6 +840,7 @@ async def test_service_execute_action_companies_delete(db_session):
async def test_service_execute_action_companies_delete_not_found(db_session):
"""Service: execute_action with DELETE non-existent company returns 404."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -742,8 +849,16 @@ async def test_service_execute_action_companies_delete_not_found(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
{"method": "DELETE", "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", "body": None},
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{
"method": "DELETE",
"path": "/api/v1/companies/00000000-0000-0000-0000-000000000000",
"body": None,
},
)
assert result["success"] is False
assert result["status_code"] == 404
@@ -753,6 +868,7 @@ async def test_service_execute_action_companies_delete_not_found(db_session):
async def test_service_execute_action_companies_delete_no_id(db_session):
"""Service: execute_action with DELETE without ID returns 400."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -761,7 +877,11 @@ async def test_service_execute_action_companies_delete_no_id(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "DELETE", "path": "/api/v1/companies/{id}", "body": None},
)
assert result["success"] is False
@@ -772,6 +892,7 @@ async def test_service_execute_action_companies_delete_no_id(db_session):
async def test_service_execute_action_contacts_get(db_session):
"""Service: execute_action with GET /api/v1/contacts returns list."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -780,7 +901,11 @@ async def test_service_execute_action_contacts_get(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "GET", "path": "/api/v1/contacts", "body": None},
)
assert result["success"] is True
@@ -792,6 +917,7 @@ async def test_service_execute_action_contacts_post(db_session):
"""Service: execute_action with POST /api/v1/contacts — Contact model uses first_name/last_name,
service passes 'name' which causes error, verify error handling works."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -800,8 +926,16 @@ async def test_service_execute_action_contacts_post(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
{"method": "POST", "path": "/api/v1/contacts", "body": {"name": "John Doe", "email": "john@example.com"}},
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{
"method": "POST",
"path": "/api/v1/contacts",
"body": {"name": "John Doe", "email": "john@example.com"},
},
)
# Contact model has first_name/last_name, not name — service code attempts to set 'name'
# which raises TypeError, caught by execute_action's try/except
@@ -813,6 +947,7 @@ async def test_service_execute_action_contacts_post(db_session):
async def test_service_execute_action_contacts_unsupported_method(db_session):
"""Service: execute_action with DELETE /api/v1/contacts returns 400 (unsupported)."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -821,7 +956,11 @@ async def test_service_execute_action_contacts_unsupported_method(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "DELETE", "path": "/api/v1/contacts/123", "body": None},
)
assert result["success"] is False
@@ -832,6 +971,7 @@ async def test_service_execute_action_contacts_unsupported_method(db_session):
async def test_service_execute_action_workflows_get(db_session):
"""Service: execute_action with GET /api/v1/workflows returns list."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -840,7 +980,11 @@ async def test_service_execute_action_workflows_get(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "GET", "path": "/api/v1/workflows", "body": None},
)
assert result["success"] is True
@@ -851,6 +995,7 @@ async def test_service_execute_action_workflows_get(db_session):
async def test_service_execute_action_workflows_unsupported_method(db_session):
"""Service: execute_action with POST /api/v1/workflows returns 400 (unsupported)."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -859,7 +1004,11 @@ async def test_service_execute_action_workflows_unsupported_method(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "POST", "path": "/api/v1/workflows", "body": {"name": "test"}},
)
assert result["success"] is False
@@ -870,6 +1019,7 @@ async def test_service_execute_action_workflows_unsupported_method(db_session):
async def test_service_execute_action_unsupported_entity(db_session):
"""Service: execute_action with unknown entity returns 400."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -878,7 +1028,11 @@ async def test_service_execute_action_unsupported_entity(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "GET", "path": "/api/v1/unknown", "body": None},
)
assert result["success"] is False
@@ -890,6 +1044,7 @@ async def test_service_execute_action_unsupported_entity(db_session):
async def test_service_execute_action_companies_unsupported_method(db_session):
"""Service: execute_action with PUT /api/v1/companies returns 400 (unsupported)."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -898,7 +1053,11 @@ async def test_service_execute_action_companies_unsupported_method(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "admin", conv_id,
db_session,
tenant_id,
admin_id,
"admin",
conv_id,
{"method": "PUT", "path": "/api/v1/companies", "body": {}},
)
assert result["success"] is False
@@ -909,12 +1068,16 @@ async def test_service_execute_action_companies_unsupported_method(db_session):
async def test_service_execute_action_invalid_conversation(db_session):
"""Service: execute_action with invalid conversation_id returns 404."""
from app.services.ai_copilot_service import execute_action
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
result = await execute_action(
db_session, tenant_id, admin_id, "admin",
db_session,
tenant_id,
admin_id,
"admin",
"00000000-0000-0000-0000-000000000000",
{"method": "GET", "path": "/api/v1/companies", "body": None},
)
@@ -926,6 +1089,7 @@ async def test_service_execute_action_invalid_conversation(db_session):
async def test_service_execute_action_rbac_blocked(db_session):
"""Service: execute_action as viewer with DELETE returns 403."""
from app.services.ai_copilot_service import execute_action, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -934,8 +1098,16 @@ async def test_service_execute_action_rbac_blocked(db_session):
conv_id = query_result["conversation_id"]
result = await execute_action(
db_session, tenant_id, admin_id, "viewer", conv_id,
{"method": "DELETE", "path": "/api/v1/companies/00000000-0000-0000-0000-000000000000", "body": None},
db_session,
tenant_id,
admin_id,
"viewer",
conv_id,
{
"method": "DELETE",
"path": "/api/v1/companies/00000000-0000-0000-0000-000000000000",
"body": None,
},
)
assert result["status_code"] == 403
assert result["success"] is False
@@ -945,6 +1117,7 @@ async def test_service_execute_action_rbac_blocked(db_session):
async def test_service_get_history_pagination(db_session):
"""Service: get_history returns paginated results."""
from app.services.ai_copilot_service import get_history, process_query
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -963,6 +1136,7 @@ async def test_service_get_history_pagination(db_session):
async def test_service_get_history_empty(db_session):
"""Service: get_history returns empty when no conversations exist."""
from app.services.ai_copilot_service import get_history
seed = await seed_tenant_and_users(db_session)
tenant_id = seed["tenant_a"].id
admin_id = seed["admin_a"].id
@@ -975,6 +1149,7 @@ async def test_service_get_history_empty(db_session):
def test_service_derive_rbac_from_path_companies_get():
"""Service: _derive_rbac_from_path for GET /api/v1/companies → (companies, read)."""
from app.services.ai_copilot_service import _derive_rbac_from_path
module, action = _derive_rbac_from_path("GET", "/api/v1/companies")
assert module == "companies"
assert action == "read"
@@ -983,6 +1158,7 @@ def test_service_derive_rbac_from_path_companies_get():
def test_service_derive_rbac_from_path_contacts_post():
"""Service: _derive_rbac_from_path for POST /api/v1/contacts → (contacts, create)."""
from app.services.ai_copilot_service import _derive_rbac_from_path
module, action = _derive_rbac_from_path("POST", "/api/v1/contacts")
assert module == "contacts"
assert action == "create"
@@ -991,6 +1167,7 @@ def test_service_derive_rbac_from_path_contacts_post():
def test_service_derive_rbac_from_path_workflows_patch():
"""Service: _derive_rbac_from_path for PATCH /api/v1/workflows → (workflows, update)."""
from app.services.ai_copilot_service import _derive_rbac_from_path
module, action = _derive_rbac_from_path("PATCH", "/api/v1/workflows")
assert module == "workflows"
assert action == "update"
@@ -999,6 +1176,7 @@ def test_service_derive_rbac_from_path_workflows_patch():
def test_service_derive_rbac_from_path_unknown_entity():
"""Service: _derive_rbac_from_path for unknown entity returns entity as module."""
from app.services.ai_copilot_service import _derive_rbac_from_path
module, action = _derive_rbac_from_path("DELETE", "/api/v1/foobar")
assert module == "foobar"
assert action == "delete"
@@ -1007,6 +1185,7 @@ def test_service_derive_rbac_from_path_unknown_entity():
def test_service_derive_rbac_from_path_ai_entity():
"""Service: _derive_rbac_from_path for /api/v1/ai → (ai_copilot, read)."""
from app.services.ai_copilot_service import _derive_rbac_from_path
module, action = _derive_rbac_from_path("GET", "/api/v1/ai/copilot/query")
assert module == "ai_copilot"
assert action == "read"
@@ -1015,14 +1194,17 @@ def test_service_derive_rbac_from_path_ai_entity():
def test_service_safe_iso_none():
"""Service: _safe_iso with None returns None."""
from app.services.ai_copilot_service import _safe_iso
assert _safe_iso(None) is None
def test_service_safe_iso_datetime():
"""Service: _safe_iso with datetime returns ISO string."""
from datetime import datetime
from app.services.ai_copilot_service import _safe_iso
from datetime import datetime, timezone
dt = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
dt = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
result = _safe_iso(dt)
assert result is not None
assert "2026-01-01" in result
@@ -1031,15 +1213,18 @@ def test_service_safe_iso_datetime():
def test_service_safe_iso_exception():
"""Service: _safe_iso with object that raises on isoformat returns None."""
from app.services.ai_copilot_service import _safe_iso
class Bad:
def isoformat(self):
raise ValueError("bad")
assert _safe_iso(Bad()) is None
def test_service_get_attr_missing():
"""Service: _get_attr with missing attribute returns default."""
from app.services.ai_copilot_service import _get_attr
obj = type("Obj", (), {"x": 1})()
assert _get_attr(obj, "x") == 1
assert _get_attr(obj, "y", "default") == "default"
@@ -1048,12 +1233,14 @@ def test_service_get_attr_missing():
def test_service_get_attr_none():
"""Service: _get_attr with None value returns default."""
from app.services.ai_copilot_service import _get_attr
obj = type("Obj", (), {"x": None})()
assert _get_attr(obj, "x", "fallback") == "fallback"
# ─── Route Error Path Tests for AI Copilot ───
@pytest.mark.asyncio
async def test_route_copilot_execute_not_found(client: AsyncClient, db_session):
"""Route: POST /execute with invalid conversation_id returns 404."""
@@ -1064,7 +1251,12 @@ async def test_route_copilot_execute_not_found(client: AsyncClient, db_session):
"/api/v1/ai/copilot/execute",
json={
"conversation_id": "00000000-0000-0000-0000-000000000000",
"action": {"method": "GET", "path": "/api/v1/companies", "description": "List", "confidence": 0.9},
"action": {
"method": "GET",
"path": "/api/v1/companies",
"description": "List",
"confidence": 0.9,
},
},
headers=ORIGIN_HEADER,
)
@@ -1080,7 +1272,10 @@ async def test_route_copilot_execute_rbac_blocked(client: AsyncClient, db_sessio
# First create a conversation as viewer
query_resp = await client.post(
"/api/v1/ai/copilot/query",
json={"query": "Delete company", "context": {"entity_id": "00000000-0000-0000-0000-000000000000"}},
json={
"query": "Delete company",
"context": {"entity_id": "00000000-0000-0000-0000-000000000000"},
},
headers=ORIGIN_HEADER,
)
conv_id = query_resp.json()["conversation_id"]
@@ -1108,7 +1303,12 @@ async def test_route_copilot_execute_unauthenticated(client: AsyncClient, db_ses
"/api/v1/ai/copilot/execute",
json={
"conversation_id": "00000000-0000-0000-0000-000000000000",
"action": {"method": "GET", "path": "/api/v1/companies", "description": "List", "confidence": 0.9},
"action": {
"method": "GET",
"path": "/api/v1/companies",
"description": "List",
"confidence": 0.9,
},
},
headers=ORIGIN_HEADER,
)
+12 -5
View File
@@ -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()
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff