Files
call-center/tests/test_deal_state_machine.py

371 lines
13 KiB
Python

import json
from fastapi.testclient import TestClient
from sqlalchemy import select
import services.sales_service.app as sales_module
from services.shared.db import get_session
from services.shared.sql_models import EventOutboxRow
from services.shared.sales_sql_models import SalesStageHistoryRow
def _headers(tenant_id: str = "tenant_state", role: str = "admin") -> dict[str, str]:
return {"X-User": "admin", "X-Role": role, "X-Tenant-ID": tenant_id}
def _deal_payload(seed: str = "state", **overrides: object) -> dict:
payload = {
"stage_id": "new_qualified_lead",
"scenario_type": "quick_sale",
"priority": 3,
"title": f"State machine deal {seed}",
"need_summary": "Need a managed sales flow.",
"document_required": False,
"preferred_channel": "telegram",
"current_channel": "telegram",
}
payload.update(overrides)
return payload
def _create_deal(client: TestClient, tenant_id: str = "tenant_state", **overrides: object) -> dict:
response = client.post("/api/v1/deals", json=_deal_payload(tenant_id, **overrides), headers=_headers(tenant_id))
assert response.status_code == 200
return response.json()
def _change_stage(
client: TestClient,
deal_id: str,
target_stage_code: str,
tenant_id: str = "tenant_state",
*,
reason: str | None = None,
metadata: dict | None = None,
force: bool = False,
role: str = "admin",
) -> dict:
payload = {"target_stage_code": target_stage_code, "metadata": metadata or {}, "force": force}
if reason is not None:
payload["reason"] = reason
response = client.post(f"/api/v1/deals/{deal_id}/change-stage", json=payload, headers=_headers(tenant_id, role))
assert response.status_code == 200
return response.json()
def _prepare_need_confirmed(client: TestClient, deal_id: str, tenant_id: str = "tenant_state") -> None:
_change_stage(client, deal_id, "active_text_communication", tenant_id, reason="text started")
_change_stage(client, deal_id, "need_confirmed", tenant_id, reason="need confirmed")
def _prepare_invoice(client: TestClient, deal_id: str, tenant_id: str = "tenant_state") -> dict:
_prepare_need_confirmed(client, deal_id, tenant_id)
created = client.post(
f"/api/v1/deals/{deal_id}/invoices",
json={"amount": 1000, "currency": "KZT", "due_date": "2026-05-15"},
headers=_headers(tenant_id),
)
assert created.status_code == 200
sent = client.post(f"/api/v1/invoices/{created.json()['invoice_id']}/send", headers=_headers(tenant_id))
assert sent.status_code == 200
return sent.json()
def _stage_id(client: TestClient, tenant_id: str, code: str) -> str:
pipelines = client.get("/api/v1/pipelines", headers=_headers(tenant_id))
assert pipelines.status_code == 200
default_pipeline = next(item for item in pipelines.json() if item["is_default"])
stages = client.get(f"/api/v1/pipelines/{default_pipeline['pipeline_id']}/stages", headers=_headers(tenant_id))
assert stages.status_code == 200
return next(item["stage_id"] for item in stages.json() if item["code"] == code)
def _event_payloads(event_type: str, tenant_id: str) -> list[dict]:
session = get_session()
try:
rows = session.execute(
select(EventOutboxRow)
.where(EventOutboxRow.producer_service == "sales-service", EventOutboxRow.event_type == event_type)
.order_by(EventOutboxRow.id.asc())
).scalars().all()
payloads = []
for row in rows:
envelope = json.loads(row.payload_json or "{}")
payload = envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {}
if payload.get("tenant_id") == tenant_id:
payloads.append(payload)
return payloads
finally:
session.close()
def test_valid_stage_transition_succeeds():
client = TestClient(sales_module.app)
deal = _create_deal(client)
changed = _change_stage(client, deal["deal_id"], "warm_lead", reason="lead warmed")
assert changed["stage"]["code"] == "warm_lead"
def test_invalid_stage_transition_fails():
client = TestClient(sales_module.app)
deal = _create_deal(client)
response = client.post(
f"/api/v1/deals/{deal['deal_id']}/change-stage",
json={"target_stage_code": "offer_sent", "reason": "skip pipeline"},
headers=_headers(),
)
assert response.status_code == 400
assert response.json()["error"] == "invalid_stage_transition"
def test_change_stage_missing_deal_returns_state_machine_error():
client = TestClient(sales_module.app)
response = client.post(
"/api/v1/deals/sde_missing/change-stage",
json={"target_stage_code": "warm_lead", "reason": "missing deal"},
headers=_headers(),
)
assert response.status_code == 404
assert response.json()["error"] == "deal_not_found"
def test_cannot_jump_from_new_lead_to_paid():
client = TestClient(sales_module.app)
deal = _create_deal(client)
response = client.post(
f"/api/v1/deals/{deal['deal_id']}/change-stage",
json={"target_stage_code": "paid", "reason": "jump"},
headers=_headers(),
)
assert response.status_code == 400
assert response.json()["error"] == "invalid_stage_transition"
def test_cannot_set_stage_from_another_tenant():
client = TestClient(sales_module.app)
foreign_stage_id = _stage_id(client, "tenant_state_foreign", "warm_lead")
deal = _create_deal(client, "tenant_state_owner")
response = client.post(
f"/api/v1/deals/{deal['deal_id']}/change-stage",
json={"target_stage_id": foreign_stage_id, "reason": "foreign stage"},
headers=_headers("tenant_state_owner"),
)
assert response.status_code == 403
assert response.json()["error"] == "cross_tenant_stage_forbidden"
def test_cannot_set_stage_from_another_pipeline():
client = TestClient(sales_module.app)
tenant_id = "tenant_state_pipeline"
deal = _create_deal(client, tenant_id)
pipeline = client.post(
"/api/v1/pipelines",
json={"code": "enterprise_sales", "name": "Enterprise Sales"},
headers=_headers(tenant_id),
)
assert pipeline.status_code == 200
stage = client.post(
f"/api/v1/pipelines/{pipeline.json()['pipeline_id']}/stages",
json={"code": "enterprise_warm", "name": "Enterprise Warm", "category": "entry", "sort_order": 10},
headers=_headers(tenant_id),
)
assert stage.status_code == 200
response = client.post(
f"/api/v1/deals/{deal['deal_id']}/change-stage",
json={"target_stage_id": stage.json()["stage_id"], "reason": "wrong pipeline"},
headers=_headers(tenant_id),
)
assert response.status_code == 400
assert response.json()["error"] == "invalid_stage_pipeline"
def test_offer_sent_requires_offer():
client = TestClient(sales_module.app)
deal = _create_deal(client)
_prepare_need_confirmed(client, deal["deal_id"])
_change_stage(client, deal["deal_id"], "offer_preparing", reason="prepare offer")
response = client.post(
f"/api/v1/deals/{deal['deal_id']}/change-stage",
json={"target_stage_code": "offer_sent", "reason": "send missing offer"},
headers=_headers(),
)
assert response.status_code == 400
assert response.json()["error"] == "offer_required_before_offer_sent"
def test_invoice_sent_requires_invoice():
client = TestClient(sales_module.app)
deal = _create_deal(client)
_prepare_need_confirmed(client, deal["deal_id"])
_change_stage(client, deal["deal_id"], "invoice_preparing", reason="prepare invoice")
response = client.post(
f"/api/v1/deals/{deal['deal_id']}/change-stage",
json={"target_stage_code": "invoice_sent", "reason": "send missing invoice"},
headers=_headers(),
)
assert response.status_code == 400
assert response.json()["error"] == "invoice_required_before_invoice_sent"
def test_won_requires_payment_when_payment_required():
client = TestClient(sales_module.app)
deal = _create_deal(client, payment_required=True)
escalated = client.post(
f"/api/v1/deals/{deal['deal_id']}/escalate",
json={"escalation_type": "manual_review", "reason": "Need human review", "severity": "medium"},
headers=_headers(),
)
assert escalated.status_code == 200
response = client.post(
f"/api/v1/deals/{deal['deal_id']}/change-stage",
json={"target_stage_code": "won", "reason": "manual win"},
headers=_headers(),
)
assert response.status_code == 400
assert response.json()["error"] == "payment_required_before_won"
def test_lost_requires_reason():
client = TestClient(sales_module.app)
deal = _create_deal(client)
response = client.post(
f"/api/v1/deals/{deal['deal_id']}/change-stage",
json={"target_stage_code": "lost"},
headers=_headers(),
)
assert response.status_code == 400
assert response.json()["error"] == "lost_reason_required"
def test_successful_transition_writes_stage_history():
client = TestClient(sales_module.app)
deal = _create_deal(client, "tenant_state_history")
_change_stage(client, deal["deal_id"], "warm_lead", "tenant_state_history", reason="lead warmed")
session = get_session()
try:
rows = session.execute(
select(SalesStageHistoryRow).where(
SalesStageHistoryRow.tenant_id == "tenant_state_history",
SalesStageHistoryRow.deal_id == deal["deal_id"],
)
).scalars().all()
assert rows
assert rows[-1].reason == "lead warmed"
finally:
session.close()
def test_successful_transition_publishes_deal_stage_changed():
client = TestClient(sales_module.app)
deal = _create_deal(client, "tenant_state_event")
_change_stage(client, deal["deal_id"], "warm_lead", "tenant_state_event", reason="lead warmed")
payload = _event_payloads("deal.stage_changed", "tenant_state_event")[-1]
assert payload["deal_id"] == deal["deal_id"]
assert payload["from_stage_code"] == "new_qualified_lead"
assert payload["to_stage_code"] == "warm_lead"
def test_won_transition_sets_status_and_closed_at():
client = TestClient(sales_module.app)
tenant_id = "tenant_state_won"
deal = _create_deal(client, tenant_id)
invoice = _prepare_invoice(client, deal["deal_id"], tenant_id)
response = client.post(
"/api/v1/payments/webhook",
json={
"deal_id": deal["deal_id"],
"invoice_id": invoice["invoice_id"],
"payment_provider": "manual",
"external_payment_id": "state-paid-1",
"amount": 1000,
"currency": "KZT",
"status": "success",
},
headers=_headers(tenant_id),
)
assert response.status_code == 200
refreshed = client.get(f"/api/v1/deals/{deal['deal_id']}", headers=_headers(tenant_id))
assert refreshed.status_code == 200
assert refreshed.json()["status"] == "won"
assert refreshed.json()["closed_at"]
assert refreshed.json()["stage"]["code"] == "won"
def test_lost_transition_sets_status_and_closed_at():
client = TestClient(sales_module.app)
deal = _create_deal(client, "tenant_state_lost")
changed = _change_stage(client, deal["deal_id"], "lost", "tenant_state_lost", reason="not interested")
assert changed["status"] == "lost"
assert changed["closed_at"]
assert changed["lost_reason"] == "not interested"
def test_payment_webhook_transitions_deal_to_paid_and_won():
client = TestClient(sales_module.app)
tenant_id = "tenant_state_payment"
deal = _create_deal(client, tenant_id)
invoice = _prepare_invoice(client, deal["deal_id"], tenant_id)
response = client.post(
"/api/v1/payments/webhook",
json={
"deal_id": deal["deal_id"],
"invoice_id": invoice["invoice_id"],
"payment_provider": "manual",
"external_payment_id": "state-paid-2",
"amount": 1000,
"currency": "KZT",
"status": "success",
},
headers=_headers(tenant_id),
)
assert response.status_code == 200
workspace = client.get(f"/api/v1/deals/{deal['deal_id']}/workspace", headers=_headers(tenant_id))
assert workspace.status_code == 200
stage_codes = [item["to_stage"]["code"] for item in workspace.json()["stage_history"]]
assert "paid" in stage_codes
assert "won" in stage_codes
def test_force_transition_requires_system_or_admin_actor():
client = TestClient(sales_module.app)
deal = _create_deal(client, "tenant_state_force")
response = client.post(
f"/api/v1/deals/{deal['deal_id']}/change-stage",
json={"target_stage_code": "warm_lead", "reason": "operator force", "force": True},
headers=_headers("tenant_state_force", role="operator"),
)
assert response.status_code == 403
assert response.json()["error"] == "force_transition_forbidden"