Add the Sales CRM service through collaboration, sync architecture/registry docs, expose crm.torbatyar.ir, and include a production deploy script. Co-authored-by: Cursor <cursoragent@cursor.com>
205 lines
7.4 KiB
Python
205 lines
7.4 KiB
Python
"""Architecture tests — CRM module boundary enforcement."""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
from app.models.foundation import (
|
|
Contact,
|
|
Lead,
|
|
Opportunity,
|
|
Organization,
|
|
Pipeline,
|
|
PipelineStage,
|
|
Quote,
|
|
SalesActivity,
|
|
)
|
|
|
|
|
|
FORBIDDEN_IMPORT_PREFIXES = (
|
|
"app.services.accounting",
|
|
"app.models.accounting",
|
|
"backend.services.accounting",
|
|
"backend.services.automation",
|
|
"backend.services.notification",
|
|
"backend.services.file_storage",
|
|
"backend.services.identity_access",
|
|
"backend.core_service",
|
|
)
|
|
|
|
|
|
def test_all_models_have_tenant_id():
|
|
from app.core.database import Base
|
|
import app.models # noqa: F401
|
|
|
|
skip = {"alembic_version"}
|
|
for table in Base.metadata.tables.values():
|
|
if table.name in skip:
|
|
continue
|
|
assert "tenant_id" in table.columns, f"{table.name} missing tenant_id"
|
|
|
|
|
|
def test_foundation_aggregates_are_independent():
|
|
aggregates = {
|
|
Lead,
|
|
Contact,
|
|
Organization,
|
|
Opportunity,
|
|
Pipeline,
|
|
PipelineStage,
|
|
SalesActivity,
|
|
Quote,
|
|
}
|
|
assert len(aggregates) == 8
|
|
for model in aggregates:
|
|
assert hasattr(model, "tenant_id")
|
|
assert hasattr(model, "id")
|
|
# No SQLAlchemy relationship graphs coupling aggregates
|
|
assert not model.__mapper__.relationships
|
|
|
|
|
|
def test_permissions_defined():
|
|
from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES
|
|
|
|
assert "crm.view" in ALL_PERMISSIONS
|
|
assert "crm.leads.create" in ALL_PERMISSIONS
|
|
assert "crm.contacts.view" in ALL_PERMISSIONS
|
|
assert "crm.organizations.manage" in ALL_PERMISSIONS
|
|
assert "crm.opportunities.win" in ALL_PERMISSIONS
|
|
assert "crm.pipelines.manage" in ALL_PERMISSIONS
|
|
assert "crm.playbooks.assign" in ALL_PERMISSIONS
|
|
assert "crm.forecasts.view" in ALL_PERMISSIONS
|
|
assert "crm.goals.create" in ALL_PERMISSIONS
|
|
assert "crm.targets.manage" in ALL_PERMISSIONS
|
|
assert "crm.tasks.complete" in ALL_PERMISSIONS
|
|
assert "crm.meetings.create" in ALL_PERMISSIONS
|
|
assert "crm.calls.manage" in ALL_PERMISSIONS
|
|
assert "crm.timeline.view" in ALL_PERMISSIONS
|
|
assert "crm.comments.create" in ALL_PERMISSIONS
|
|
assert "crm.mentions.view" in ALL_PERMISSIONS
|
|
assert "crm.team.manage" in ALL_PERMISSIONS
|
|
assert "crm.activities.complete" in ALL_PERMISSIONS
|
|
assert "crm.quotes.create" in ALL_PERMISSIONS
|
|
assert "crm.notes.create" in ALL_PERMISSIONS
|
|
assert "crm.attachments.create" in ALL_PERMISSIONS
|
|
assert "crm.customfields.manage" in ALL_PERMISSIONS
|
|
for prefix in PERMISSION_PREFIXES:
|
|
assert any(
|
|
p.startswith(prefix.rstrip(".")) or p.startswith(prefix)
|
|
for p in ALL_PERMISSIONS
|
|
)
|
|
|
|
|
|
def test_events_defined():
|
|
from app.events.types import CrmEventType
|
|
|
|
assert CrmEventType.LEAD_CREATED.value == "crm.lead.created"
|
|
assert CrmEventType.LEAD_UPDATED.value == "crm.lead.updated"
|
|
assert CrmEventType.CONTACT_CREATED.value == "crm.contact.created"
|
|
assert CrmEventType.ORGANIZATION_CREATED.value == "crm.organization.created"
|
|
assert CrmEventType.OPPORTUNITY_CREATED.value == "crm.opportunity.created"
|
|
assert CrmEventType.OPPORTUNITY_UPDATED.value == "crm.opportunity.updated"
|
|
assert CrmEventType.OPPORTUNITY_WON.value == "crm.opportunity.won"
|
|
assert CrmEventType.OPPORTUNITY_LOST.value == "crm.opportunity.lost"
|
|
assert CrmEventType.PIPELINE_CHANGED.value == "crm.pipeline.changed"
|
|
assert CrmEventType.STAGE_CHANGED.value == "crm.stage.changed"
|
|
assert CrmEventType.PLAYBOOK_ASSIGNED.value == "crm.playbook.assigned"
|
|
assert CrmEventType.PLAYBOOK_COMPLETED.value == "crm.playbook.completed"
|
|
assert CrmEventType.FORECAST_UPDATED.value == "crm.forecast.updated"
|
|
assert CrmEventType.ACTIVITY_COMPLETED.value == "crm.activity.completed"
|
|
assert CrmEventType.TASK_COMPLETED.value == "crm.task.completed"
|
|
assert CrmEventType.MEETING_FINISHED.value == "crm.meeting.finished"
|
|
assert CrmEventType.CALL_LOGGED.value == "crm.call.logged"
|
|
assert CrmEventType.TIMELINE_UPDATED.value == "crm.timeline.updated"
|
|
assert CrmEventType.COMMENT_CREATED.value == "crm.comment.created"
|
|
assert CrmEventType.MENTION_CREATED.value == "crm.mention.created"
|
|
assert CrmEventType.QUOTE_CREATED.value == "crm.quote.created"
|
|
assert CrmEventType.NOTE_CREATED.value == "crm.note.created"
|
|
assert CrmEventType.ATTACHMENT_ADDED.value == "crm.attachment.added"
|
|
|
|
|
|
def test_platform_provider_contracts_exist():
|
|
from app.providers import (
|
|
AIProvider,
|
|
AnalyticsProvider,
|
|
AutomationProvider,
|
|
CommunicationProvider,
|
|
Customer360Provider,
|
|
FileStorageProvider,
|
|
HelpdeskProvider,
|
|
NotificationProvider,
|
|
ProductServiceProvider,
|
|
)
|
|
|
|
assert AutomationProvider is not None
|
|
assert Customer360Provider is not None
|
|
assert NotificationProvider is not None
|
|
assert AnalyticsProvider is not None
|
|
assert AIProvider is not None
|
|
assert CommunicationProvider is not None
|
|
assert HelpdeskProvider is not None
|
|
assert FileStorageProvider is not None
|
|
assert ProductServiceProvider is not None
|
|
|
|
|
|
def test_no_forbidden_service_imports():
|
|
root = Path(__file__).resolve().parents[1]
|
|
violations: list[str] = []
|
|
for path in root.rglob("*.py"):
|
|
if "tests" in path.parts:
|
|
continue
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
for forbidden in FORBIDDEN_IMPORT_PREFIXES:
|
|
if alias.name.startswith(forbidden) or forbidden in alias.name:
|
|
violations.append(f"{path}: import {alias.name}")
|
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
for forbidden in FORBIDDEN_IMPORT_PREFIXES:
|
|
if node.module.startswith(forbidden) or forbidden in node.module:
|
|
violations.append(f"{path}: from {node.module}")
|
|
assert violations == []
|
|
|
|
|
|
def test_api_ownership_is_crm_only():
|
|
from app.api.v1 import api_router
|
|
|
|
prefixes = {getattr(r, "path", "") for r in api_router.routes}
|
|
joined = " ".join(sorted(prefixes))
|
|
assert "/leads" in joined or any("/leads" in p for p in prefixes)
|
|
# Forbid platform-owned path segments (word boundaries), not substrings
|
|
# (e.g. "ai" must not match "/emails").
|
|
import re
|
|
|
|
forbidden = (
|
|
"automation",
|
|
"customer360",
|
|
"notification",
|
|
"helpdesk",
|
|
"analytics",
|
|
"ai",
|
|
)
|
|
for name in forbidden:
|
|
assert not re.search(rf"(^|/){re.escape(name)}(/|$|\s)", joined), name
|
|
|
|
|
|
def test_folder_structure():
|
|
root = Path(__file__).resolve().parents[2]
|
|
required = [
|
|
"app/models",
|
|
"app/repositories",
|
|
"app/services",
|
|
"app/validators",
|
|
"app/schemas",
|
|
"app/events",
|
|
"app/permissions",
|
|
"app/providers",
|
|
"app/api/v1",
|
|
"app/tests",
|
|
"alembic/versions",
|
|
"README.md",
|
|
]
|
|
for rel in required:
|
|
assert (root / rel).exists(), f"missing {rel}"
|