TorbatYar/backend/services/crm/app/tests/test_phase63.py
Mortezakoohjani 064d67f099 Ship CRM Core Platform (phases 6.0–6.3) with docs and prod deploy.
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>
2026-07-24 20:32:00 +03:30

300 lines
9.6 KiB
Python

"""Phase 6.3 — Enterprise Sales Collaboration Platform tests."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.database import engine
from app.events.publisher import get_event_publisher
from app.events.types import CrmEventType
from app.models.collaboration import Comment, SalesTimelineEvent
from app.models.types import (
CallDirection,
CollaborationEntityType,
TimelineEventType,
)
from app.schemas.collaboration import (
BookmarkCreate,
CallLogCreate,
ChecklistItemCreate,
CommentCreate,
MeetingCreate,
MeetingFinishRequest,
SalesActivityCreate,
TaskCreate,
TeamMemberCreate,
TeamRoleCreate,
)
from app.services.collaboration_services import (
BookmarkService,
CallService,
CommentService,
MeetingService,
SalesActivityService,
SalesTeamService,
TaskService,
TimelineService,
)
from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers
from app.validators.collaboration import (
ensure_call_has_opportunity_link,
ensure_meeting_window,
validate_comment_body,
)
from shared.exceptions import AppError
from shared.security import CurrentUser
def _session_factory():
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@pytest.mark.asyncio
async def test_activity_timeline_and_priority(db_setup):
Session = _session_factory()
async with Session() as session:
act = await SalesActivityService(session).create(
TENANT_A,
SalesActivityCreate(
subject="Follow up demo",
activity_type="demo",
priority="high",
opportunity_id=uuid4(),
),
)
assert act.priority.value == "high"
assert act.activity_type.value == "demo"
events = get_event_publisher().published
assert any(e.event_type == CrmEventType.ACTIVITY_CREATED.value for e in events)
assert any(e.event_type == CrmEventType.TIMELINE_UPDATED.value for e in events)
timeline = await TimelineService(session).list(TENANT_A, offset=0, limit=20)
assert any(t.event_type == TimelineEventType.ACTIVITY_CREATED for t in timeline)
@pytest.mark.asyncio
async def test_task_checklist_and_complete(db_setup):
Session = _session_factory()
async with Session() as session:
svc = TaskService(session)
task = await svc.create(
TENANT_A,
TaskCreate(title="Prepare proposal", assignee_user_id="u1"),
)
item = await svc.add_checklist_item(
TENANT_A,
ChecklistItemCreate(task_id=task.id, title="Draft slides"),
)
await svc.complete_checklist_item(TENANT_A, item.id)
done = await svc.complete(TENANT_A, task.id)
assert done.status == "completed"
assert done.completion_percent == 100
assert any(
e.event_type == CrmEventType.TASK_COMPLETED.value
for e in get_event_publisher().published
)
@pytest.mark.asyncio
async def test_meeting_call_comment_bookmark(db_setup):
Session = _session_factory()
async with Session() as session:
start = datetime.now(timezone.utc)
meeting = await MeetingService(session).create(
TENANT_A,
MeetingCreate(
subject="Discovery",
start_at=start,
end_at=start + timedelta(hours=1),
opportunity_id=uuid4(),
location="HQ",
),
)
finished = await MeetingService(session).finish(
TENANT_A,
meeting.id,
MeetingFinishRequest(outcome="qualified"),
)
assert finished.is_finished is True
call = await CallService(session).log(
TENANT_A,
CallLogCreate(
direction=CallDirection.OUTBOUND,
opportunity_id=uuid4(),
duration_seconds=120,
notes="Left voicemail",
),
)
assert call.duration_seconds == 120
actor = CurrentUser(user_id="u1", username="u1", roles=["tenant_admin"])
comment = await CommentService(session).create(
TENANT_A,
CommentCreate(
entity_type=CollaborationEntityType.OPPORTUNITY,
entity_id=uuid4(),
body="Need pricing review",
mention_user_ids=["u2"],
is_pinned=True,
),
actor=actor,
)
assert comment.version == 1
assert comment.is_pinned is True
bookmark = await BookmarkService(session).add_bookmark(
TENANT_A,
"u1",
BookmarkCreate(
entity_type=CollaborationEntityType.LEAD,
entity_id=uuid4(),
label="Hot lead",
),
)
assert bookmark.user_id == "u1"
team = SalesTeamService(session)
role = await team.create_role(
TENANT_A,
TeamRoleCreate(code="mgr", name="Manager", role_kind="manager"),
)
member = await team.add_member(
TENANT_A,
TeamMemberCreate(
user_id="u1",
team_key="enterprise",
role_id=role.id,
role_code="manager",
),
)
assert member.team_key == "enterprise"
@pytest.mark.asyncio
async def test_timeline_immutable(db_setup):
Session = _session_factory()
async with Session() as session:
event = await TimelineService(session).append(
tenant_id=TENANT_A,
event_type=TimelineEventType.NOTE_ADDED,
entity_type=CollaborationEntityType.LEAD,
entity_id=uuid4(),
title="Note",
commit=True,
)
with pytest.raises(AppError) as exc:
await TimelineService(session).reject_update(TENANT_A, event.id)
assert exc.value.error_code == "timeline_immutable"
@pytest.mark.asyncio
async def test_collaboration_api_flow(client):
opp_id = str(uuid4())
activity = await client.post(
"/api/v1/activities",
json={
"subject": "Negotiation call",
"activity_type": "negotiation",
"priority": "urgent",
"opportunity_id": opp_id,
},
headers=tenant_headers(TENANT_A),
)
assert activity.status_code == 201, activity.text
task = await client.post(
"/api/v1/tasks",
json={"title": "Send quote", "opportunity_id": opp_id},
headers=tenant_headers(TENANT_A),
)
assert task.status_code == 201, task.text
completed = await client.post(
f"/api/v1/tasks/{task.json()['id']}/complete",
headers=tenant_headers(TENANT_A),
)
assert completed.status_code == 200
start = datetime.now(timezone.utc)
meeting = await client.post(
"/api/v1/meetings",
json={
"subject": "Kickoff",
"start_at": start.isoformat(),
"end_at": (start + timedelta(minutes=30)).isoformat(),
"opportunity_id": opp_id,
"online_meeting_url": "https://meet.example/abc",
},
headers=tenant_headers(TENANT_A),
)
assert meeting.status_code == 201, meeting.text
finished = await client.post(
f"/api/v1/meetings/{meeting.json()['id']}/finish",
json={"outcome": "done"},
headers=tenant_headers(TENANT_A),
)
assert finished.status_code == 200
call = await client.post(
"/api/v1/calls",
json={
"direction": "inbound",
"opportunity_id": opp_id,
"duration_seconds": 45,
"recording_ref": "rec-1",
},
headers=tenant_headers(TENANT_A),
)
assert call.status_code == 201, call.text
comment = await client.post(
"/api/v1/comments",
json={
"entity_type": "opportunity",
"entity_id": opp_id,
"body": "Looks good @manager",
"mention_user_ids": ["manager-1"],
},
headers=tenant_headers(TENANT_A),
)
assert comment.status_code == 201, comment.text
bookmark = await client.post(
"/api/v1/bookmarks",
json={"entity_type": "opportunity", "entity_id": opp_id},
headers=tenant_headers(TENANT_A),
)
assert bookmark.status_code == 201
timeline = await client.get(
f"/api/v1/timeline/opportunities/{opp_id}",
headers=tenant_headers(TENANT_A),
)
assert timeline.status_code == 200
assert len(timeline.json()) >= 1
other = await client.get("/api/v1/tasks", headers=tenant_headers(TENANT_B))
assert other.status_code == 200
assert other.json() == []
def test_collaboration_validators():
with pytest.raises(AppError):
ensure_meeting_window(
datetime(2026, 1, 1, tzinfo=timezone.utc),
datetime(2026, 1, 1, tzinfo=timezone.utc),
)
with pytest.raises(AppError):
ensure_call_has_opportunity_link(None)
assert validate_comment_body(" hi ") == "hi"
with pytest.raises(AppError):
validate_comment_body(" ")
def test_collaboration_models_tenant_ready():
assert hasattr(Comment, "tenant_id")
assert hasattr(SalesTimelineEvent, "tenant_id")
assert hasattr(SalesTimelineEvent, "is_immutable")