Files
Auto-Trader/agent/tests/test_channel_authoring_contract.py
T

75 lines
3.5 KiB
Python

"""Every channel must satisfy the authoring contract (#1625).
A new channel ships as a ``BaseChannel`` subclass plus field metadata, and the
generic Web UI renders it with no per-channel frontend code. This suite walks
every built-in channel the registry discovers and checks the contract points
the UI and settings flows depend on, so a channel that breaks the recipe fails
here instead of in the browser.
"""
from __future__ import annotations
import asyncio
import re
import tomllib
from pathlib import Path
import pytest
from src.channels.config_meta import channel_field_hints, split_values_secrets
from src.channels.registry import discover_channel_names, load_channel_class
@pytest.mark.parametrize("name", discover_channel_names())
def test_every_discovered_channel_satisfies_the_authoring_contract(name) -> None:
try:
cls = load_channel_class(name)
except ImportError as exc:
# Only the adapter's explicit missing-dependency envelope can skip.
# An arbitrary import or missing BaseChannel subclass must fail.
project = tomllib.loads((Path(__file__).resolve().parents[2] / "pyproject.toml").read_text())["project"]
optional = project["optional-dependencies"].get(name, [])
modules = {re.split(r"[<>=!\[; ]", requirement)[0].replace("-", "_") for requirement in optional}
direct_missing = isinstance(exc, ModuleNotFoundError) and (exc.name or "").split(".")[0] in modules
declared_missing = isinstance(exc.__cause__, ModuleNotFoundError) and "dependencies not installed" in str(exc)
if not (direct_missing or declared_missing):
raise
pytest.skip(str(exc))
# A concrete adapter: start/stop/send are abstract on BaseChannel.
assert not cls.__abstractmethods__, f"{name} leaves BaseChannel abstracts unset"
default_config = cls.default_config()
assert isinstance(default_config, dict), f"{name} default_config is not a dict"
# The generic form renders from hints; every configurable scalar key
# must resolve one. Dict-valued fields are exempt by design: the form
# cannot edit them, so they stay file-configured (see _derive_hints).
hints = channel_field_hints(name)
configurable = {k for k, v in default_config.items() if k != "enabled" and not isinstance(v, dict)}
hint_keys = {h["key"] for h in hints}
missing = configurable - hint_keys
assert not missing, f"{name} has scalar config keys with no field hint: {sorted(missing)}"
# Test the serialized form consumed by the settings UI, rather than
# only asking the same classifier that supplied the hints.
sentinel = "credential-contract-sentinel"
configured = {hint["key"]: sentinel for hint in hints if hint["secret"]}
configured["extra_api_token"] = sentinel
values, secrets = split_values_secrets(name, configured)
assert not (set(configured) & set(values)), f"{name} exposes a credential in values"
assert set(configured) <= set(secrets)
assert sentinel not in str(secrets)
for hint in hints:
if hint["type"] == "password":
assert hint["secret"], f"{name}.{hint['key']} is a password field not marked secret"
def test_test_connection_envelope_shape_is_documented_by_default() -> None:
# The base contract: a channel that does not override the probe reports
# "unsupported" rather than crashing the settings UI.
from src.channels.base import BaseChannel
assert "test_connection" not in BaseChannel.__abstractmethods__
assert asyncio.run(BaseChannel.test_connection(None)) == {"ok": False, "code": "unsupported"}