mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
Registers a new `three_cx` provider that fronts a 3CX cloud PBX through an intermediate Asterisk bridge. Save-time hook writes the matching PJSIP endpoint/aor/auth/registration and dialplan rows to the Asterisk Realtime Architecture Postgres (via `ASTERISK_ARA_DSN`), so a config change in the Dograh UI is immediately picked up by Asterisk without a `pjsip reload`. Strip prefix is honoured at the dialplan layer. Inbound calls are matched back to a configuration by the dialled extension (`account_id_credential_field="extension"`), allowing one shared Asterisk to serve multiple Dograh orgs without collision. Touches `providers/__init__.py` and `schemas/telephony_config.py` only — per `providers/AGENTS.md`. Provider/transport/strategies are duplicated from `ari/` rather than imported, in line with the cross-provider-import prohibition. See `docs/providers/three_cx.md` for the Asterisk ARA setup runbook. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
367 lines
12 KiB
Python
367 lines
12 KiB
Python
"""Unit tests for the 3CX telephony provider.
|
|
|
|
Scope:
|
|
* Config schemas (validators, mask/unmask roundtrip via discriminated union)
|
|
* Pure-Python helpers (endpoint_id, dialplan row generation)
|
|
* Provider methods that don't need a transport (validate_config,
|
|
parse_inbound_webhook, validate_account_id)
|
|
* Provisioning hook with mocked asyncpg pool — no real Postgres
|
|
* SPEC wiring (preprocessor + account_id field)
|
|
|
|
These tests deliberately use no DB fixtures, so they don't trigger the
|
|
session-scoped test-database setup in ``api/conftest.py``. They still
|
|
require ``api/.env.test`` to define ``DATABASE_URL`` and ``REDIS_URL``,
|
|
because the root conftest reads ``api.constants`` at import time.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from api.schemas.telephony_config import (
|
|
TelephonyConfigurationResponse,
|
|
ThreeCxConfigurationRequest,
|
|
ThreeCxConfigurationResponse,
|
|
)
|
|
from api.services.telephony import registry
|
|
from api.services.telephony.providers.three_cx import SPEC
|
|
from api.services.telephony.providers.three_cx.dialplan import (
|
|
_prefix_to_pattern,
|
|
build_dialplan_rows,
|
|
outbound_context_for,
|
|
)
|
|
from api.services.telephony.providers.three_cx.provider import ThreeCxProvider
|
|
from api.services.telephony.providers.three_cx.provisioning import (
|
|
_provision_3cx_trunk,
|
|
endpoint_id_for,
|
|
)
|
|
|
|
_FULL_CREDS = {
|
|
"ari_endpoint": "http://asterisk.example.com:8088",
|
|
"app_name": "dograh",
|
|
"app_password": "secret",
|
|
"ws_client_name": "dograh_staging",
|
|
"sip_domain": "1156.3cx.cloud",
|
|
"extension": "12611",
|
|
"sip_password": "3cx-sip-secret",
|
|
"strip_prefix": "^\\+39",
|
|
"from_numbers": ["+393331112222"],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# endpoint_id_for
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_endpoint_id_for_italian_3cx_tenant():
|
|
assert endpoint_id_for("1156.3cx.cloud", "12611") == "dograh_1156_3cx_cloud_12611"
|
|
|
|
|
|
def test_endpoint_id_for_normalizes_uppercase_and_dots():
|
|
assert (
|
|
endpoint_id_for("ACME.PBX.3CX.cloud", "200")
|
|
== "dograh_acme_pbx_3cx_cloud_200"
|
|
)
|
|
|
|
|
|
def test_endpoint_id_for_collapses_runs_of_separators():
|
|
assert endpoint_id_for("foo..bar--baz", "9") == "dograh_foo_bar_baz_9"
|
|
|
|
|
|
def test_endpoint_id_for_rejects_empty_sip_domain():
|
|
with pytest.raises(ValueError):
|
|
endpoint_id_for("", "12611")
|
|
|
|
|
|
def test_endpoint_id_for_rejects_empty_extension():
|
|
with pytest.raises(ValueError):
|
|
endpoint_id_for("1156.3cx.cloud", "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# dialplan
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_prefix_to_pattern_italian():
|
|
pattern, skip = _prefix_to_pattern("^\\+39")
|
|
assert pattern == "_+39N."
|
|
assert skip == 3 # '+39' is 3 characters
|
|
|
|
|
|
def test_prefix_to_pattern_empty_falls_back_to_match_all():
|
|
pattern, skip = _prefix_to_pattern("")
|
|
assert pattern == "_X."
|
|
assert skip == 0
|
|
|
|
|
|
def test_prefix_to_pattern_unsupported_regex_raises():
|
|
with pytest.raises(ValueError):
|
|
_prefix_to_pattern("^\\+[0-9]{2}")
|
|
|
|
|
|
def test_build_dialplan_rows_outbound_dials_into_pjsip_endpoint_with_skip():
|
|
rows = build_dialplan_rows(
|
|
endpoint_id="dograh_1156_3cx_cloud_12611",
|
|
extension="12611",
|
|
stasis_app="dograh",
|
|
strip_prefix="^\\+39",
|
|
)
|
|
outbound = next(r for r in rows if r["context"].endswith("-outbound"))
|
|
assert outbound["app"] == "Dial"
|
|
assert outbound["exten"] == "_+39N."
|
|
assert outbound["appdata"] == "PJSIP/${EXTEN:3}@dograh_1156_3cx_cloud_12611,60"
|
|
|
|
|
|
def test_build_dialplan_rows_inbound_routes_extension_and_wildcard_to_stasis():
|
|
rows = build_dialplan_rows(
|
|
endpoint_id="dograh_1156_3cx_cloud_12611",
|
|
extension="12611",
|
|
stasis_app="dograh",
|
|
strip_prefix="",
|
|
)
|
|
inbound = [r for r in rows if r["context"].endswith("-inbound")]
|
|
extens = {r["exten"] for r in inbound}
|
|
assert extens == {"12611", "_X."}
|
|
for r in inbound:
|
|
assert r["app"] == "Stasis"
|
|
assert r["appdata"].startswith("dograh,inbound,")
|
|
|
|
|
|
def test_outbound_context_for_matches_dialplan_naming():
|
|
rows = build_dialplan_rows(
|
|
endpoint_id="ep1",
|
|
extension="10",
|
|
stasis_app="dograh",
|
|
strip_prefix="",
|
|
)
|
|
outbound = next(r for r in rows if r["app"] == "Dial")
|
|
assert outbound["context"] == outbound_context_for("ep1")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_config_request_validators_strip_and_lowercase_sip_domain():
|
|
req = ThreeCxConfigurationRequest(
|
|
ari_endpoint="http://asterisk:8088",
|
|
app_name="dograh",
|
|
app_password="x",
|
|
sip_domain=" 1156.3CX.Cloud ",
|
|
extension=" 12611 ",
|
|
sip_password="y",
|
|
)
|
|
assert req.sip_domain == "1156.3cx.cloud"
|
|
assert req.extension == "12611"
|
|
|
|
|
|
def test_config_request_provider_literal_defaults_to_three_cx():
|
|
req = ThreeCxConfigurationRequest(
|
|
ari_endpoint="x",
|
|
app_name="x",
|
|
app_password="x",
|
|
sip_domain="1156.3cx.cloud",
|
|
extension="12611",
|
|
sip_password="x",
|
|
)
|
|
assert req.provider == "three_cx"
|
|
|
|
|
|
def test_telephony_config_response_can_carry_three_cx():
|
|
"""The top-level response model must expose a `three_cx` slot."""
|
|
resp = TelephonyConfigurationResponse(
|
|
three_cx=ThreeCxConfigurationResponse(
|
|
ari_endpoint="x",
|
|
app_name="dograh",
|
|
app_password="***", # already masked by caller
|
|
sip_domain="1156.3cx.cloud",
|
|
extension="12611",
|
|
sip_password="***", # already masked by caller
|
|
from_numbers=["+393331112222"],
|
|
)
|
|
)
|
|
assert resp.three_cx is not None
|
|
assert resp.three_cx.app_password == "***"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provider
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_provider_validate_config_with_full_data():
|
|
p = ThreeCxProvider(_FULL_CREDS)
|
|
assert p.validate_config() is True
|
|
|
|
|
|
def test_provider_validate_config_missing_ari_endpoint_is_false():
|
|
creds = {**_FULL_CREDS, "ari_endpoint": ""}
|
|
assert ThreeCxProvider(creds).validate_config() is False
|
|
|
|
|
|
def test_provider_parse_inbound_webhook_populates_account_id_from_extension():
|
|
webhook = {
|
|
"channel": {
|
|
"id": "ch-1",
|
|
"state": "Ringing",
|
|
"caller": {"number": "+393331112222"},
|
|
"dialplan": {"exten": "12611"},
|
|
}
|
|
}
|
|
n = ThreeCxProvider.parse_inbound_webhook(webhook)
|
|
assert n.provider == "three_cx"
|
|
assert n.to_number == "12611"
|
|
assert n.account_id == "12611"
|
|
assert n.from_number == "+393331112222"
|
|
|
|
|
|
def test_provider_parse_inbound_webhook_uses_none_for_missing_extension():
|
|
n = ThreeCxProvider.parse_inbound_webhook({"channel": {}})
|
|
assert n.account_id is None
|
|
|
|
|
|
def test_provider_validate_account_id_matches_extension():
|
|
assert ThreeCxProvider.validate_account_id({"extension": "12611"}, "12611") is True
|
|
|
|
|
|
def test_provider_validate_account_id_rejects_wrong_extension():
|
|
assert ThreeCxProvider.validate_account_id({"extension": "12611"}, "9999") is False
|
|
|
|
|
|
def test_provider_validate_account_id_rejects_missing_config_extension():
|
|
assert ThreeCxProvider.validate_account_id({}, "12611") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SPEC registration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_spec_registered_with_account_id_extension_and_preprocessor():
|
|
spec = registry.get("three_cx")
|
|
assert spec is SPEC
|
|
assert spec.account_id_credential_field == "extension"
|
|
assert spec.preprocess_credentials_on_save is not None
|
|
assert spec.transport_sample_rate == 8000
|
|
|
|
|
|
def test_spec_ui_metadata_marks_passwords_sensitive():
|
|
by_name = {f.name: f for f in SPEC.ui_metadata.fields}
|
|
assert by_name["app_password"].sensitive is True
|
|
assert by_name["sip_password"].sensitive is True
|
|
# Non-secret fields should NOT be marked sensitive.
|
|
assert by_name["sip_domain"].sensitive is False
|
|
assert by_name["extension"].sensitive is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provisioning (mocked asyncpg)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_mock_pool():
|
|
"""Build a mock asyncpg pool whose ``acquire()`` yields a recording conn."""
|
|
conn = MagicMock()
|
|
conn.execute = AsyncMock(return_value="OK")
|
|
|
|
tx_ctx = MagicMock()
|
|
tx_ctx.__aenter__ = AsyncMock(return_value=None)
|
|
tx_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
conn.transaction = MagicMock(return_value=tx_ctx)
|
|
|
|
acquire_ctx = MagicMock()
|
|
acquire_ctx.__aenter__ = AsyncMock(return_value=conn)
|
|
acquire_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
pool = MagicMock()
|
|
pool.acquire = MagicMock(return_value=acquire_ctx)
|
|
return pool, conn
|
|
|
|
|
|
@patch(
|
|
"api.services.telephony.providers.three_cx.provisioning.get_pool",
|
|
new_callable=AsyncMock,
|
|
)
|
|
async def test_provision_writes_six_table_set_in_single_transaction(get_pool_mock):
|
|
pool, conn = _make_mock_pool()
|
|
get_pool_mock.return_value = pool
|
|
|
|
out = await _provision_3cx_trunk(dict(_FULL_CREDS))
|
|
|
|
# Returns the credentials unchanged — endpoint_id is rederived at runtime.
|
|
assert out == _FULL_CREDS
|
|
|
|
statements = [call.args[0] for call in conn.execute.await_args_list]
|
|
# Idempotency deletes come first (5 statements covering 4 ps_* + extensions).
|
|
assert sum(1 for s in statements if s.lstrip().startswith("DELETE")) == 5
|
|
# Then one INSERT per ps_* table + one INSERT per dialplan row (3 rows).
|
|
inserts = [s for s in statements if "INSERT" in s]
|
|
assert any("ps_auths" in s for s in inserts)
|
|
assert any("ps_aors" in s for s in inserts)
|
|
assert any("ps_endpoints" in s for s in inserts)
|
|
assert any("ps_registrations" in s for s in inserts)
|
|
assert sum(1 for s in inserts if "INTO extensions" in s) == 3
|
|
# All inserts must happen inside one transaction context.
|
|
assert conn.transaction.call_count == 1
|
|
|
|
|
|
@patch(
|
|
"api.services.telephony.providers.three_cx.provisioning.get_pool",
|
|
new_callable=AsyncMock,
|
|
)
|
|
async def test_provision_is_idempotent_on_resave(get_pool_mock):
|
|
pool, conn = _make_mock_pool()
|
|
get_pool_mock.return_value = pool
|
|
|
|
await _provision_3cx_trunk(dict(_FULL_CREDS))
|
|
first_call_count = conn.execute.await_count
|
|
|
|
await _provision_3cx_trunk(dict(_FULL_CREDS))
|
|
# Second call performs the same delete-then-insert work.
|
|
assert conn.execute.await_count == 2 * first_call_count
|
|
|
|
|
|
async def test_provision_raises_400_on_missing_required_field():
|
|
bad = {**_FULL_CREDS}
|
|
bad.pop("extension")
|
|
with pytest.raises(HTTPException) as exc:
|
|
await _provision_3cx_trunk(bad)
|
|
assert exc.value.status_code == 400
|
|
assert "extension" in exc.value.detail
|
|
|
|
|
|
@patch(
|
|
"api.services.telephony.providers.three_cx.provisioning.get_pool",
|
|
new_callable=AsyncMock,
|
|
)
|
|
async def test_provision_translates_ara_not_configured_to_400(get_pool_mock):
|
|
from api.services.telephony.providers.three_cx.ara_db import (
|
|
AraNotConfiguredError,
|
|
)
|
|
|
|
get_pool_mock.side_effect = AraNotConfiguredError("ASTERISK_ARA_DSN not set")
|
|
with pytest.raises(HTTPException) as exc:
|
|
await _provision_3cx_trunk(dict(_FULL_CREDS))
|
|
assert exc.value.status_code == 400
|
|
assert "ASTERISK_ARA_DSN" in exc.value.detail
|
|
|
|
|
|
@patch(
|
|
"api.services.telephony.providers.three_cx.provisioning.get_pool",
|
|
new_callable=AsyncMock,
|
|
)
|
|
async def test_provision_translates_db_error_to_502(get_pool_mock):
|
|
pool, conn = _make_mock_pool()
|
|
conn.execute = AsyncMock(side_effect=RuntimeError("relation \"ps_auths\" does not exist"))
|
|
get_pool_mock.return_value = pool
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await _provision_3cx_trunk(dict(_FULL_CREDS))
|
|
assert exc.value.status_code == 502
|
|
assert "ps_auths" in exc.value.detail
|