384 lines
16 KiB
Python
384 lines
16 KiB
Python
import json
|
|
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select
|
|
|
|
import services.sales_service.app as sales_module
|
|
from services.shared.core import new_id
|
|
from services.shared.db import get_session
|
|
from services.shared.sql_models import EventOutboxRow
|
|
from services.shared.sales_sql_models import SalesChannelSwitchRow, SalesDealRow, SalesEscalationRow
|
|
|
|
|
|
def _headers(tenant_id: str = "tenant_omni") -> dict[str, str]:
|
|
return {"X-User": "admin", "X-Role": "admin", "X-Tenant-ID": tenant_id}
|
|
|
|
|
|
def _lead_payload(seed: str) -> dict:
|
|
return {
|
|
"source_type": "website",
|
|
"source_channel": "webchat",
|
|
"full_name": f"Omni Buyer {seed}",
|
|
"company_name": "Omni QA",
|
|
"phone": f"+7700{seed[-7:]}",
|
|
"email": f"{seed}@omni.test",
|
|
"lead_temperature": "warm",
|
|
"lead_score": 70,
|
|
"initial_need_summary": "Need omnichannel CRM context.",
|
|
"preferred_channel": "telegram",
|
|
"assigned_agent_type": "text_ai",
|
|
"status": "new_qualified_lead",
|
|
"priority": 3,
|
|
"title": f"Omni Lead {seed}",
|
|
}
|
|
|
|
|
|
def _create_lead_and_deal(client: TestClient, tenant_id: str = "tenant_omni") -> tuple[dict, dict]:
|
|
seed = new_id("omn").replace("_", "")
|
|
response = client.post("/api/v1/leads", json=_lead_payload(seed), headers=_headers(tenant_id))
|
|
assert response.status_code == 200
|
|
lead = response.json()
|
|
deals = client.get("/api/v1/deals", headers=_headers(tenant_id))
|
|
assert deals.status_code == 200
|
|
deal = next(item for item in deals.json() if item["lead_id"] == lead["lead_id"])
|
|
return lead, deal
|
|
|
|
|
|
def _start_communication(client: TestClient, deal_id: str, channel_type: str, tenant_id: str = "tenant_omni") -> dict:
|
|
response = client.post(
|
|
f"/api/v1/deals/{deal_id}/communications/{channel_type}",
|
|
json={
|
|
"channel_type": channel_type,
|
|
"direction": "outbound",
|
|
"agent_type": "human",
|
|
"subject": f"{channel_type} contact",
|
|
},
|
|
headers=_headers(tenant_id),
|
|
)
|
|
assert response.status_code == 200
|
|
return response.json()
|
|
|
|
|
|
def _change_stage(client: TestClient, deal_id: str, stage_code: str, tenant_id: str = "tenant_omni") -> dict:
|
|
response = client.post(
|
|
f"/api/v1/deals/{deal_id}/change-stage",
|
|
json={"target_stage_code": stage_code, "reason": f"test.{stage_code}"},
|
|
headers=_headers(tenant_id),
|
|
)
|
|
assert response.status_code == 200
|
|
return response.json()
|
|
|
|
|
|
def _prepare_invoice(client: TestClient, deal_id: str, tenant_id: str = "tenant_omni") -> dict:
|
|
updated = client.patch(f"/api/v1/deals/{deal_id}", json={"document_required": False}, headers=_headers(tenant_id))
|
|
assert updated.status_code == 200
|
|
_change_stage(client, deal_id, "active_text_communication", tenant_id)
|
|
_change_stage(client, deal_id, "need_confirmed", tenant_id)
|
|
invoice = client.post(
|
|
f"/api/v1/deals/{deal_id}/invoices",
|
|
json={"amount": 1000, "currency": "KZT", "due_date": "2026-05-15"},
|
|
headers=_headers(tenant_id),
|
|
)
|
|
assert invoice.status_code == 200
|
|
sent = client.post(f"/api/v1/invoices/{invoice.json()['invoice_id']}/send", headers=_headers(tenant_id))
|
|
assert sent.status_code == 200
|
|
return sent.json()
|
|
|
|
|
|
def _event_payload(event_type: str, tenant_id: str) -> dict:
|
|
session = get_session()
|
|
try:
|
|
rows = session.execute(select(EventOutboxRow).where(EventOutboxRow.event_type == event_type).order_by(EventOutboxRow.id.desc())).scalars().all()
|
|
for row in rows:
|
|
payload = json.loads(row.payload_json or "{}").get("payload", {})
|
|
if payload.get("tenant_id") == tenant_id:
|
|
return payload
|
|
raise AssertionError(f"expected {event_type}")
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def test_switch_text_to_voice_keeps_same_deal():
|
|
client = TestClient(sales_module.app)
|
|
_, deal = _create_lead_and_deal(client, "tenant_omni_text_voice")
|
|
communication = _start_communication(client, deal["deal_id"], "text", "tenant_omni_text_voice")
|
|
|
|
switched = client.post(
|
|
f"/api/v1/communications/{communication['communication_id']}/switch-channel",
|
|
json={"to_channel": "voice", "reason_code": "customer_requested_call", "reason_text": "Customer asked for a call"},
|
|
headers=_headers("tenant_omni_text_voice"),
|
|
)
|
|
|
|
assert switched.status_code == 200
|
|
assert switched.json()["deal_id"] == deal["deal_id"]
|
|
workspace = client.get(f"/api/v1/deals/{deal['deal_id']}/workspace", headers=_headers("tenant_omni_text_voice")).json()
|
|
assert workspace["deal"]["deal_id"] == deal["deal_id"]
|
|
assert workspace["deal"]["current_channel"] == "voice"
|
|
assert workspace["stage"]["code"] == "active_voice_communication"
|
|
|
|
|
|
def test_switch_voice_to_text_keeps_same_deal():
|
|
client = TestClient(sales_module.app)
|
|
_, deal = _create_lead_and_deal(client, "tenant_omni_voice_text")
|
|
communication = _start_communication(client, deal["deal_id"], "voice", "tenant_omni_voice_text")
|
|
|
|
switched = client.post(
|
|
f"/api/v1/communications/{communication['communication_id']}/switch-channel",
|
|
json={"to_channel": "text", "reason_code": "after_call_send_offer", "reason_text": "Send written follow-up", "create_session": True},
|
|
headers=_headers("tenant_omni_voice_text"),
|
|
)
|
|
|
|
assert switched.status_code == 200
|
|
assert switched.json()["deal_id"] == deal["deal_id"]
|
|
assert switched.json()["new_communication"]["deal_id"] == deal["deal_id"]
|
|
workspace = client.get(f"/api/v1/deals/{deal['deal_id']}/workspace", headers=_headers("tenant_omni_voice_text")).json()
|
|
assert workspace["deal"]["current_channel"] == "text"
|
|
assert workspace["stage"]["code"] == "active_text_communication"
|
|
|
|
|
|
def test_switch_channel_creates_channel_switch_history():
|
|
client = TestClient(sales_module.app)
|
|
_, deal = _create_lead_and_deal(client, "tenant_omni_switch_history")
|
|
communication = _start_communication(client, deal["deal_id"], "text", "tenant_omni_switch_history")
|
|
response = client.post(
|
|
f"/api/v1/communications/{communication['communication_id']}/switch-channel",
|
|
json={"to_channel": "voice", "reason_code": "no_reply_in_text", "reason_text": "No reply"},
|
|
headers=_headers("tenant_omni_switch_history"),
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
history = client.get(f"/api/v1/deals/{deal['deal_id']}/channel-switches", headers=_headers("tenant_omni_switch_history"))
|
|
|
|
assert history.status_code == 200
|
|
assert history.json()[0]["switch_id"] == response.json()["switch_id"]
|
|
assert history.json()[0]["reason_code"] == "no_reply_in_text"
|
|
|
|
|
|
def test_switch_channel_publishes_event():
|
|
client = TestClient(sales_module.app)
|
|
_, deal = _create_lead_and_deal(client, "tenant_omni_switch_event")
|
|
communication = _start_communication(client, deal["deal_id"], "text", "tenant_omni_switch_event")
|
|
|
|
response = client.post(
|
|
f"/api/v1/communications/{communication['communication_id']}/switch-channel",
|
|
json={"to_channel": "voice", "reason_code": "complex_question", "reason_text": "Need voice"},
|
|
headers=_headers("tenant_omni_switch_event"),
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = _event_payload("communication.channel_switched", "tenant_omni_switch_event")
|
|
assert payload["switch_id"] == response.json()["switch_id"]
|
|
assert payload["to_channel"] == "voice"
|
|
|
|
|
|
def test_switch_channel_does_not_change_finance_stage_unnecessarily():
|
|
client = TestClient(sales_module.app)
|
|
_, deal = _create_lead_and_deal(client, "tenant_omni_finance_stage")
|
|
_prepare_invoice(client, deal["deal_id"], "tenant_omni_finance_stage")
|
|
|
|
response = client.post(
|
|
f"/api/v1/deals/{deal['deal_id']}/switch-channel",
|
|
json={"to_channel": "voice", "reason_code": "payment_follow_up", "reason_text": "Call about payment"},
|
|
headers=_headers("tenant_omni_finance_stage"),
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
workspace = client.get(f"/api/v1/deals/{deal['deal_id']}/workspace", headers=_headers("tenant_omni_finance_stage")).json()
|
|
assert workspace["deal"]["current_channel"] == "voice"
|
|
assert workspace["stage"]["code"] == "invoice_sent"
|
|
|
|
|
|
def test_channel_switch_is_tenant_scoped():
|
|
client = TestClient(sales_module.app)
|
|
_, foreign_deal = _create_lead_and_deal(client, "tenant_omni_switch_foreign")
|
|
communication = _start_communication(client, foreign_deal["deal_id"], "text", "tenant_omni_switch_foreign")
|
|
|
|
response = client.post(
|
|
f"/api/v1/communications/{communication['communication_id']}/switch-channel",
|
|
json={"to_channel": "voice", "reason_code": "human_decision"},
|
|
headers=_headers("tenant_omni_switch_other"),
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert client.get(f"/api/v1/deals/{foreign_deal['deal_id']}/channel-switches", headers=_headers("tenant_omni_switch_other")).status_code == 404
|
|
|
|
|
|
def test_create_list_update_deal_note_and_workspace_contains_notes():
|
|
tenant_id = "tenant_omni_notes"
|
|
client = TestClient(sales_module.app)
|
|
_, deal = _create_lead_and_deal(client, tenant_id)
|
|
|
|
created = client.post(
|
|
f"/api/v1/deals/{deal['deal_id']}/notes",
|
|
json={"note_type": "objection", "content": "Customer asked about payment delay", "source_type": "manual"},
|
|
headers=_headers(tenant_id),
|
|
)
|
|
listed = client.get(f"/api/v1/deals/{deal['deal_id']}/notes", headers=_headers(tenant_id))
|
|
updated = client.patch(
|
|
f"/api/v1/deals/{deal['deal_id']}/notes/{created.json()['note_id']}",
|
|
json={"content": "Customer objection resolved", "note_type": "summary"},
|
|
headers=_headers(tenant_id),
|
|
)
|
|
workspace = client.get(f"/api/v1/deals/{deal['deal_id']}/workspace", headers=_headers(tenant_id))
|
|
|
|
assert created.status_code == 200
|
|
assert listed.status_code == 200
|
|
assert listed.json()[0]["note_id"] == created.json()["note_id"]
|
|
assert updated.status_code == 200
|
|
assert updated.json()["content"] == "Customer objection resolved"
|
|
assert workspace.status_code == 200
|
|
assert workspace.json()["notes"][0]["note_id"] == created.json()["note_id"]
|
|
|
|
|
|
def test_notes_are_tenant_scoped():
|
|
client = TestClient(sales_module.app)
|
|
_, deal = _create_lead_and_deal(client, "tenant_omni_note_owner")
|
|
created = client.post(
|
|
f"/api/v1/deals/{deal['deal_id']}/notes",
|
|
json={"note_type": "general", "content": "Private note"},
|
|
headers=_headers("tenant_omni_note_owner"),
|
|
)
|
|
assert created.status_code == 200
|
|
|
|
assert client.get(f"/api/v1/deals/{deal['deal_id']}/notes", headers=_headers("tenant_omni_note_other")).status_code == 404
|
|
assert (
|
|
client.patch(
|
|
f"/api/v1/deals/{deal['deal_id']}/notes/{created.json()['note_id']}",
|
|
json={"content": "Nope"},
|
|
headers=_headers("tenant_omni_note_other"),
|
|
).status_code
|
|
== 404
|
|
)
|
|
|
|
|
|
def test_workspace_contains_transcripts_and_communication_channel_provider():
|
|
tenant_id = "tenant_omni_workspace_contract"
|
|
client = TestClient(sales_module.app)
|
|
response = client.post(
|
|
"/internal/sales-sync/voice",
|
|
json={
|
|
"call_id": "call-omni-workspace-1",
|
|
"voice_session_id": "voice-omni-workspace-1",
|
|
"caller_number": "+77009991122",
|
|
"caller_name": "Voice Prospect",
|
|
"summary": "Needs invoice",
|
|
"transcript_text": "customer: hello\nagent: hello",
|
|
},
|
|
headers=_headers(tenant_id),
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
workspace = response.json()
|
|
assert workspace["transcripts"][0]["transcript_text"].startswith("customer:")
|
|
assert workspace["communications"][0]["channel_provider"] == "voice"
|
|
|
|
|
|
def test_create_escalation_and_lifecycle():
|
|
tenant_id = "tenant_omni_escalation_lifecycle"
|
|
client = TestClient(sales_module.app)
|
|
_, deal = _create_lead_and_deal(client, tenant_id)
|
|
|
|
created = client.post(
|
|
f"/api/v1/deals/{deal['deal_id']}/escalate",
|
|
json={"escalation_type": "customer_requested_human", "reason": "Customer asked for a person", "severity": "high"},
|
|
headers=_headers(tenant_id),
|
|
)
|
|
assigned = client.post(
|
|
f"/api/v1/escalations/{created.json()['escalation_id']}/assign",
|
|
json={"assigned_to_user_id": "human-1"},
|
|
headers=_headers(tenant_id),
|
|
)
|
|
started = client.post(f"/api/v1/escalations/{created.json()['escalation_id']}/start", headers=_headers(tenant_id))
|
|
resolved = client.post(
|
|
f"/api/v1/escalations/{created.json()['escalation_id']}/resolve",
|
|
json={"resolution_code": "handled", "resolution_summary": "Human handled the case"},
|
|
headers=_headers(tenant_id),
|
|
)
|
|
|
|
assert created.status_code == 200
|
|
assert assigned.status_code == 200
|
|
assert assigned.json()["status"] == "assigned"
|
|
assert started.status_code == 200
|
|
assert started.json()["status"] == "in_progress"
|
|
assert resolved.status_code == 200
|
|
assert resolved.json()["status"] == "resolved"
|
|
workspace = client.get(f"/api/v1/deals/{deal['deal_id']}/workspace", headers=_headers(tenant_id)).json()
|
|
assert workspace["escalations"][0]["status"] == "resolved"
|
|
|
|
|
|
def test_cancel_escalation():
|
|
tenant_id = "tenant_omni_escalation_cancel"
|
|
client = TestClient(sales_module.app)
|
|
_, deal = _create_lead_and_deal(client, tenant_id)
|
|
created = client.post(
|
|
f"/api/v1/deals/{deal['deal_id']}/escalate",
|
|
json={"escalation_type": "low_confidence", "reason": "AI confidence low"},
|
|
headers=_headers(tenant_id),
|
|
)
|
|
|
|
canceled = client.post(
|
|
f"/api/v1/escalations/{created.json()['escalation_id']}/cancel",
|
|
json={"resolution_code": "not_needed", "resolution_summary": "Handled automatically"},
|
|
headers=_headers(tenant_id),
|
|
)
|
|
|
|
assert canceled.status_code == 200
|
|
assert canceled.json()["status"] == "canceled"
|
|
|
|
|
|
def test_escalation_transitions_deal_to_support_and_does_not_duplicate_open_escalation():
|
|
tenant_id = "tenant_omni_escalation_support"
|
|
client = TestClient(sales_module.app)
|
|
_, deal = _create_lead_and_deal(client, tenant_id)
|
|
payload = {"escalation_type": "complex_b2b_case", "reason": "Complex B2B contract"}
|
|
|
|
first = client.post(f"/api/v1/deals/{deal['deal_id']}/escalate", json=payload, headers=_headers(tenant_id))
|
|
second = client.post(f"/api/v1/deals/{deal['deal_id']}/escalate", json=payload, headers=_headers(tenant_id))
|
|
|
|
assert first.status_code == 200
|
|
assert second.status_code == 200
|
|
assert second.json()["escalation_id"] == first.json()["escalation_id"]
|
|
workspace = client.get(f"/api/v1/deals/{deal['deal_id']}/workspace", headers=_headers(tenant_id)).json()
|
|
assert workspace["stage"]["code"] == "transferred_to_support"
|
|
assert workspace["active_escalation"]["escalation_id"] == first.json()["escalation_id"]
|
|
|
|
|
|
def test_escalation_is_tenant_scoped():
|
|
client = TestClient(sales_module.app)
|
|
_, deal = _create_lead_and_deal(client, "tenant_omni_escalation_owner")
|
|
created = client.post(
|
|
f"/api/v1/deals/{deal['deal_id']}/escalate",
|
|
json={"escalation_type": "payment_dispute", "reason": "Payment dispute"},
|
|
headers=_headers("tenant_omni_escalation_owner"),
|
|
)
|
|
assert created.status_code == 200
|
|
|
|
assert client.get(f"/api/v1/deals/{deal['deal_id']}/escalations", headers=_headers("tenant_omni_escalation_other")).status_code == 404
|
|
assert (
|
|
client.post(
|
|
f"/api/v1/escalations/{created.json()['escalation_id']}/assign",
|
|
json={"assigned_to_user_id": "other-human"},
|
|
headers=_headers("tenant_omni_escalation_other"),
|
|
).status_code
|
|
== 404
|
|
)
|
|
|
|
|
|
def test_switch_channel_does_not_create_new_deal():
|
|
tenant_id = "tenant_omni_no_new_deal"
|
|
client = TestClient(sales_module.app)
|
|
_, deal = _create_lead_and_deal(client, tenant_id)
|
|
communication = _start_communication(client, deal["deal_id"], "text", tenant_id)
|
|
before = client.get("/api/v1/deals", headers=_headers(tenant_id)).json()
|
|
|
|
response = client.post(
|
|
f"/api/v1/communications/{communication['communication_id']}/switch-channel",
|
|
json={"to_channel": "voice", "reason_code": "human_decision"},
|
|
headers=_headers(tenant_id),
|
|
)
|
|
after = client.get("/api/v1/deals", headers=_headers(tenant_id)).json()
|
|
|
|
assert response.status_code == 200
|
|
assert len(after) == len(before)
|
|
assert {item["deal_id"] for item in after} == {item["deal_id"] for item in before}
|