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