Files
call-center/tests/test_ivr_service.py

511 lines
19 KiB
Python

from fastapi.testclient import TestClient
from services.interaction_service.app import app as interaction_app
from services.ivr_service.app import app as ivr_app
from services.shared.security import issue_app_token
from services.voice_adapter_service.app import app as voice_app
def _admin_headers() -> dict[str, str]:
return {"X-User": "admin", "X-Role": "admin"}
def _supervisor_headers() -> dict[str, str]:
return {"X-User": "supervisor", "X-Role": "supervisor"}
def _valid_flow(queue_id: str, resolved_queue_id: str) -> dict:
return {
"name": "Queue IVR",
"description": "DTMF flow",
"queue_id": queue_id,
"entry_node_id": "root",
"flow_json": {
"nodes": [
{
"node_id": "root",
"prompt_text": "Press 1 for support",
"prompt_audio_key": "ivr/demo-root",
"is_terminal": False,
"invalid_target_node_id": "root",
"options": [
{"digit": "1", "target_node_id": "support"},
],
},
{
"node_id": "support",
"prompt_text": "Routing to support",
"prompt_audio_key": "ivr/demo-support",
"is_terminal": True,
"outcome_code": "support_route",
"resolved_queue_id": resolved_queue_id,
"resolved_queue_code": "support",
"options": [],
},
]
},
"is_active": True,
}
def _trusted_service_headers(subject: str = "svc:asterisk-bridge") -> dict[str, str]:
token = issue_app_token(
subject=subject,
username="asterisk-bridge",
role="admin",
auth_source="service",
provider="asterisk-bridge",
)
return {"Authorization": f"Bearer {token}"}
def test_create_validate_and_activate_flows():
client = TestClient(ivr_app)
queue_id = "ivr_queue_validation"
invalid = client.post(
"/ivr/flows",
headers=_admin_headers(),
json={
"name": "Broken Flow",
"description": "",
"queue_id": queue_id,
"entry_node_id": "root",
"flow_json": {"nodes": [{"node_id": "root", "is_terminal": False, "options": []}]},
"is_active": True,
},
)
assert invalid.status_code == 400
first = client.post(
"/ivr/flows",
headers=_admin_headers(),
json=_valid_flow(queue_id, "support_queue_a"),
)
assert first.status_code == 200
first_flow_id = first.json()["flow_id"]
assert first.json()["is_active"] is True
second_payload = _valid_flow(queue_id, "support_queue_b")
second_payload["name"] = "Replacement Flow"
second = client.post("/ivr/flows", headers=_admin_headers(), json=second_payload)
assert second.status_code == 200
second_flow_id = second.json()["flow_id"]
assert second_flow_id != first_flow_id
first_detail = client.get(f"/ivr/flows/{first_flow_id}", headers=_supervisor_headers())
assert first_detail.status_code == 200
assert first_detail.json()["is_active"] is False
activated = client.post(f"/ivr/flows/{first_flow_id}/activate", headers=_admin_headers())
assert activated.status_code == 200
assert activated.json()["is_active"] is True
second_detail = client.get(f"/ivr/flows/{second_flow_id}", headers=_supervisor_headers())
assert second_detail.status_code == 200
assert second_detail.json()["is_active"] is False
missing_queue_code = client.post(
"/ivr/flows",
headers=_admin_headers(),
json={
"name": "Broken Terminal",
"description": "",
"queue_id": queue_id,
"entry_node_id": "root",
"flow_json": {
"nodes": [
{
"node_id": "root",
"prompt_text": "Press 1",
"is_terminal": False,
"options": [{"digit": "1", "target_node_id": "terminal"}],
},
{
"node_id": "terminal",
"prompt_text": "Done",
"is_terminal": True,
"outcome_code": "done",
"resolved_queue_id": "support_queue_a",
"options": [],
},
]
},
"is_active": False,
},
)
assert missing_queue_code.status_code == 400
assert "resolved_queue_code" in missing_queue_code.json()["detail"]
def test_flow_validation_accepts_prompt_sequence_without_single_prompt_key():
client = TestClient(ivr_app)
response = client.post(
"/ivr/flows",
headers=_admin_headers(),
json={
"name": "Prompt Sequence Flow",
"description": "Mixed language prompt split into segments",
"queue_id": "ivr_prompt_sequence_queue",
"entry_node_id": "root",
"flow_json": {
"nodes": [
{
"node_id": "root",
"prompt_text": "Combined bilingual prompt",
"prompt_sequence": [
{
"prompt_audio_key": "ivr/demo-language-ru",
"prompt_text": "Russian segment",
"language": "ru",
},
{
"prompt_audio_key": "ivr/demo-language-kz",
"prompt_text": "Kazakh segment",
"language": "kz",
},
],
"is_terminal": False,
"invalid_target_node_id": "root",
"options": [
{"digit": "1", "target_node_id": "terminal"},
],
},
{
"node_id": "terminal",
"prompt_text": "Done",
"is_terminal": True,
"outcome_code": "done",
"resolved_queue_id": "support_queue_a",
"resolved_queue_code": "support",
"options": [],
},
]
},
"is_active": False,
},
)
assert response.status_code == 200
payload = response.json()
assert payload["flow_json"]["nodes"][0]["prompt_sequence"][0]["prompt_audio_key"] == "ivr/demo-language-ru"
assert payload["flow_json"]["nodes"][0]["prompt_sequence"][1]["prompt_audio_key"] == "ivr/demo-language-kz"
def test_ivr_session_runtime_emits_voice_event_and_timeline():
ivr_client = TestClient(ivr_app)
voice_client = TestClient(voice_app)
interaction_client = TestClient(interaction_app)
interaction = interaction_client.post(
"/interactions",
json={
"channel": "voice",
"subject": "IVR runtime trace",
"queue_id": "ivr_runtime_queue",
"priority": 3,
},
headers={"X-User": "operator", "X-Role": "operator"},
)
assert interaction.status_code == 200
interaction_id = interaction.json()["interaction_id"]
flow = ivr_client.post(
"/ivr/flows",
headers=_admin_headers(),
json=_valid_flow("ivr_runtime_queue", "ivr_resolved_support"),
)
assert flow.status_code == 200
flow_id = flow.json()["flow_id"]
started = ivr_client.post(
"/ivr/sessions/start",
headers=_supervisor_headers(),
json={
"call_id": "call_ivr_runtime",
"queue_id": "ivr_runtime_queue",
"interaction_id": interaction_id,
},
)
assert started.status_code == 200
assert started.json()["session"]["flow_id"] == flow_id
session_id = started.json()["session"]["session_id"]
stepped = ivr_client.post(
f"/ivr/sessions/{session_id}/dtmf",
headers=_supervisor_headers(),
json={"digit": "1"},
)
assert stepped.status_code == 200
assert stepped.json()["completed"] is True
assert stepped.json()["session"]["status"] == "completed"
assert stepped.json()["session"]["outcome_code"] == "support_route"
assert stepped.json()["session"]["resolved_queue_code"] == "support"
repeated = ivr_client.post(
f"/ivr/sessions/{session_id}/dtmf",
headers=_supervisor_headers(),
json={"digit": "1"},
)
assert repeated.status_code == 409
voice_events = voice_client.get("/integrations/voice/events", headers=_admin_headers())
assert voice_events.status_code == 200
assert any(
item["event_type"] == "ivr.completed"
and item["payload"]["session_id"] == session_id
and item["payload"]["flow_id"] == flow_id
for item in voice_events.json()
)
timeline = interaction_client.get(f"/interactions/{interaction_id}/timeline")
assert timeline.status_code == 200
actions = [item["action"] for item in timeline.json()["events"]]
assert "ivr.session.started" in actions
assert "ivr.step.completed" in actions
assert "ivr.completed" in actions
def test_no_input_runtime_path_supports_trusted_service_actor():
ivr_client = TestClient(ivr_app)
interaction_client = TestClient(interaction_app)
interaction = interaction_client.post(
"/interactions",
json={
"channel": "voice",
"subject": "IVR no input trace",
"queue_id": "ivr_no_input_queue",
"priority": 3,
},
headers={"X-User": "operator", "X-Role": "operator"},
)
assert interaction.status_code == 200
interaction_id = interaction.json()["interaction_id"]
flow = ivr_client.post(
"/ivr/flows",
headers=_admin_headers(),
json={
"name": "No Input Flow",
"description": "No input route",
"queue_id": "ivr_no_input_queue",
"entry_node_id": "root",
"flow_json": {
"nodes": [
{
"node_id": "root",
"prompt_text": "Press 1 for support",
"prompt_audio_key": "ivr/no-input-root",
"is_terminal": False,
"invalid_target_node_id": "root",
"no_input_target_node_id": "timeout",
"options": [{"digit": "1", "target_node_id": "support"}],
},
{
"node_id": "support",
"prompt_text": "Support queue",
"prompt_audio_key": "ivr/no-input-support",
"is_terminal": True,
"outcome_code": "support_route",
"resolved_queue_id": "ivr_support_queue",
"resolved_queue_code": "support",
"options": [],
},
{
"node_id": "timeout",
"prompt_text": "Routing to default queue",
"prompt_audio_key": "ivr/no-input-timeout",
"is_terminal": True,
"outcome_code": "timeout_route",
"resolved_queue_id": "ivr_timeout_queue",
"resolved_queue_code": "voice_lab",
"options": [],
},
]
},
"is_active": True,
},
)
assert flow.status_code == 200
started = ivr_client.post(
"/ivr/sessions/start",
headers=_trusted_service_headers(),
json={
"call_id": "call_ivr_no_input",
"queue_id": "ivr_no_input_queue",
"interaction_id": interaction_id,
},
)
assert started.status_code == 200
session_id = started.json()["session"]["session_id"]
no_input = ivr_client.post(
f"/ivr/sessions/{session_id}/no-input",
headers=_trusted_service_headers(),
)
assert no_input.status_code == 200
assert no_input.json()["completed"] is True
assert no_input.json()["session"]["status"] == "completed"
assert no_input.json()["session"]["resolved_queue_id"] == "ivr_timeout_queue"
assert no_input.json()["session"]["resolved_queue_code"] == "voice_lab"
blocked = ivr_client.post(
"/ivr/sessions/start",
headers=_trusted_service_headers("svc:not-allowed"),
json={"call_id": "call_blocked", "queue_id": "ivr_no_input_queue"},
)
assert blocked.status_code == 403
timeline = interaction_client.get(f"/interactions/{interaction_id}/timeline")
assert timeline.status_code == 200
actions = [item["action"] for item in timeline.json()["events"]]
assert "ivr.step.no_input" in actions
assert "ivr.completed" in actions
def test_multistep_language_selection_flow_routes_after_second_digit():
ivr_client = TestClient(ivr_app)
flow = ivr_client.post(
"/ivr/flows",
headers=_admin_headers(),
json={
"name": "Language IVR",
"description": "Greeting and language choice before routing",
"queue_id": "ivr_language_queue",
"entry_node_id": "root",
"flow_json": {
"nodes": [
{
"node_id": "root",
"prompt_text": "Welcome. Press 1 for Russian. Press 2 for Kazakh.",
"prompt_audio_key": "ivr/language-root",
"is_terminal": False,
"invalid_target_node_id": "root",
"no_input_target_node_id": "root",
"options": [
{"digit": "1", "target_node_id": "menu_ru"},
{"digit": "2", "target_node_id": "menu_kz"},
],
},
{
"node_id": "menu_ru",
"prompt_text": "Russian menu",
"prompt_audio_key": "ivr/language-menu-ru",
"is_terminal": False,
"invalid_target_node_id": "menu_ru",
"no_input_target_node_id": "menu_ru",
"options": [
{"digit": "1", "target_node_id": "sales_ru"},
{"digit": "2", "target_node_id": "support_ru"},
],
},
{
"node_id": "menu_kz",
"prompt_text": "Kazakh menu",
"prompt_audio_key": "ivr/language-menu-kz",
"is_terminal": False,
"invalid_target_node_id": "menu_kz",
"no_input_target_node_id": "menu_kz",
"options": [
{"digit": "1", "target_node_id": "sales_kz"},
{"digit": "2", "target_node_id": "support_kz"},
],
},
{
"node_id": "sales_ru",
"prompt_text": "Routing to sales",
"prompt_audio_key": "ivr/language-sales-ru",
"is_terminal": True,
"outcome_code": "sales_route_ru",
"resolved_queue_id": "ivr_sales_queue",
"resolved_queue_code": "ivr_sales_ai_ru",
"options": [],
},
{
"node_id": "support_ru",
"prompt_text": "Routing to support",
"prompt_audio_key": "ivr/language-support-ru",
"is_terminal": True,
"outcome_code": "support_route_ru",
"resolved_queue_id": "ivr_support_queue",
"resolved_queue_code": "ivr_support_ai_ru",
"options": [],
},
{
"node_id": "sales_kz",
"prompt_text": "Routing to sales",
"prompt_audio_key": "ivr/language-sales-kz",
"is_terminal": True,
"outcome_code": "sales_route_kz",
"resolved_queue_id": "ivr_sales_queue",
"resolved_queue_code": "ivr_sales_ai_kz",
"options": [],
},
{
"node_id": "support_kz",
"prompt_text": "Routing to support",
"prompt_audio_key": "ivr/language-support-kz",
"is_terminal": True,
"outcome_code": "support_route_kz",
"resolved_queue_id": "ivr_support_queue",
"resolved_queue_code": "ivr_support_ai_kz",
"options": [],
},
]
},
"is_active": True,
},
)
assert flow.status_code == 200
started = ivr_client.post(
"/ivr/sessions/start",
headers=_trusted_service_headers(),
json={
"call_id": "call_language_ivr",
"queue_id": "ivr_language_queue",
},
)
assert started.status_code == 200
assert started.json()["completed"] is False
assert started.json()["current_node"]["node_id"] == "root"
session_id = started.json()["session"]["session_id"]
select_language = ivr_client.post(
f"/ivr/sessions/{session_id}/dtmf",
headers=_trusted_service_headers(),
json={"digit": "2"},
)
assert select_language.status_code == 200
assert select_language.json()["completed"] is False
assert select_language.json()["session"]["current_node_id"] == "menu_kz"
assert select_language.json()["session"]["entered_digits"] == ["2"]
choose_support = ivr_client.post(
f"/ivr/sessions/{session_id}/dtmf",
headers=_trusted_service_headers(),
json={"digit": "2"},
)
assert choose_support.status_code == 200
assert choose_support.json()["completed"] is True
assert choose_support.json()["session"]["status"] == "completed"
assert choose_support.json()["session"]["entered_digits"] == ["2", "2"]
assert choose_support.json()["session"]["resolved_queue_id"] == "ivr_support_queue"
assert choose_support.json()["session"]["resolved_queue_code"] == "ivr_support_ai_kz"
assert choose_support.json()["session"]["outcome_code"] == "support_route_kz"
def test_start_without_active_flow_returns_404():
client = TestClient(ivr_app)
response = client.post(
"/ivr/sessions/start",
headers=_supervisor_headers(),
json={"call_id": "call_missing_flow", "queue_id": "missing_queue"},
)
assert response.status_code == 404