TorbatYar/backend/services/crm/app/tests/test_phase62.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

541 lines
17 KiB
Python

"""Phase 6.2 — Enterprise Sales Process Engine tests."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from uuid import uuid4
import pytest
from sqlalchemy import select
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.sales_engine import (
OpportunityProduct,
SalesForecast,
SalesPlaybook,
SalesStageHistory,
)
from app.models.types import (
ContactRoleType,
ForecastKind,
ForecastPeriod,
GoalMetric,
GoalScope,
PlaybookStepKind,
TargetPeriod,
WinLossOutcome,
)
from app.schemas.sales_engine import (
ForecastComputeRequest,
GoalCreate,
OpportunityCompetitorCreate,
OpportunityContactCreate,
OpportunityCreate,
OpportunityLoseRequest,
OpportunityProductCreate,
OpportunityStageChangeRequest,
OpportunityUpdate,
OpportunityWinRequest,
PipelineCreate,
PipelineStageCreate,
PlaybookAssignRequest,
PlaybookCompleteStepRequest,
PlaybookCreate,
PlaybookStepCreate,
TargetCreate,
WinLossCategoryCreate,
WinLossReasonCreate,
)
from app.services.opportunity_service import OpportunityService, PipelineService
from app.services.sales_engine_services import (
ForecastService,
GoalService,
OpportunityExtrasService,
PlaybookService,
TargetService,
WinLossService,
)
from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers
from app.validators.sales_engine import (
validate_lost_requires_reason,
validate_stage_flags,
)
from shared.exceptions import AppError
def _session_factory():
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def _seed_pipeline(session, tenant_id):
ps = PipelineService(session)
pipe = await ps.create(
tenant_id,
PipelineCreate(
name="Enterprise", code=f"ENT-{uuid4().hex[:6]}", is_default=True
),
)
start = await ps.create_stage(
tenant_id,
PipelineStageCreate(
pipeline_id=pipe.id,
name="Qualify",
code="START",
position=0,
win_probability=10,
is_start_stage=True,
color="#00AA00",
),
)
mid = await ps.create_stage(
tenant_id,
PipelineStageCreate(
pipeline_id=pipe.id,
name="Negotiate",
code="MID",
position=1,
win_probability=50,
),
)
won = await ps.create_stage(
tenant_id,
PipelineStageCreate(
pipeline_id=pipe.id,
name="Closed Won",
code="WON",
position=2,
win_probability=100,
is_won_stage=True,
is_closed_stage=True,
),
)
lost = await ps.create_stage(
tenant_id,
PipelineStageCreate(
pipeline_id=pipe.id,
name="Closed Lost",
code="LOST",
position=3,
win_probability=0,
is_lost_stage=True,
is_closed_stage=True,
),
)
return pipe, start, mid, won, lost
@pytest.mark.asyncio
async def test_pipeline_stage_rules_and_opportunity_number(db_setup):
Session = _session_factory()
async with Session() as session:
pipe, start, mid, won, lost = await _seed_pipeline(session, TENANT_A)
ps = PipelineService(session)
with pytest.raises(AppError) as exc:
await ps.create_stage(
TENANT_A,
PipelineStageCreate(
pipeline_id=pipe.id,
name="Another Start",
code="START2",
is_start_stage=True,
),
)
assert exc.value.error_code == "duplicate_start_stage"
opp = await OpportunityService(session).create(
TENANT_A,
OpportunityCreate(
name="Deal A",
pipeline_id=pipe.id,
stage_id=start.id,
amount=Decimal("1000"),
expected_close_date=date(2026, 8, 1),
),
)
assert opp.opportunity_number.startswith("OPP-")
assert opp.title == "Deal A"
assert opp.probability == 10
@pytest.mark.asyncio
async def test_stage_change_history_and_immutable_won(db_setup):
Session = _session_factory()
async with Session() as session:
pipe, start, mid, won, lost = await _seed_pipeline(session, TENANT_A)
svc = OpportunityService(session)
opp = await svc.create(
TENANT_A,
OpportunityCreate(
name="Deal B",
pipeline_id=pipe.id,
stage_id=start.id,
amount=Decimal("5000"),
),
)
moved = await svc.change_stage(
TENANT_A,
opp.id,
OpportunityStageChangeRequest(stage_id=mid.id, reason="progress"),
)
assert moved.stage_id == mid.id
assert moved.probability == 50
result = await session.execute(
select(SalesStageHistory).where(SalesStageHistory.opportunity_id == opp.id)
)
assert len(list(result.scalars().all())) >= 2
await svc.mark_won(
TENANT_A, opp.id, OpportunityWinRequest(custom_reason="best price")
)
with pytest.raises(AppError) as exc:
await svc.update(
TENANT_A, opp.id, OpportunityUpdate(name="nope")
)
assert exc.value.error_code == "opportunity_immutable"
@pytest.mark.asyncio
async def test_lost_requires_reason_and_win_loss(db_setup):
Session = _session_factory()
async with Session() as session:
pipe, start, mid, won, lost = await _seed_pipeline(session, TENANT_A)
svc = OpportunityService(session)
opp = await svc.create(
TENANT_A,
OpportunityCreate(
name="Deal C",
pipeline_id=pipe.id,
stage_id=start.id,
amount=Decimal("200"),
),
)
wl = WinLossService(session)
cat = await wl.create_category(
TENANT_A, WinLossCategoryCreate(code="PRICE", name="Price")
)
reason = await wl.create_reason(
TENANT_A,
WinLossReasonCreate(
code="TOO_HIGH",
name="Too expensive",
outcome=WinLossOutcome.LOST,
category_id=cat.id,
),
)
with pytest.raises(AppError):
validate_lost_requires_reason(None, None)
lost_opp = await svc.mark_lost(
TENANT_A,
opp.id,
OpportunityLoseRequest(reason_id=reason.id, price_factor=True),
)
assert lost_opp.status.value == "lost"
events = get_event_publisher().published
assert any(e.event_type == CrmEventType.OPPORTUNITY_LOST.value for e in events)
@pytest.mark.asyncio
async def test_playbook_forecast_goals_products(db_setup):
Session = _session_factory()
async with Session() as session:
pipe, start, mid, won, lost = await _seed_pipeline(session, TENANT_A)
opp = await OpportunityService(session).create(
TENANT_A,
OpportunityCreate(
name="Deal D",
pipeline_id=pipe.id,
stage_id=mid.id,
amount=Decimal("10000"),
expected_revenue=Decimal("10000"),
probability=80,
expected_close_date=date(2026, 9, 15),
),
)
pb = PlaybookService(session)
book = await pb.create(
TENANT_A,
PlaybookCreate(
name="Enterprise Playbook",
code="ENT-PB",
version=1,
pipeline_id=pipe.id,
),
)
step = await pb.add_step(
TENANT_A,
PlaybookStepCreate(
playbook_id=book.id,
title="Discovery",
step_kind=PlaybookStepKind.REQUIRED_ACTIVITY,
is_required=True,
checklist_items=["agenda", "stakeholders"],
),
)
assignment = await pb.assign(
TENANT_A,
PlaybookAssignRequest(playbook_id=book.id, opportunity_id=opp.id),
)
done = await pb.complete_step(
TENANT_A,
assignment.id,
PlaybookCompleteStepRequest(step_id=step.id),
)
assert done.completion_percent == 100
assert done.status.value == "completed"
assert any(
e.event_type == CrmEventType.PLAYBOOK_COMPLETED.value
for e in get_event_publisher().published
)
extras = OpportunityExtrasService(session)
product = await extras.add_product(
TENANT_A,
OpportunityProductCreate(
opportunity_id=opp.id,
product_ref_id="prod-sku-1",
quantity=Decimal("2"),
unit_price=Decimal("100"),
discount_percent=Decimal("10"),
),
)
assert product.expected_revenue == Decimal("180")
await extras.add_contact(
TENANT_A,
OpportunityContactCreate(
opportunity_id=opp.id,
contact_id=uuid4(),
role=ContactRoleType.DECISION_MAKER,
is_primary=True,
),
)
await extras.add_competitor(
TENANT_A,
OpportunityCompetitorCreate(
opportunity_id=opp.id,
competitor_name="RivalCo",
strength="price",
weakness="support",
win_probability=30,
),
)
forecast = await ForecastService(session).compute(
TENANT_A,
ForecastComputeRequest(
name="Q3 Weighted",
kind=ForecastKind.WEIGHTED,
period=ForecastPeriod.QUARTERLY,
period_start=date(2026, 7, 1),
period_end=date(2026, 9, 30),
pipeline_id=pipe.id,
),
)
assert forecast.opportunity_count >= 1
assert forecast.weighted_amount > 0
assert forecast.committed_amount > 0
goal = await GoalService(session).create(
TENANT_A,
GoalCreate(
name="Team Revenue",
scope=GoalScope.TEAM,
metric=GoalMetric.REVENUE,
team_key="sales-ent",
target_value=Decimal("100000"),
period_start=date(2026, 1, 1),
period_end=date(2026, 12, 31),
),
)
target = await TargetService(session).create(
TENANT_A,
TargetCreate(
name="Q3 Target",
period=TargetPeriod.QUARTERLY,
period_start=date(2026, 7, 1),
period_end=date(2026, 9, 30),
goal_id=goal.id,
target_value=Decimal("25000"),
actual_value=Decimal("12500"),
),
)
assert target.achievement_percent == Decimal("50.0000")
@pytest.mark.asyncio
async def test_sales_engine_api_flow(client):
pipeline = await client.post(
"/api/v1/pipelines",
json={"name": "Retail", "code": "RETAIL62", "sort_order": 1},
headers=tenant_headers(TENANT_A),
)
assert pipeline.status_code == 201, pipeline.text
pipeline_id = pipeline.json()["id"]
stage = await client.post(
"/api/v1/pipelines/stages",
json={
"pipeline_id": pipeline_id,
"name": "Open",
"code": "OPEN",
"is_start_stage": True,
"win_probability": 20,
},
headers=tenant_headers(TENANT_A),
)
assert stage.status_code == 201
stage_id = stage.json()["id"]
opp = await client.post(
"/api/v1/opportunities",
json={
"title": "Retail Deal",
"pipeline_id": pipeline_id,
"stage_id": stage_id,
"amount": "8000",
"expected_close_date": "2026-10-01",
},
headers=tenant_headers(TENANT_A),
)
assert opp.status_code == 201, opp.text
assert opp.json()["opportunity_number"]
opportunity_id = opp.json()["id"]
stage2 = await client.post(
"/api/v1/pipelines/stages",
json={
"pipeline_id": pipeline_id,
"name": "Proposal",
"code": "PROP",
"position": 1,
"win_probability": 55,
},
headers=tenant_headers(TENANT_A),
)
changed = await client.post(
f"/api/v1/opportunities/{opportunity_id}/stage",
json={"stage_id": stage2.json()["id"], "reason": "ready"},
headers=tenant_headers(TENANT_A),
)
assert changed.status_code == 200
hist = await client.get(
f"/api/v1/opportunities/{opportunity_id}/stage-history",
headers=tenant_headers(TENANT_A),
)
assert hist.status_code == 200
assert len(hist.json()) >= 2
pb = await client.post(
"/api/v1/playbooks",
json={"name": "Retail PB", "code": "RET-PB", "pipeline_id": pipeline_id},
headers=tenant_headers(TENANT_A),
)
assert pb.status_code == 201
await client.post(
"/api/v1/playbooks/steps",
json={
"playbook_id": pb.json()["id"],
"title": "Call",
"step_kind": "recommended_activity",
},
headers=tenant_headers(TENANT_A),
)
assign = await client.post(
"/api/v1/playbooks/assignments",
json={"playbook_id": pb.json()["id"], "opportunity_id": opportunity_id},
headers=tenant_headers(TENANT_A),
)
assert assign.status_code == 201
prod = await client.post(
"/api/v1/opportunity-products",
json={
"opportunity_id": opportunity_id,
"product_ref_id": "SKU-9",
"quantity": "1",
"unit_price": "8000",
},
headers=tenant_headers(TENANT_A),
)
assert prod.status_code == 201
fc = await client.post(
"/api/v1/forecasts/compute",
json={
"name": "Oct",
"period": "monthly",
"period_start": "2026-10-01",
"period_end": "2026-10-31",
"pipeline_id": pipeline_id,
},
headers=tenant_headers(TENANT_A),
)
assert fc.status_code == 201, fc.text
goal = await client.post(
"/api/v1/goals",
json={
"name": "Indiv",
"scope": "individual",
"metric": "opportunity_count",
"owner_user_id": "u1",
"target_value": "10",
"period_start": "2026-01-01",
"period_end": "2026-12-31",
},
headers=tenant_headers(TENANT_A),
)
assert goal.status_code == 201
target = await client.post(
"/api/v1/targets",
json={
"name": "Annual",
"period": "annual",
"period_start": "2026-01-01",
"period_end": "2026-12-31",
"target_value": "100",
"actual_value": "40",
},
headers=tenant_headers(TENANT_A),
)
assert target.status_code == 201
assert float(target.json()["achievement_percent"]) == 40.0
lost = await client.post(
f"/api/v1/opportunities/{opportunity_id}/lose",
json={"custom_reason": "budget cut", "timing_factor": True},
headers=tenant_headers(TENANT_A),
)
assert lost.status_code == 200
assert lost.json()["status"] == "lost"
wl = await client.get(
f"/api/v1/win-loss/opportunities/{opportunity_id}",
headers=tenant_headers(TENANT_A),
)
assert wl.status_code == 200
assert wl.json()["is_locked"] is True
other = await client.get(
"/api/v1/playbooks",
headers=tenant_headers(TENANT_B),
)
assert other.status_code == 200
assert other.json() == []
def test_stage_flag_validator():
with pytest.raises(AppError):
validate_stage_flags(
is_start_stage=True, is_won_stage=True, is_lost_stage=False
)
def test_sales_engine_models_have_tenant():
assert hasattr(SalesPlaybook, "tenant_id")
assert hasattr(OpportunityProduct, "tenant_id")
assert hasattr(SalesForecast, "tenant_id")
assert hasattr(SalesStageHistory, "tenant_id")