dograh/api/tests/test_telephony_routes.py
Abhishek fb4038a969
fix: fix org scoped access for resources (#517)
* fix: fix org scoped access for resources

* Fix auth and config validation regressions

* fix: track org config validation timestamp

* fix: backfill org model configuration v2 from legacy user rows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: align config tests with org-level v2 resolution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: helm example values tweaks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:04:33 +05:30

397 lines
14 KiB
Python

from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock, Mock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.enums import WorkflowRunMode, WorkflowRunState
from api.errors.telephony_errors import TelephonyError
from api.routes.telephony import _handle_telephony_websocket, handle_inbound_run, router
from api.services.auth.depends import get_user
from api.services.call_concurrency import CallConcurrencyLimitError
def _make_test_app() -> FastAPI:
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_user] = lambda: SimpleNamespace(
id=7,
selected_organization_id=11,
)
return app
def _workflow(*, workflow_id: int = 33, user_id: int = 99):
return SimpleNamespace(
id=workflow_id,
user_id=user_id,
organization_id=11,
template_context_variables={"template_key": "template-value"},
)
def _provider():
return SimpleNamespace(
PROVIDER_NAME="twilio",
WEBHOOK_ENDPOINT="twilio/voice",
validate_config=Mock(return_value=True),
initiate_call=AsyncMock(
return_value=SimpleNamespace(
caller_number="+15550001111",
provider_metadata={"call_id": "call-123"},
)
),
)
def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow():
app = _make_test_app()
client = TestClient(app)
workflow = _workflow()
provider = _provider()
quota_mock = AsyncMock(
return_value=SimpleNamespace(has_quota=True, error_message="")
)
with (
patch("api.routes.telephony.db_client") as mock_db,
patch("api.routes.telephony.call_concurrency") as mock_concurrency,
patch(
"api.routes.telephony.authorize_workflow_run_start",
new=quota_mock,
),
patch(
"api.routes.telephony.get_default_telephony_provider",
new=AsyncMock(return_value=provider),
),
patch(
"api.routes.telephony.get_backend_endpoints",
new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")),
),
):
slot = object()
mock_concurrency.acquire_org_slot = AsyncMock(return_value=slot)
mock_concurrency.bind_workflow_run = AsyncMock()
mock_concurrency.release_slot = AsyncMock()
mock_concurrency.release_workflow_run_slot = AsyncMock()
mock_db.get_user_configurations = AsyncMock(
return_value=SimpleNamespace(test_phone_number=None)
)
mock_db.get_default_telephony_configuration = AsyncMock(
return_value=SimpleNamespace(id=55)
)
mock_db.get_workflow = AsyncMock(return_value=workflow)
mock_db.create_workflow_run = AsyncMock(
return_value=SimpleNamespace(
id=501,
name="WR-TEL-OUT-00000001",
initial_context={"template_key": "template-value"},
)
)
mock_db.update_workflow_run = AsyncMock()
response = client.post(
"/telephony/initiate-call",
json={"workflow_id": workflow.id, "phone_number": "+15551234567"},
)
assert response.status_code == 200
quota_mock.assert_awaited_once_with(
workflow_id=workflow.id,
organization_id=workflow.organization_id,
workflow_run_id=501,
actor_user=ANY,
)
mock_db.get_workflow.assert_awaited_once_with(workflow.id, organization_id=11)
create_call = mock_db.create_workflow_run.await_args
create_args = create_call.args
create_kwargs = create_call.kwargs
assert create_args[1] == workflow.id
assert create_kwargs["user_id"] == workflow.user_id
assert create_kwargs["organization_id"] == workflow.organization_id
assert create_kwargs["initial_context"]["template_key"] == "template-value"
mock_concurrency.acquire_org_slot.assert_awaited_once_with(
workflow.organization_id,
source="telephony_outbound",
timeout=0,
)
mock_concurrency.bind_workflow_run.assert_awaited_once_with(slot, 501)
initiate_kwargs = provider.initiate_call.await_args.kwargs
assert initiate_kwargs["workflow_id"] == workflow.id
assert initiate_kwargs["user_id"] == workflow.user_id
assert "user_id=99" in initiate_kwargs["webhook_url"]
mock_db.get_user_configurations.assert_not_called()
def test_initiate_call_uses_organization_preference_phone_number():
app = _make_test_app()
client = TestClient(app)
workflow = _workflow()
provider = _provider()
quota_mock = AsyncMock(
return_value=SimpleNamespace(has_quota=True, error_message="")
)
with (
patch("api.routes.telephony.db_client") as mock_db,
patch("api.routes.telephony.call_concurrency") as mock_concurrency,
patch(
"api.routes.telephony.authorize_workflow_run_start",
new=quota_mock,
),
patch(
"api.routes.telephony.get_default_telephony_provider",
new=AsyncMock(return_value=provider),
),
patch(
"api.routes.telephony.get_backend_endpoints",
new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")),
),
):
mock_concurrency.acquire_org_slot = AsyncMock(return_value=object())
mock_concurrency.bind_workflow_run = AsyncMock()
mock_concurrency.release_slot = AsyncMock()
mock_concurrency.release_workflow_run_slot = AsyncMock()
mock_db.get_user_configurations = AsyncMock(
return_value=SimpleNamespace(test_phone_number="+15550000000")
)
mock_db.get_configuration = Mock(
return_value=SimpleNamespace(value={"test_phone_number": "+15557654321"})
)
mock_db.get_default_telephony_configuration = AsyncMock(
return_value=SimpleNamespace(id=55)
)
mock_db.get_workflow = AsyncMock(return_value=workflow)
mock_db.create_workflow_run = AsyncMock(
return_value=SimpleNamespace(
id=501,
name="WR-TEL-OUT-00000001",
initial_context={},
)
)
mock_db.update_workflow_run = AsyncMock()
response = client.post(
"/telephony/initiate-call",
json={"workflow_id": workflow.id},
)
assert response.status_code == 200
assert provider.initiate_call.await_args.kwargs["to_number"] == "+15557654321"
mock_db.get_user_configurations.assert_not_called()
def test_initiate_call_rejects_existing_run_for_different_workflow():
app = _make_test_app()
client = TestClient(app)
workflow = _workflow()
provider = _provider()
quota_mock = AsyncMock(
return_value=SimpleNamespace(has_quota=True, error_message="")
)
with (
patch("api.routes.telephony.db_client") as mock_db,
patch("api.routes.telephony.call_concurrency") as mock_concurrency,
patch(
"api.routes.telephony.authorize_workflow_run_start",
new=quota_mock,
),
patch(
"api.routes.telephony.get_default_telephony_provider",
new=AsyncMock(return_value=provider),
),
):
mock_concurrency.acquire_org_slot = AsyncMock(return_value=object())
mock_concurrency.bind_workflow_run = AsyncMock()
mock_concurrency.release_slot = AsyncMock()
mock_concurrency.release_workflow_run_slot = AsyncMock()
mock_db.get_user_configurations = AsyncMock(
return_value=SimpleNamespace(test_phone_number=None)
)
mock_db.get_default_telephony_configuration = AsyncMock(
return_value=SimpleNamespace(id=55)
)
mock_db.get_workflow = AsyncMock(return_value=workflow)
mock_db.get_workflow_run = AsyncMock(
return_value=SimpleNamespace(
id=501,
workflow_id=44,
name="WR-TEL-OUT-00000044",
initial_context={},
)
)
response = client.post(
"/telephony/initiate-call",
json={
"workflow_id": workflow.id,
"workflow_run_id": 501,
"phone_number": "+15551234567",
},
)
assert response.status_code == 400
assert response.json()["detail"] == "workflow_run_workflow_mismatch"
mock_db.get_workflow_run.assert_awaited_once_with(501, organization_id=11)
mock_concurrency.release_slot.assert_awaited_once()
assert not mock_db.create_workflow_run.called
assert provider.initiate_call.await_count == 0
def test_initiate_call_rejects_when_concurrency_limit_reached():
app = _make_test_app()
client = TestClient(app)
workflow = _workflow()
provider = _provider()
with (
patch("api.routes.telephony.db_client") as mock_db,
patch("api.routes.telephony.call_concurrency") as mock_concurrency,
patch(
"api.routes.telephony.get_default_telephony_provider",
new=AsyncMock(return_value=provider),
),
):
mock_concurrency.acquire_org_slot = AsyncMock(
side_effect=CallConcurrencyLimitError(
organization_id=workflow.organization_id,
source="telephony_outbound",
wait_time=0,
max_concurrent=1,
)
)
mock_db.get_default_telephony_configuration = AsyncMock(
return_value=SimpleNamespace(id=55)
)
mock_db.get_workflow = AsyncMock(return_value=workflow)
mock_db.create_workflow_run = AsyncMock()
response = client.post(
"/telephony/initiate-call",
json={"workflow_id": workflow.id, "phone_number": "+15551234567"},
)
assert response.status_code == 429
assert response.json()["detail"] == "Concurrent call limit reached"
mock_db.create_workflow_run.assert_not_called()
provider.initiate_call.assert_not_awaited()
@pytest.mark.asyncio
async def test_inbound_run_rejects_when_concurrency_limit_reached():
request = SimpleNamespace(headers={}, url="https://api.example.com/inbound/run")
provider_class = SimpleNamespace(
PROVIDER_NAME="twilio",
generate_validation_error_response=Mock(return_value="limit-response"),
)
normalized_data = SimpleNamespace(
provider="twilio",
direction="inbound",
to_number="+15551230000",
from_number="+15557650000",
to_country="US",
from_country="US",
account_id="acct-1",
call_id="call-1",
raw_data={},
)
config = SimpleNamespace(id=55, organization_id=11)
phone_row = SimpleNamespace(id=77, inbound_workflow_id=33)
workflow = SimpleNamespace(id=33, user_id=99)
provider_instance = SimpleNamespace(
verify_inbound_signature=AsyncMock(return_value=True)
)
with (
patch(
"api.routes.telephony.parse_webhook_request",
new=AsyncMock(return_value=({}, "raw-body")),
),
patch(
"api.routes.telephony._detect_provider",
new=AsyncMock(return_value=provider_class),
),
patch(
"api.routes.telephony.normalize_webhook_data",
return_value=normalized_data,
),
patch("api.routes.telephony.db_client") as mock_db,
patch(
"api.routes.telephony.get_telephony_provider_by_id",
new=AsyncMock(return_value=provider_instance),
),
patch("api.routes.telephony.call_concurrency") as mock_concurrency,
):
mock_db.find_inbound_route_by_account = AsyncMock(
return_value=(config, phone_row)
)
mock_db.get_workflow = AsyncMock(return_value=workflow)
mock_db.create_workflow_run = AsyncMock()
mock_concurrency.acquire_org_slot = AsyncMock(
side_effect=CallConcurrencyLimitError(
organization_id=config.organization_id,
source="inbound:twilio",
wait_time=0,
max_concurrent=1,
)
)
response = await handle_inbound_run(request)
assert response == "limit-response"
provider_class.generate_validation_error_response.assert_called_once_with(
TelephonyError.CONCURRENT_CALL_LIMIT
)
mock_db.create_workflow_run.assert_not_awaited()
@pytest.mark.asyncio
async def test_smallwebrtc_run_reaching_telephony_websocket_closes_without_running():
websocket = AsyncMock()
workflow_run = SimpleNamespace(
id=501,
workflow_id=33,
mode=WorkflowRunMode.SMALLWEBRTC.value,
state=WorkflowRunState.INITIALIZED.value,
initial_context={},
gathered_context={},
)
workflow = SimpleNamespace(id=33, organization_id=11, user_id=99)
provider_lookup = AsyncMock()
with (
patch("api.routes.telephony.db_client") as mock_db,
patch("api.routes.telephony.call_concurrency") as mock_concurrency,
patch(
"api.routes.telephony.get_telephony_provider_for_run",
new=provider_lookup,
),
):
mock_concurrency.unregister_active_call = AsyncMock()
mock_db.get_workflow_run = AsyncMock(return_value=workflow_run)
mock_db.get_workflow_by_id = AsyncMock(return_value=workflow)
mock_db.update_workflow_run = AsyncMock()
await _handle_telephony_websocket(websocket, 33, 99, 501)
websocket.close.assert_awaited_once_with(
code=4400,
reason=(
"smallwebrtc runs connect through the WebRTC signaling endpoint, "
"not the telephony websocket"
),
)
assert mock_db.update_workflow_run.await_count == 0
assert provider_lookup.await_count == 0
mock_concurrency.unregister_active_call.assert_not_awaited()