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>
300 lines
9.6 KiB
Python
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")
|