Co-authored-by: Jack Hunzicker <JackHunzicker@users.noreply.github.com>
613 lines
24 KiB
Python
613 lines
24 KiB
Python
"""Tests for the phone platform adapter's pure helpers + standalone sender.
|
|
|
|
The adapter's :class:`PhoneAdapter` binds to ``gateway.platforms.base`` which
|
|
is only importable inside the live hermes-agent process, so these tests cover
|
|
the gateway-independent surface:
|
|
|
|
* env-driven gating (``check_requirements`` / ``validate_config`` /
|
|
``is_connected`` / ``_phone_enabled``)
|
|
* relay URL resolution precedence (``_relay_base_url``)
|
|
* payload construction (``_build_message_payload`` — truncation,
|
|
surfacing/title lifting, home-channel defaults)
|
|
* ``_env_enablement`` seeding
|
|
* ``_standalone_send`` (the out-of-process cron / send_message path) over a
|
|
fake httpx client — success, no-phone (503), disabled, and error shapes.
|
|
* inbound reply helpers (Phase 2c): ``_replies_url_and_headers`` and
|
|
``_normalize_reply`` (chat_id default, empty-text/non-dict skip, id synth).
|
|
|
|
Runs under plain ``unittest`` (no pytest, no ``responses``) to skip the
|
|
repo's ``conftest.py``. Run with::
|
|
|
|
python -m unittest plugin.tests.test_phone_platform
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
import types
|
|
import unittest
|
|
from typing import Any, Optional
|
|
from unittest.mock import patch
|
|
|
|
from plugin import phone_platform as pp
|
|
|
|
|
|
def _run(coro):
|
|
"""Drive a coroutine to completion on a fresh event loop."""
|
|
return asyncio.new_event_loop().run_until_complete(coro)
|
|
|
|
|
|
# ── Fake httpx surface ────────────────────────────────────────────────────
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, status_code: int, payload: Optional[dict] = None, text: str = ""):
|
|
self.status_code = status_code
|
|
self._payload = payload
|
|
self.text = text
|
|
|
|
def json(self) -> Any:
|
|
if self._payload is None:
|
|
raise ValueError("no json")
|
|
return self._payload
|
|
|
|
|
|
class _FakeAsyncClient:
|
|
"""Records the single POST and returns a pre-seeded response.
|
|
|
|
Supports both the ``async with httpx.AsyncClient(...) as c`` form used by
|
|
``_standalone_send`` and direct construction.
|
|
"""
|
|
|
|
last_call: dict[str, Any] = {}
|
|
call_count = 0
|
|
|
|
def __init__(self, response: _FakeResponse, *, raises: Optional[Exception] = None):
|
|
self._response = response
|
|
self._raises = raises
|
|
|
|
async def __aenter__(self) -> "_FakeAsyncClient":
|
|
return self
|
|
|
|
async def __aexit__(self, *exc) -> None:
|
|
return None
|
|
|
|
async def post(self, url, json=None, headers=None, timeout=None) -> _FakeResponse:
|
|
type(self).call_count += 1
|
|
type(self).last_call = {
|
|
"url": url,
|
|
"json": json,
|
|
"headers": headers,
|
|
"timeout": timeout,
|
|
}
|
|
if self._raises is not None:
|
|
raise self._raises
|
|
return self._response
|
|
|
|
|
|
class _FakeHttpx:
|
|
"""Stand-in for the ``httpx`` module exposing ``AsyncClient``."""
|
|
|
|
def __init__(self, response: _FakeResponse, *, raises: Optional[Exception] = None):
|
|
self._response = response
|
|
self._raises = raises
|
|
|
|
def AsyncClient(self, *args, **kwargs) -> _FakeAsyncClient: # noqa: N802 - mimic httpx
|
|
return _FakeAsyncClient(self._response, raises=self._raises)
|
|
|
|
|
|
_ENV_KEYS = (
|
|
"PHONE_ENABLED",
|
|
"PHONE_RELAY_URL",
|
|
"PHONE_RELAY_TOKEN",
|
|
"PHONE_HOME_CHANNEL",
|
|
"PHONE_HOME_CHANNEL_NAME",
|
|
"ANDROID_BRIDGE_URL",
|
|
"ANDROID_RELAY_PORT",
|
|
"RELAY_PORT",
|
|
)
|
|
|
|
|
|
class _EnvIsolated(unittest.TestCase):
|
|
"""Base that snapshots + restores the env keys the adapter reads."""
|
|
|
|
def setUp(self) -> None:
|
|
self._saved = {k: os.environ.get(k) for k in _ENV_KEYS}
|
|
for k in _ENV_KEYS:
|
|
os.environ.pop(k, None)
|
|
self._saved_httpx = pp.httpx
|
|
self._saved_avail = pp.HTTPX_AVAILABLE
|
|
_FakeAsyncClient.call_count = 0
|
|
_FakeAsyncClient.last_call = {}
|
|
|
|
def tearDown(self) -> None:
|
|
for k, v in self._saved.items():
|
|
if v is None:
|
|
os.environ.pop(k, None)
|
|
else:
|
|
os.environ[k] = v
|
|
pp.httpx = self._saved_httpx
|
|
pp.HTTPX_AVAILABLE = self._saved_avail
|
|
|
|
|
|
# ── Gating ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class GatingTests(_EnvIsolated):
|
|
def test_disabled_by_default(self) -> None:
|
|
self.assertFalse(pp._phone_enabled())
|
|
self.assertFalse(pp.is_connected(object()))
|
|
|
|
def test_truthy_spellings_enable(self) -> None:
|
|
for val in ("1", "true", "TRUE", "yes", "on"):
|
|
os.environ["PHONE_ENABLED"] = val
|
|
self.assertTrue(pp._phone_enabled(), val)
|
|
|
|
def test_falsey_spellings_stay_off(self) -> None:
|
|
for val in ("0", "false", "no", "off", "", "bogus"):
|
|
os.environ["PHONE_ENABLED"] = val
|
|
self.assertFalse(pp._phone_enabled(), val)
|
|
|
|
def test_check_requirements_needs_httpx_and_enabled(self) -> None:
|
|
os.environ["PHONE_ENABLED"] = "1"
|
|
pp.HTTPX_AVAILABLE = True
|
|
self.assertTrue(pp.check_requirements())
|
|
pp.HTTPX_AVAILABLE = False
|
|
self.assertFalse(pp.check_requirements())
|
|
|
|
def test_validate_config_env_or_extra(self) -> None:
|
|
class _Cfg:
|
|
extra = {"enabled": True}
|
|
|
|
self.assertTrue(pp.validate_config(_Cfg())) # via extra
|
|
self.assertFalse(pp.validate_config(object())) # neither
|
|
os.environ["PHONE_ENABLED"] = "1"
|
|
self.assertTrue(pp.validate_config(object())) # via env
|
|
|
|
|
|
# ── URL resolution ────────────────────────────────────────────────────────
|
|
|
|
|
|
class RelayUrlTests(_EnvIsolated):
|
|
def test_default(self) -> None:
|
|
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:8767")
|
|
|
|
def test_explicit_phone_relay_url_wins(self) -> None:
|
|
os.environ["PHONE_RELAY_URL"] = "https://relay.example:9000/"
|
|
os.environ["ANDROID_BRIDGE_URL"] = "http://other:1"
|
|
self.assertEqual(pp._relay_base_url(), "https://relay.example:9000")
|
|
|
|
def test_falls_back_to_android_bridge_url(self) -> None:
|
|
os.environ["ANDROID_BRIDGE_URL"] = "http://192.168.1.5:8767/"
|
|
self.assertEqual(pp._relay_base_url(), "http://192.168.1.5:8767")
|
|
|
|
def test_preserves_explicit_loopback_overrides(self) -> None:
|
|
for key in ("PHONE_RELAY_URL", "ANDROID_BRIDGE_URL"):
|
|
for override in ("http://localhost:8767/", "http://[::1]:8767/"):
|
|
with self.subTest(key=key, override=override):
|
|
os.environ[key] = override
|
|
self.assertEqual(pp._relay_base_url(), override.rstrip("/"))
|
|
os.environ.pop(key)
|
|
|
|
def test_port_override(self) -> None:
|
|
os.environ["ANDROID_RELAY_PORT"] = "8888"
|
|
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:8888")
|
|
os.environ.pop("ANDROID_RELAY_PORT")
|
|
os.environ["RELAY_PORT"] = "7777"
|
|
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:7777")
|
|
|
|
def test_token_header_only_when_set(self) -> None:
|
|
url, headers = pp._relay_url_and_headers()
|
|
self.assertTrue(url.endswith("/phone/message"))
|
|
self.assertNotIn("Authorization", headers)
|
|
os.environ["PHONE_RELAY_TOKEN"] = "abc"
|
|
_, headers = pp._relay_url_and_headers()
|
|
self.assertEqual(headers["Authorization"], "Bearer abc")
|
|
|
|
|
|
# ── Payload building ───────────────────────────────────────────────────────
|
|
|
|
|
|
class PayloadTests(_EnvIsolated):
|
|
def test_defaults(self) -> None:
|
|
p = pp._build_message_payload(None, "hello")
|
|
self.assertEqual(p["chat_id"], "phone")
|
|
self.assertEqual(p["text"], "hello")
|
|
self.assertEqual(p["title"], "Phone")
|
|
self.assertIsNone(p["surfacing"])
|
|
self.assertEqual(p["metadata"], {})
|
|
|
|
def test_home_channel_env(self) -> None:
|
|
os.environ["PHONE_HOME_CHANNEL"] = "dev-phone"
|
|
os.environ["PHONE_HOME_CHANNEL_NAME"] = "Dev Phone"
|
|
p = pp._build_message_payload(None, "hi")
|
|
self.assertEqual(p["chat_id"], "dev-phone")
|
|
self.assertEqual(p["title"], "Dev Phone")
|
|
|
|
def test_truncation(self) -> None:
|
|
long = "x" * (pp.MAX_MESSAGE_LENGTH + 50)
|
|
p = pp._build_message_payload("c", long)
|
|
self.assertEqual(len(p["text"]), pp.MAX_MESSAGE_LENGTH)
|
|
|
|
def test_surfacing_and_title_lifted_from_metadata(self) -> None:
|
|
p = pp._build_message_payload(
|
|
"c", "hi", metadata={"surfacing": "inbox", "title": "Alert", "k": "v"}
|
|
)
|
|
self.assertEqual(p["surfacing"], "inbox")
|
|
self.assertEqual(p["title"], "Alert")
|
|
# Lifted keys removed; other metadata preserved.
|
|
self.assertEqual(p["metadata"], {"k": "v"})
|
|
|
|
def test_explicit_surfacing_arg_wins(self) -> None:
|
|
p = pp._build_message_payload(
|
|
"c", "hi", surfacing="session", metadata={"surfacing": "inbox"}
|
|
)
|
|
self.assertEqual(p["surfacing"], "session")
|
|
|
|
|
|
# ── env_enablement ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class EnvEnablementTests(_EnvIsolated):
|
|
def test_none_when_disabled(self) -> None:
|
|
self.assertIsNone(pp._env_enablement())
|
|
|
|
def test_seed_when_enabled(self) -> None:
|
|
os.environ["PHONE_ENABLED"] = "1"
|
|
os.environ["PHONE_HOME_CHANNEL"] = "myphone"
|
|
seed = pp._env_enablement()
|
|
self.assertIsNotNone(seed)
|
|
assert seed is not None
|
|
self.assertTrue(seed["enabled"])
|
|
self.assertEqual(seed["home_channel"], {"chat_id": "myphone", "name": "Phone"})
|
|
self.assertEqual(seed["relay_url"], "http://127.0.0.1:8767")
|
|
self.assertFalse(seed["typing_indicator"])
|
|
|
|
|
|
# ── standalone sender ──────────────────────────────────────────────────────
|
|
|
|
|
|
class StandaloneSendTests(_EnvIsolated):
|
|
def test_disabled_short_circuits(self) -> None:
|
|
pp.HTTPX_AVAILABLE = True
|
|
out = _run(pp._standalone_send(object(), "phone", "hi"))
|
|
self.assertIn("disabled", out["error"])
|
|
|
|
def test_httpx_missing(self) -> None:
|
|
os.environ["PHONE_ENABLED"] = "1"
|
|
pp.HTTPX_AVAILABLE = False
|
|
out = _run(pp._standalone_send(object(), "phone", "hi"))
|
|
self.assertIn("httpx", out["error"])
|
|
|
|
def test_success(self) -> None:
|
|
os.environ["PHONE_ENABLED"] = "1"
|
|
pp.HTTPX_AVAILABLE = True
|
|
pp.httpx = _FakeHttpx(_FakeResponse(200, {"delivered": True, "message_id": "m1"}))
|
|
out = _run(pp._standalone_send(object(), "phone", "hello"))
|
|
self.assertEqual(out["success"], True)
|
|
self.assertEqual(out["platform"], "phone")
|
|
self.assertEqual(out["chat_id"], "phone")
|
|
self.assertEqual(out["message_id"], "m1")
|
|
# The POST hit the right URL with the right body.
|
|
call = _FakeAsyncClient.last_call
|
|
self.assertTrue(call["url"].endswith("/phone/message"))
|
|
self.assertEqual(call["json"]["text"], "hello")
|
|
self.assertEqual(_FakeAsyncClient.call_count, 1)
|
|
|
|
def test_no_phone_503(self) -> None:
|
|
os.environ["PHONE_ENABLED"] = "1"
|
|
pp.HTTPX_AVAILABLE = True
|
|
pp.httpx = _FakeHttpx(_FakeResponse(503))
|
|
out = _run(pp._standalone_send(object(), "phone", "hello"))
|
|
self.assertIn("no phone connected", out["error"].lower())
|
|
|
|
def test_relay_unreachable(self) -> None:
|
|
os.environ["PHONE_ENABLED"] = "1"
|
|
pp.HTTPX_AVAILABLE = True
|
|
pp.httpx = _FakeHttpx(_FakeResponse(200), raises=OSError("conn refused"))
|
|
out = _run(pp._standalone_send(object(), "phone", "hello"))
|
|
self.assertIn("unreachable", out["error"])
|
|
|
|
|
|
# ── Inbound reply helpers (Phase 2c) ───────────────────────────────────────
|
|
|
|
|
|
class RepliesUrlTests(_EnvIsolated):
|
|
def test_url_targets_replies_route(self) -> None:
|
|
url, headers = pp._replies_url_and_headers()
|
|
self.assertTrue(url.endswith("/phone/replies"))
|
|
# GET — no Content-Type, and no auth header without a token.
|
|
self.assertNotIn("Content-Type", headers)
|
|
self.assertNotIn("Authorization", headers)
|
|
|
|
def test_url_honors_relay_base_override(self) -> None:
|
|
os.environ["PHONE_RELAY_URL"] = "http://relay.local:9000/"
|
|
url, _ = pp._replies_url_and_headers()
|
|
self.assertEqual(url, "http://relay.local:9000/phone/replies")
|
|
|
|
def test_token_header_only_when_set(self) -> None:
|
|
os.environ["PHONE_RELAY_TOKEN"] = "secret"
|
|
_, headers = pp._replies_url_and_headers()
|
|
self.assertEqual(headers["Authorization"], "Bearer secret")
|
|
|
|
|
|
class NormalizeReplyTests(_EnvIsolated):
|
|
def test_valid_reply(self) -> None:
|
|
out = pp._normalize_reply(
|
|
{
|
|
"text": "on my way",
|
|
"chat_id": "phone",
|
|
"reply_to": "m-1",
|
|
"message_id": "r-1",
|
|
"metadata": {"source": "notification", "priority": "high"},
|
|
},
|
|
"home",
|
|
)
|
|
assert out is not None
|
|
self.assertEqual(out["text"], "on my way")
|
|
self.assertEqual(out["chat_id"], "phone")
|
|
self.assertEqual(out["reply_to"], "m-1")
|
|
self.assertEqual(out["message_id"], "r-1")
|
|
self.assertEqual(out["metadata"], {"source": "notification", "priority": "high"})
|
|
|
|
def test_reply_metadata_defaults_to_empty_dict(self) -> None:
|
|
out = pp._normalize_reply({"text": "hi", "metadata": "bad"}, "home")
|
|
assert out is not None
|
|
self.assertEqual(out["metadata"], {})
|
|
|
|
def test_reply_metadata_is_sanitized(self) -> None:
|
|
out = pp._normalize_reply(
|
|
{
|
|
"text": "hi",
|
|
"metadata": {
|
|
"safe": "x",
|
|
"items": ["a", 1, {"drop": "nested"}],
|
|
"nested": {"drop": "dict"},
|
|
"long": "x" * 600,
|
|
},
|
|
},
|
|
"home",
|
|
)
|
|
assert out is not None
|
|
self.assertEqual(out["metadata"]["safe"], "x")
|
|
self.assertEqual(out["metadata"]["items"], ["a", 1])
|
|
self.assertNotIn("nested", out["metadata"])
|
|
self.assertEqual(len(out["metadata"]["long"]), 512)
|
|
|
|
def test_chat_id_defaults_to_home(self) -> None:
|
|
out = pp._normalize_reply({"text": "hi"}, "home-chan")
|
|
assert out is not None
|
|
self.assertEqual(out["chat_id"], "home-chan")
|
|
self.assertIsNone(out["reply_to"])
|
|
self.assertTrue(out["message_id"]) # synthesized
|
|
|
|
def test_empty_text_skipped(self) -> None:
|
|
self.assertIsNone(pp._normalize_reply({"text": " "}, "home"))
|
|
self.assertIsNone(pp._normalize_reply({}, "home"))
|
|
self.assertIsNone(pp._normalize_reply({"text": None}, "home"))
|
|
|
|
def test_non_dict_skipped(self) -> None:
|
|
self.assertIsNone(pp._normalize_reply(None, "home"))
|
|
self.assertIsNone(pp._normalize_reply("nope", "home"))
|
|
self.assertIsNone(pp._normalize_reply(["x"], "home"))
|
|
|
|
|
|
# ── Adapter ↔ gateway connect contract (Phase 2c regression) ───────────────
|
|
|
|
|
|
class ConnectContractTests(unittest.TestCase):
|
|
"""Guard the gateway's ``connect(is_reconnect=...)`` call contract.
|
|
|
|
The gateway supervisor calls ``adapter.connect(is_reconnect=is_reconnect)``
|
|
(``BasePlatformAdapter.connect`` / ``gateway/run.py``). When
|
|
``PhoneAdapter.connect`` was declared ``connect(self)``, that call raised
|
|
``TypeError`` at connect time — the adapter never came up and the inbound
|
|
reply loop never started, so the two-way reply round-trip silently died
|
|
with the user's reply stuck buffered in the relay. The live ``PhoneAdapter``
|
|
binds to the gateway base class (absent here), so we assert via ``inspect``
|
|
rather than instantiating it.
|
|
"""
|
|
|
|
def test_connect_accepts_is_reconnect_keyword(self) -> None:
|
|
import inspect
|
|
|
|
sig = inspect.signature(pp.PhoneAdapter.connect)
|
|
self.assertIn(
|
|
"is_reconnect",
|
|
sig.parameters,
|
|
"PhoneAdapter.connect must accept is_reconnect — the gateway always "
|
|
"passes it; a bare connect(self) raises TypeError and the inbound "
|
|
"reply loop never starts.",
|
|
)
|
|
param = sig.parameters["is_reconnect"]
|
|
self.assertEqual(param.kind, inspect.Parameter.KEYWORD_ONLY)
|
|
self.assertEqual(param.default, False)
|
|
|
|
|
|
# ── Home-channel env default (silences upstream's /sethome nudge) ───────────
|
|
|
|
|
|
class _FakeCtx:
|
|
"""Minimal plugin ctx capturing the ``register_platform`` kwargs."""
|
|
|
|
def __init__(self) -> None:
|
|
self.calls = 0
|
|
self.platform_kwargs: Optional[dict] = None
|
|
|
|
def register_platform(
|
|
self,
|
|
*,
|
|
parse_target_ref_fn=None,
|
|
validate_target_ref_fn=None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
self.calls += 1
|
|
self.platform_kwargs = {
|
|
**kwargs,
|
|
"parse_target_ref_fn": parse_target_ref_fn,
|
|
"validate_target_ref_fn": validate_target_ref_fn,
|
|
}
|
|
|
|
|
|
class _LegacyFakeCtx:
|
|
"""Old register_platform/PlatformEntry shape with no typed target hooks."""
|
|
|
|
def __init__(self) -> None:
|
|
self.calls = 0
|
|
self.platform_kwargs: Optional[dict] = None
|
|
|
|
def register_platform(self, **kwargs: Any) -> None:
|
|
self.calls += 1
|
|
unsupported = {"parse_target_ref_fn", "validate_target_ref_fn"} & kwargs.keys()
|
|
if unsupported:
|
|
raise TypeError(f"unsupported kwargs: {sorted(unsupported)}")
|
|
self.platform_kwargs = kwargs
|
|
|
|
|
|
class HomeChannelDefaultTests(_EnvIsolated):
|
|
"""``register_phone_platform`` pre-fills ``PHONE_HOME_CHANNEL`` when enabled.
|
|
|
|
Upstream's first-message onboarding nudge (``gateway/run.py`` — "📬 No home
|
|
channel is set for Phone … /sethome") checks the env var directly, so a
|
|
single-device platform with no env set nags on every new Thread's first
|
|
message. Registration seeds the only sensible value while respecting an
|
|
explicit (non-blank) operator override.
|
|
"""
|
|
|
|
def test_enabled_sets_default_home_channel(self) -> None:
|
|
os.environ["PHONE_ENABLED"] = "1"
|
|
ctx = _FakeCtx()
|
|
pp.register_phone_platform(ctx)
|
|
self.assertEqual(os.environ.get("PHONE_HOME_CHANNEL"), pp.DEFAULT_CHAT_ID)
|
|
# And the platform actually registered, cron env wired to the same key.
|
|
self.assertIsNotNone(ctx.platform_kwargs)
|
|
self.assertEqual(
|
|
ctx.platform_kwargs["cron_deliver_env_var"], "PHONE_HOME_CHANNEL"
|
|
)
|
|
|
|
def test_explicit_home_channel_respected(self) -> None:
|
|
os.environ["PHONE_ENABLED"] = "1"
|
|
os.environ["PHONE_HOME_CHANNEL"] = "myphone"
|
|
pp.register_phone_platform(_FakeCtx())
|
|
self.assertEqual(os.environ.get("PHONE_HOME_CHANNEL"), "myphone")
|
|
|
|
def test_blank_home_channel_replaced(self) -> None:
|
|
os.environ["PHONE_ENABLED"] = "1"
|
|
os.environ["PHONE_HOME_CHANNEL"] = " "
|
|
pp.register_phone_platform(_FakeCtx())
|
|
self.assertEqual(os.environ.get("PHONE_HOME_CHANNEL"), pp.DEFAULT_CHAT_ID)
|
|
|
|
def test_disabled_does_not_set_default(self) -> None:
|
|
# PHONE_ENABLED is popped by _EnvIsolated.setUp — platform opted out.
|
|
pp.register_phone_platform(_FakeCtx())
|
|
self.assertNotIn("PHONE_HOME_CHANNEL", os.environ)
|
|
|
|
def test_current_host_registers_typed_hooks_once(self) -> None:
|
|
os.environ["PHONE_ENABLED"] = "1"
|
|
ctx = _FakeCtx()
|
|
pp.register_phone_platform(ctx)
|
|
self.assertEqual(ctx.calls, 1)
|
|
assert ctx.platform_kwargs is not None
|
|
self.assertIs(ctx.platform_kwargs["parse_target_ref_fn"], pp.parse_target_ref)
|
|
self.assertIs(
|
|
ctx.platform_kwargs["validate_target_ref_fn"], pp.validate_target_ref
|
|
)
|
|
self.assertIs(ctx.platform_kwargs["standalone_sender_fn"], pp._standalone_send)
|
|
|
|
def test_legacy_host_omits_unsupported_hooks_and_registers_once(self) -> None:
|
|
os.environ["PHONE_ENABLED"] = "1"
|
|
ctx = _LegacyFakeCtx()
|
|
legacy_entry = type("LegacyPlatformEntry", (), {"__dataclass_fields__": {}})
|
|
gateway_module = types.ModuleType("gateway")
|
|
registry_module = types.ModuleType("gateway.platform_registry")
|
|
registry_module.PlatformEntry = legacy_entry
|
|
gateway_module.platform_registry = registry_module
|
|
with patch.dict(
|
|
sys.modules,
|
|
{
|
|
"gateway": gateway_module,
|
|
"gateway.platform_registry": registry_module,
|
|
},
|
|
):
|
|
pp.register_phone_platform(ctx)
|
|
self.assertEqual(ctx.calls, 1)
|
|
assert ctx.platform_kwargs is not None
|
|
self.assertNotIn("parse_target_ref_fn", ctx.platform_kwargs)
|
|
self.assertNotIn("validate_target_ref_fn", ctx.platform_kwargs)
|
|
self.assertIs(ctx.platform_kwargs["standalone_sender_fn"], pp._standalone_send)
|
|
|
|
|
|
class TypedTargetTests(_EnvIsolated):
|
|
def test_default_home_and_explicit_phone_target(self) -> None:
|
|
# Upstream strips the platform prefix from ``phone:phone`` before
|
|
# invoking the plugin parser, so both home and explicit forms present
|
|
# the canonical ``phone`` ref here.
|
|
self.assertEqual(pp.parse_target_ref("phone"), ("phone", None))
|
|
self.assertEqual(pp.parse_target_ref(" phone "), ("phone", None))
|
|
self.assertIs(pp.validate_target_ref("phone"), True)
|
|
|
|
def test_custom_home_is_the_only_accepted_ref(self) -> None:
|
|
os.environ["PHONE_HOME_CHANNEL"] = "family-phone"
|
|
self.assertEqual(
|
|
pp.parse_target_ref("family-phone"), ("family-phone", None)
|
|
)
|
|
self.assertIsNone(pp.parse_target_ref("phone"))
|
|
self.assertIs(pp.validate_target_ref("family-phone"), True)
|
|
error = pp.validate_target_ref("phone")
|
|
self.assertIsInstance(error, str)
|
|
self.assertIn("family-phone", error)
|
|
|
|
def test_empty_and_foreign_refs_fail_closed(self) -> None:
|
|
self.assertIsNone(pp.parse_target_ref(""))
|
|
self.assertIsNone(pp.parse_target_ref(" "))
|
|
self.assertIsNone(pp.parse_target_ref("another-phone"))
|
|
self.assertEqual(pp.validate_target_ref(""), "Phone target cannot be empty")
|
|
self.assertIsInstance(pp.validate_target_ref("another-phone"), str)
|
|
|
|
def test_parser_and_validator_reject_wrong_types_without_raising(self) -> None:
|
|
self.assertIsNone(pp.parse_target_ref(None)) # type: ignore[arg-type]
|
|
verdict = pp.validate_target_ref(None) # type: ignore[arg-type]
|
|
self.assertEqual(verdict, "Phone target cannot be empty")
|
|
|
|
|
|
class LiveAdapterSendTests(_EnvIsolated):
|
|
def test_live_adapter_uses_one_post_and_not_standalone_sender(self) -> None:
|
|
client = _FakeAsyncClient(
|
|
_FakeResponse(200, {"delivered": True, "message_id": "live-1"})
|
|
)
|
|
adapter = pp.PhoneAdapter.__new__(pp.PhoneAdapter)
|
|
adapter._http_client = client
|
|
|
|
result = _run(adapter.send("phone", "hello live"))
|
|
|
|
self.assertTrue(result.success)
|
|
self.assertEqual(result.message_id, "live-1")
|
|
self.assertEqual(_FakeAsyncClient.call_count, 1)
|
|
self.assertEqual(_FakeAsyncClient.last_call["json"]["text"], "hello live")
|
|
|
|
|
|
class ChannelDirectoryTests(_EnvIsolated):
|
|
"""The adapter enumerates its home through upstream's directory hook."""
|
|
|
|
def test_lists_default_phone_home(self) -> None:
|
|
adapter = pp.PhoneAdapter.__new__(pp.PhoneAdapter)
|
|
self.assertEqual(
|
|
_run(adapter.list_channels()),
|
|
[{"id": "phone", "name": "Phone", "type": "dm"}],
|
|
)
|
|
|
|
def test_lists_operator_configured_home(self) -> None:
|
|
os.environ["PHONE_HOME_CHANNEL"] = "family-phone"
|
|
os.environ["PHONE_HOME_CHANNEL_NAME"] = "Family phone"
|
|
adapter = pp.PhoneAdapter.__new__(pp.PhoneAdapter)
|
|
self.assertEqual(
|
|
_run(adapter.list_channels()),
|
|
[{"id": "family-phone", "name": "Family phone", "type": "dm"}],
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|