"""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