feat: add call history API endpoints and TTS service client

Adds read-only access to persisted call records for the dashboard
and implements a client for the Rhema text-to-speech service.

- api/call_history.py: New router providing paged call lists
  and detailed call records with transcript metadata.
- services/tts.py: Async client for OpenAI-compatible TTS
  endpoints (Rhema/Kokoro) used for call-flow steps.
This commit is contained in:
2026-05-22 06:28:33 -04:00
parent dbdb03beb9
commit 63f1a270bb
28 changed files with 2275 additions and 11 deletions

133
tests/test_routing.py Normal file
View File

@@ -0,0 +1,133 @@
"""Tests for the routing evaluator (services/routing.py)."""
from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
from models.routing import (
RoutingAction,
RoutingActionType,
RoutingMatch,
RoutingRule,
TimeRange,
)
from services.routing import RoutingService
def _rule(
name: str,
*,
priority: int = 100,
enabled: bool = True,
caller: str | None = None,
dnis: str | None = None,
time_range: TimeRange | None = None,
action_type: RoutingActionType = RoutingActionType.RING_CHAIN,
device_id: str | None = None,
) -> RoutingRule:
return RoutingRule(
id=f"rule_{name}",
name=name,
priority=priority,
enabled=enabled,
match=RoutingMatch(caller_pattern=caller, dnis=dnis, time_range=time_range),
action=RoutingAction(type=action_type, device_id=device_id),
)
class TestRoutingEvaluator:
def _make_service(self, rules):
svc = RoutingService(gateway=None)
svc._rules = rules
return svc
@pytest.mark.asyncio
async def test_no_rules_returns_default_take_message(self):
svc = self._make_service([])
decision = await svc.evaluate("+15551112222", "+15553334444")
assert decision.action.type == RoutingActionType.TAKE_MESSAGE
assert decision.matched_rule_id is None
@pytest.mark.asyncio
async def test_priority_lower_wins(self):
svc = self._make_service([
_rule("low", priority=10, action_type=RoutingActionType.RING_DEVICE,
device_id="dev_a"),
_rule("high", priority=200, action_type=RoutingActionType.REJECT),
])
decision = await svc.evaluate("+15551112222", "+15553334444")
assert decision.matched_rule_name == "low"
assert decision.action.type == RoutingActionType.RING_DEVICE
@pytest.mark.asyncio
async def test_disabled_rule_skipped(self):
svc = self._make_service([
_rule("first", priority=10, enabled=False,
action_type=RoutingActionType.REJECT),
_rule("second", priority=20,
action_type=RoutingActionType.RING_CHAIN),
])
decision = await svc.evaluate("+1", "+2")
assert decision.matched_rule_name == "second"
@pytest.mark.asyncio
async def test_caller_glob_pattern(self):
svc = self._make_service([
_rule("tollfree", caller="+1800*",
action_type=RoutingActionType.REJECT),
])
toll = await svc.evaluate("+18001234567", "+15551112222")
assert toll.action.type == RoutingActionType.REJECT
normal = await svc.evaluate("+14155551212", "+15551112222")
assert normal.action.type == RoutingActionType.TAKE_MESSAGE
@pytest.mark.asyncio
async def test_dnis_must_match_exactly(self):
svc = self._make_service([
_rule("by_did", dnis="+15553334444",
action_type=RoutingActionType.RING_CHAIN),
])
match = await svc.evaluate("+1", "+15553334444")
assert match.matched_rule_name == "by_did"
miss = await svc.evaluate("+1", "+19998887777")
assert miss.matched_rule_name is None
@pytest.mark.asyncio
async def test_time_range_in_window(self):
# Mon 10:30 UTC is inside Mon-Fri 09:00-17:00 UTC
tr = TimeRange(start="09:00", end="17:00", tz="UTC", days=[0, 1, 2, 3, 4])
svc = self._make_service([
_rule("business_hours", time_range=tr,
action_type=RoutingActionType.RING_CHAIN),
])
monday_10_30 = datetime(2026, 1, 5, 10, 30, tzinfo=ZoneInfo("UTC"))
decision = await svc.evaluate("+1", "+2", now=monday_10_30)
assert decision.matched_rule_name == "business_hours"
@pytest.mark.asyncio
async def test_time_range_outside_window(self):
tr = TimeRange(start="09:00", end="17:00", tz="UTC", days=[0, 1, 2, 3, 4])
svc = self._make_service([
_rule("business_hours", time_range=tr,
action_type=RoutingActionType.RING_CHAIN),
])
saturday_noon = datetime(2026, 1, 10, 12, 0, tzinfo=ZoneInfo("UTC"))
decision = await svc.evaluate("+1", "+2", now=saturday_noon)
# Weekend → no match → default take_message
assert decision.matched_rule_name is None
@pytest.mark.asyncio
async def test_time_range_wraps_midnight(self):
# Overnight 22:00 → 06:00, every day
tr = TimeRange(start="22:00", end="06:00", tz="UTC", days=list(range(7)))
svc = self._make_service([
_rule("overnight", time_range=tr,
action_type=RoutingActionType.REJECT),
])
late = datetime(2026, 1, 5, 23, 30, tzinfo=ZoneInfo("UTC"))
early = datetime(2026, 1, 6, 4, 0, tzinfo=ZoneInfo("UTC"))
mid_day = datetime(2026, 1, 5, 12, 0, tzinfo=ZoneInfo("UTC"))
assert (await svc.evaluate("+1", "+2", now=late)).matched_rule_name == "overnight"
assert (await svc.evaluate("+1", "+2", now=early)).matched_rule_name == "overnight"
assert (await svc.evaluate("+1", "+2", now=mid_day)).matched_rule_name is None