340 lines
13 KiB
Python
340 lines
13 KiB
Python
from fastapi.testclient import TestClient
|
|
import pytest
|
|
|
|
import services.sales_service.app as sales_module
|
|
|
|
|
|
def _headers(tenant_id: str = "tenant_test") -> dict[str, str]:
|
|
return {"X-User": "admin", "X-Role": "admin", "X-Tenant-ID": tenant_id}
|
|
|
|
|
|
def _create_lead(client: TestClient) -> tuple[dict, str]:
|
|
created = client.post(
|
|
"/api/v1/leads",
|
|
json={
|
|
"source_type": "crm",
|
|
"source_channel": "telegram",
|
|
"full_name": "Test Buyer",
|
|
"company_name": "Sales QA",
|
|
"phone": "+77001234567",
|
|
"email": "buyer@test.local",
|
|
"lead_temperature": "hot",
|
|
"lead_score": 82,
|
|
"initial_need_summary": "Need a commercial offer and a callback.",
|
|
"preferred_channel": "telegram",
|
|
"assigned_agent_type": "text_ai",
|
|
"status": "new_qualified_lead",
|
|
"priority": 3,
|
|
"title": "Lead: Test Buyer",
|
|
},
|
|
headers=_headers(),
|
|
)
|
|
assert created.status_code == 200
|
|
lead = created.json()
|
|
deals = client.get("/api/v1/deals", headers=_headers())
|
|
assert deals.status_code == 200
|
|
deal_id = next(item["deal_id"] for item in deals.json() if item["lead_id"] == lead["lead_id"])
|
|
return lead, deal_id
|
|
|
|
|
|
class _DummySyncResponse:
|
|
def __init__(self, payload: dict[str, str]):
|
|
self._payload = payload
|
|
self.status_code = 200
|
|
self.text = ""
|
|
|
|
def raise_for_status(self) -> None:
|
|
return None
|
|
|
|
def json(self) -> dict[str, str]:
|
|
return self._payload
|
|
|
|
|
|
class _DummyHttpxClient:
|
|
requests: list[dict] = []
|
|
response_payload: dict[str, str] = {}
|
|
|
|
def __init__(self, timeout: float):
|
|
self.timeout = timeout
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return None
|
|
|
|
def post(self, url: str, json: dict | None = None, headers: dict | None = None):
|
|
self.__class__.requests.append(
|
|
{
|
|
"url": url,
|
|
"json": json or {},
|
|
"headers": headers or {},
|
|
"timeout": self.timeout,
|
|
}
|
|
)
|
|
return _DummySyncResponse(self.__class__.response_payload)
|
|
|
|
|
|
def test_sales_lead_creation_auto_creates_deal_and_workspace():
|
|
client = TestClient(sales_module.app)
|
|
|
|
lead, deal_id = _create_lead(client)
|
|
workspace = client.get(f"/api/v1/deals/{deal_id}/workspace", headers=_headers())
|
|
|
|
assert workspace.status_code == 200
|
|
payload = workspace.json()
|
|
assert payload["lead"]["lead_id"] == lead["lead_id"]
|
|
assert payload["deal"]["deal_id"] == deal_id
|
|
assert payload["deal"]["preferred_channel"] == "telegram"
|
|
assert payload["timeline"]
|
|
|
|
|
|
def test_sales_bind_telegram_and_send_outbound_message(monkeypatch):
|
|
client = TestClient(sales_module.app)
|
|
_, deal_id = _create_lead(client)
|
|
_DummyHttpxClient.requests = []
|
|
_DummyHttpxClient.response_payload = {"ok": True}
|
|
monkeypatch.setattr(sales_module.httpx, "Client", _DummyHttpxClient)
|
|
monkeypatch.setenv("SALES_TELEGRAM_BRIDGE_ENABLED", "1")
|
|
|
|
created_communication = client.post(
|
|
f"/api/v1/deals/{deal_id}/communications/text",
|
|
json={
|
|
"channel_type": "text",
|
|
"direction": "outbound",
|
|
"agent_type": "text_ai",
|
|
"subject": "Telegram follow-up",
|
|
},
|
|
headers=_headers(),
|
|
)
|
|
assert created_communication.status_code == 200
|
|
communication = created_communication.json()
|
|
|
|
bound = client.post(
|
|
f"/api/v1/communications/{communication['communication_id']}/bind-external",
|
|
json={
|
|
"channel_provider": "telegram",
|
|
"telegram_thread_id": "tg-thread-42",
|
|
"telegram_chat_id": "tg-chat-42",
|
|
},
|
|
headers=_headers(),
|
|
)
|
|
assert bound.status_code == 200
|
|
assert bound.json()["metadata"]["telegram_thread_id"] == "tg-thread-42"
|
|
|
|
outbound = client.post(
|
|
"/api/v1/messages/outbound",
|
|
json={
|
|
"deal_id": deal_id,
|
|
"communication_id": communication["communication_id"],
|
|
"channel_provider": "telegram",
|
|
"sender_type": "human",
|
|
"sender_id": "admin",
|
|
"body": "Please check the offer in Telegram.",
|
|
"metadata": {"telegram_thread_id": "tg-thread-42"},
|
|
},
|
|
headers=_headers(),
|
|
)
|
|
assert outbound.status_code == 200
|
|
assert outbound.json()["delivery_status"] == "sent"
|
|
assert _DummyHttpxClient.requests
|
|
assert _DummyHttpxClient.requests[0]["url"].endswith("/integrations/telegram/threads/tg-thread-42/messages")
|
|
assert _DummyHttpxClient.requests[0]["json"]["text"] == "Please check the offer in Telegram."
|
|
|
|
switched = client.post(
|
|
f"/api/v1/communications/{communication['communication_id']}/switch-channel",
|
|
json={
|
|
"to_channel": "voice",
|
|
"reason_for_channel_switch": "Need to clarify details by phone",
|
|
},
|
|
headers=_headers(),
|
|
)
|
|
assert switched.status_code == 200
|
|
|
|
workspace = client.get(f"/api/v1/deals/{deal_id}/workspace", headers=_headers())
|
|
assert workspace.status_code == 200
|
|
payload = workspace.json()
|
|
assert payload["deal"]["current_channel"] == "voice"
|
|
assert payload["channel_switches"][0]["to_channel"] == "voice"
|
|
|
|
|
|
def test_sales_inbound_call_bridges_voice_runtime(monkeypatch):
|
|
client = TestClient(sales_module.app)
|
|
_, deal_id = _create_lead(client)
|
|
_DummyHttpxClient.requests = []
|
|
_DummyHttpxClient.response_payload = {"voice_session_id": "avs_sales_case_01"}
|
|
monkeypatch.setattr(sales_module.httpx, "Client", _DummyHttpxClient)
|
|
monkeypatch.setenv("SALES_VOICE_RUNTIME_ENABLED", "1")
|
|
|
|
response = client.post(
|
|
"/api/v1/calls/inbound-webhook",
|
|
json={
|
|
"deal_id": deal_id,
|
|
"phone_number": "+77001234567",
|
|
"provider": "asterisk",
|
|
"external_call_id": "call-sales-01",
|
|
"subject": "Inbound sales call",
|
|
},
|
|
headers=_headers(),
|
|
)
|
|
assert response.status_code == 200
|
|
assert _DummyHttpxClient.requests
|
|
assert _DummyHttpxClient.requests[0]["url"].endswith("/internal/voice-ai/sessions")
|
|
assert _DummyHttpxClient.requests[0]["json"]["call_id"] == "call-sales-01"
|
|
|
|
workspace = client.get(f"/api/v1/deals/{deal_id}/workspace", headers=_headers())
|
|
assert workspace.status_code == 200
|
|
communication = workspace.json()["communications"][0]
|
|
assert communication["channel_type"] == "voice"
|
|
assert communication["metadata"]["voice_session_id"] == "avs_sales_case_01"
|
|
|
|
|
|
def test_sales_internal_telegram_sync_auto_creates_workspace():
|
|
client = TestClient(sales_module.app)
|
|
|
|
response = client.post(
|
|
"/internal/sales-sync/telegram",
|
|
json={
|
|
"thread_id": "tg-thread-auto-01",
|
|
"chat_id": "tg-chat-auto-01",
|
|
"interaction_id": "int_tg_auto_01",
|
|
"phone_number": "+77005550101",
|
|
"display_name": "Telegram Prospect",
|
|
"queue_id": "q_sales",
|
|
"status": "new",
|
|
"ai_state": "queued",
|
|
"message_id": "msg_tg_auto_01",
|
|
"external_message_id": "ext_tg_auto_01",
|
|
"text": "Здравствуйте, нужен расчет и коммерческое предложение.",
|
|
"direction": "inbound",
|
|
"author_type": "customer",
|
|
"author_id": "tg-user-01",
|
|
"metadata": {"username": "sales_prospect"},
|
|
},
|
|
headers=_headers(),
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
workspace = response.json()
|
|
assert workspace["deal"]["current_channel"] == "telegram"
|
|
assert workspace["deal"]["preferred_channel"] == "telegram"
|
|
assert workspace["communications"][0]["channel_provider"] == "telegram"
|
|
assert workspace["communications"][0]["metadata"]["telegram_thread_id"] == "tg-thread-auto-01"
|
|
assert workspace["messages"][0]["external_message_id"] == "ext_tg_auto_01"
|
|
|
|
|
|
def test_sales_internal_telegram_sync_moves_pipeline_every_three_customer_messages():
|
|
client = TestClient(sales_module.app)
|
|
tenant_id = "tenant_tg_autostage"
|
|
|
|
def post_message(index: int) -> dict:
|
|
response = client.post(
|
|
"/internal/sales-sync/telegram",
|
|
json={
|
|
"thread_id": "tg-thread-autostage-01",
|
|
"chat_id": "tg-chat-autostage-01",
|
|
"interaction_id": "int_tg_autostage_01",
|
|
"display_name": "Autostage Prospect",
|
|
"message_id": f"msg_tg_autostage_{index}",
|
|
"external_message_id": f"ext_tg_autostage_{index}",
|
|
"text": f"Сообщение клиента {index}",
|
|
"direction": "inbound",
|
|
"author_type": "customer",
|
|
"author_id": "tg-user-autostage",
|
|
},
|
|
headers=_headers(tenant_id),
|
|
)
|
|
assert response.status_code == 200
|
|
return response.json()
|
|
|
|
stage_codes = [post_message(index)["stage"]["code"] for index in range(1, 7)]
|
|
|
|
assert stage_codes[0] == "new_qualified_lead"
|
|
assert stage_codes[1] == "new_qualified_lead"
|
|
assert stage_codes[2] == "warm_lead"
|
|
assert stage_codes[3] == "warm_lead"
|
|
assert stage_codes[4] == "warm_lead"
|
|
assert stage_codes[5] == "hot_lead"
|
|
|
|
duplicate = post_message(6)
|
|
assert duplicate["stage"]["code"] == "hot_lead"
|
|
assert len(duplicate["messages"]) == 6
|
|
|
|
|
|
def test_sales_internal_voice_sync_creates_call_and_transcript():
|
|
client = TestClient(sales_module.app)
|
|
|
|
response = client.post(
|
|
"/internal/sales-sync/voice",
|
|
json={
|
|
"call_id": "call-auto-voice-01",
|
|
"interaction_id": "int_voice_auto_01",
|
|
"queue_id": "q_voice_sales",
|
|
"queue_code": "voice_sales",
|
|
"caller_number": "+77005550202",
|
|
"caller_name": "Voice Prospect",
|
|
"voice_session_id": "avs_auto_01",
|
|
"ai_session_id": "ais_auto_01",
|
|
"ai_state": "completed",
|
|
"telephony_status": "ended",
|
|
"call_status": "completed",
|
|
"started_at": "2026-05-08T10:00:00Z",
|
|
"ended_at": "2026-05-08T10:03:00Z",
|
|
"summary": "Клиент запросил прайс и условия подключения.",
|
|
"transcript_text": "customer: Добрый день, нужен прайс.\nassistant: Подготовлю информацию.",
|
|
"metadata": {"agent_profile": "voice_sales"},
|
|
},
|
|
headers=_headers(),
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
workspace = response.json()
|
|
assert workspace["deal"]["current_channel"] == "voice"
|
|
assert workspace["communications"][0]["channel_type"] == "voice"
|
|
assert workspace["communications"][0]["metadata"]["voice_session_id"] == "avs_auto_01"
|
|
assert workspace["calls"][0]["external_call_id"] == "call-auto-voice-01"
|
|
assert workspace["transcripts"][0]["transcript_text"].startswith("customer:")
|
|
|
|
|
|
def test_sales_list_deals_query_matches_external_links():
|
|
client = TestClient(sales_module.app)
|
|
|
|
voice_workspace = client.post(
|
|
"/internal/sales-sync/voice",
|
|
json={
|
|
"call_id": "call-search-voice-01",
|
|
"interaction_id": "int_search_voice_01",
|
|
"caller_number": "+77005550303",
|
|
"caller_name": "Voice Search Prospect",
|
|
"voice_session_id": "avs_search_01",
|
|
"call_status": "completed",
|
|
},
|
|
headers=_headers(),
|
|
)
|
|
assert voice_workspace.status_code == 200
|
|
|
|
telegram_workspace = client.post(
|
|
"/internal/sales-sync/telegram",
|
|
json={
|
|
"thread_id": "tg-thread-search-01",
|
|
"chat_id": "tg-chat-search-01",
|
|
"interaction_id": "int_search_tg_01",
|
|
"display_name": "Telegram Search Prospect",
|
|
"text": "Need pricing details",
|
|
},
|
|
headers=_headers(),
|
|
)
|
|
assert telegram_workspace.status_code == 200
|
|
|
|
by_voice_session = client.get("/api/v1/deals?query=avs_search_01", headers=_headers())
|
|
assert by_voice_session.status_code == 200
|
|
assert any(item["deal_id"] == voice_workspace.json()["deal"]["deal_id"] for item in by_voice_session.json())
|
|
|
|
by_call_id = client.get("/api/v1/deals?query=call-search-voice-01", headers=_headers())
|
|
assert by_call_id.status_code == 200
|
|
assert any(item["deal_id"] == voice_workspace.json()["deal"]["deal_id"] for item in by_call_id.json())
|
|
|
|
by_thread_id = client.get("/api/v1/deals?query=tg-thread-search-01", headers=_headers())
|
|
assert by_thread_id.status_code == 200
|
|
assert any(item["deal_id"] == telegram_workspace.json()["deal"]["deal_id"] for item in by_thread_id.json())
|