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}