"""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")