Files
2026-07-13 13:12:33 +08:00

364 lines
13 KiB
Python

"""Tests for the channel catalog."""
from __future__ import annotations
import pytest
from opensquilla.gateway.config import (
DingTalkChannelEntry,
DiscordChannelEntry,
FeishuChannelEntry,
MatrixChannelEntry,
QQChannelEntry,
SlackChannelEntry,
TelegramChannelEntry,
WeComChannelEntry,
)
from opensquilla.onboarding.channel_specs import (
ChannelSetupSpec,
channel_catalog_payload,
get_channel_setup_spec,
list_channel_setup_specs,
)
# msteams is intentionally absent: the adapter is text-only and hidden
# from runtime catalog surfaces until first-class support lands.
ALL_TYPES = {
"slack", "feishu", "discord", "dingtalk", "wecom", "qq",
"matrix", "telegram",
}
ENTRY_MODELS = {
"slack": SlackChannelEntry,
"feishu": FeishuChannelEntry,
"discord": DiscordChannelEntry,
"dingtalk": DingTalkChannelEntry,
"wecom": WeComChannelEntry,
"qq": QQChannelEntry,
"matrix": MatrixChannelEntry,
"telegram": TelegramChannelEntry,
}
EXPECTED_PUBLIC_URL: set[str] = set()
CONDITIONAL_PUBLIC_URL = {"feishu", "slack", "telegram", "wecom"}
def test_catalog_includes_all_channels():
types = {s.type for s in list_channel_setup_specs()}
assert types == ALL_TYPES
@pytest.mark.parametrize("type_name", sorted(ALL_TYPES))
def test_each_channel_has_common_fields(type_name: str):
spec = get_channel_setup_spec(type_name)
names = {f.name for f in spec.fields}
assert {"name", "enabled", "agent_id"} <= names
@pytest.mark.parametrize("type_name", sorted(ALL_TYPES))
def test_spec_fields_align_with_pydantic_model(type_name: str):
spec = get_channel_setup_spec(type_name)
model = ENTRY_MODELS[type_name]
pydantic_fields = set(model.model_fields.keys())
spec_fields = {f.name for f in spec.fields}
assert "type" not in spec_fields
extra = spec_fields - pydantic_fields - {"type"}
assert not extra, f"setup spec exposes unknown field(s): {extra}"
@pytest.mark.parametrize("type_name", sorted(ALL_TYPES))
def test_required_pydantic_fields_are_required_in_spec(type_name: str):
spec = get_channel_setup_spec(type_name)
model = ENTRY_MODELS[type_name]
spec_required = {f.name for f in spec.fields if f.required}
for fname, finfo in model.model_fields.items():
if fname == "type":
continue
if finfo.is_required():
assert fname in spec_required, (
f"{type_name}.{fname} is required in pydantic but not in setup spec"
)
def test_slack_secrets_are_marked_secret():
spec = get_channel_setup_spec("slack")
secrets = {f.name for f in spec.fields if f.secret}
assert {"token", "app_token", "signing_secret"} <= secrets
def test_telegram_secrets_are_marked_secret():
spec = get_channel_setup_spec("telegram")
secrets = {f.name for f in spec.fields if f.secret}
assert {"token", "webhook_secret_token"} <= secrets
def test_discord_gateway_auth_fields_do_not_expose_interactions_public_key():
spec = get_channel_setup_spec("discord")
fields = {f.name: f for f in spec.fields}
assert fields["token"].required is True
assert fields["token"].secret is True
assert fields["application_id"].secret is False
assert "public_key" not in fields
def test_dingtalk_stream_credentials_are_marked_correctly():
spec = get_channel_setup_spec("dingtalk")
fields = {f.name: f for f in spec.fields}
assert fields["client_id"].required is True
assert fields["client_id"].secret is False
assert fields["client_secret"].required is True
assert fields["client_secret"].secret is True
def test_feishu_webhook_secrets_are_marked_secret():
spec = get_channel_setup_spec("feishu")
secrets = {f.name for f in spec.fields if f.secret}
assert {"app_secret", "encrypt_key", "verification_token"} <= secrets
def test_feishu_connection_mode_choices():
spec = get_channel_setup_spec("feishu")
field = next(f for f in spec.fields if f.name == "connection_mode")
assert field.field_type == "select"
assert field.default == "websocket"
assert field.choices == ("webhook", "websocket")
def test_slack_connection_mode_choices():
spec = get_channel_setup_spec("slack")
field = next(f for f in spec.fields if f.name == "connection_mode")
assert field.field_type == "select"
assert field.default == "webhook"
assert field.choices == ("webhook", "socket")
assert field.advanced is False
def test_wecom_connection_mode_choices():
spec = get_channel_setup_spec("wecom")
field = next(f for f in spec.fields if f.name == "connection_mode")
assert spec.transport == "mixed"
assert spec.requires_public_url is False
assert field.field_type == "select"
# Must mirror WeComChannelEntry.connection_mode: a headless entry that
# omits connection_mode is validated in the pydantic default mode.
assert field.default == "webhook"
assert field.default == WeComChannelEntry.model_fields["connection_mode"].default
assert field.choices == ("websocket", "webhook")
def test_wecom_mode_specific_fields_are_conditional():
spec = get_channel_setup_spec("wecom")
fields = {f.name: f for f in spec.fields}
assert fields["bot_id"].show_when == {"connection_mode": "websocket"}
assert fields["bot_secret"].show_when == {"connection_mode": "websocket"}
assert fields["websocket_url"].show_when == {"connection_mode": "websocket"}
assert fields["corp_id"].show_when == {"connection_mode": "webhook"}
assert fields["corp_secret"].show_when == {"connection_mode": "webhook"}
assert fields["token"].show_when == {"connection_mode": "webhook"}
assert fields["encoding_aes_key"].show_when == {"connection_mode": "webhook"}
assert fields["bot_secret"].secret is True
assert fields["corp_secret"].secret is True
def test_slack_mode_specific_fields_are_conditional():
spec = get_channel_setup_spec("slack")
fields = {f.name: f for f in spec.fields}
assert fields["app_token"].show_when == {"connection_mode": "socket"}
assert fields["signing_secret"].show_when == {"connection_mode": "webhook"}
assert fields["signing_secret"].required is True
assert fields["slack_channel_id"].required is False
def test_feishu_status_reactions_are_enabled_by_default():
entry = FeishuChannelEntry(
name="feishu",
app_id="cli_test",
app_secret="secret",
)
assert entry.status_reactions_enabled is True
def test_feishu_status_reactions_are_exposed_in_setup_spec():
spec = get_channel_setup_spec("feishu")
field = next(f for f in spec.fields if f.name == "status_reactions_enabled")
assert field.field_type == "bool"
assert field.default is True
assert field.advanced is True
def test_feishu_webhook_fields_are_conditional():
spec = get_channel_setup_spec("feishu")
fields = {f.name: f for f in spec.fields}
assert fields["webhook_path"].show_when == {"connection_mode": "webhook"}
assert fields["verification_token"].show_when == {"connection_mode": "webhook"}
assert fields["encrypt_key"].advanced is True
def test_telegram_webhook_fields_are_conditional():
spec = get_channel_setup_spec("telegram")
fields = {f.name: f for f in spec.fields}
assert fields["transport_name"].default == "polling"
assert fields["webhook_path"].show_when == {"transport_name": "webhook"}
assert fields["webhook_url"].show_when == {"transport_name": "webhook"}
assert fields["webhook_secret_token"].show_when == {"transport_name": "webhook"}
assert fields["poll_timeout_s"].show_when == {"transport_name": "polling"}
def test_channel_catalog_payload_exposes_ui_metadata():
payload = channel_catalog_payload()
feishu = next(c for c in payload if c["type"] == "feishu")
fields = {f["name"]: f for f in feishu["fields"]}
assert fields["app_secret"]["group"] == "credentials"
assert fields["app_secret"]["placeholder"]
assert fields["webhook_path"]["showWhen"] == {"connection_mode": "webhook"}
assert fields["encrypt_key"]["advanced"] is True
assert feishu["blocking"] is False
assert feishu["whatYouNeed"]
slack = next(c for c in payload if c["type"] == "slack")
assert "public URL" in slack["help"]
assert slack["transport"] == "mixed"
assert slack["requiresPublicUrl"] is False
slack_fields = {f["name"]: f for f in slack["fields"]}
assert slack_fields["app_token"]["showWhen"] == {"connection_mode": "socket"}
wecom = next(c for c in payload if c["type"] == "wecom")
# whatYouNeed follows the spec default mode, which mirrors the pydantic
# default (webhook): the advertised minimal setup names the fields that
# webhook-mode validation actually requires.
assert "Corp id." in wecom["whatYouNeed"]
assert "Corp secret." in wecom["whatYouNeed"]
assert "Encoding AES key." in wecom["whatYouNeed"]
assert "Bot ID." not in wecom["whatYouNeed"]
assert "Bot secret." not in wecom["whatYouNeed"]
def test_matrix_encryption_choices():
spec = get_channel_setup_spec("matrix")
field = next(f for f in spec.fields if f.name == "encryption")
assert field.choices == ("off", "required", "best_effort")
@pytest.mark.parametrize("type_name", sorted(EXPECTED_PUBLIC_URL))
def test_webhook_channels_require_public_url(type_name: str):
spec = get_channel_setup_spec(type_name)
assert spec.requires_public_url is True
@pytest.mark.parametrize("type_name", sorted(CONDITIONAL_PUBLIC_URL))
def test_conditional_webhook_channels_flagged(type_name: str):
spec = get_channel_setup_spec(type_name)
assert spec.transport in {"mixed", "webhook"}
def test_base_channel_specs_do_not_advertise_legacy_extras():
for type_name in ("feishu", "telegram", "dingtalk", "wecom", "qq"):
spec = get_channel_setup_spec(type_name)
assert spec.dependency_extra is None
def test_matrix_advertises_its_real_optional_extra():
spec = get_channel_setup_spec("matrix")
assert spec.dependency_extra == "matrix"
def test_channel_catalog_payload_only_advertises_real_install_extras():
payload = {entry["type"]: entry for entry in channel_catalog_payload()}
for type_name in ("feishu", "telegram", "dingtalk", "wecom", "qq"):
assert payload[type_name]["dependencyExtra"] is None
assert payload["matrix"]["dependencyExtra"] == "matrix"
def test_unknown_channel_raises():
with pytest.raises(KeyError):
get_channel_setup_spec("not-a-channel")
def test_msteams_is_hidden_from_catalog():
"""msteams must not be advertised via the onboarding catalog."""
types = {s.type for s in list_channel_setup_specs()}
assert "msteams" not in types
with pytest.raises(KeyError):
get_channel_setup_spec("msteams")
def test_payload_redacts_secret_defaults():
payload = channel_catalog_payload()
for entry in payload:
for f in entry["fields"]:
if f.get("secret"):
assert f["default"] in (None, "", False)
def test_catalog_is_sorted():
types = [s.type for s in list_channel_setup_specs()]
assert types == sorted(types)
def test_returns_setup_spec_instance():
assert isinstance(get_channel_setup_spec("slack"), ChannelSetupSpec)
@pytest.mark.parametrize("type_name", sorted(ALL_TYPES))
def test_spec_field_defaults_match_pydantic_defaults(type_name: str):
"""Catalog defaults must mirror the gateway pydantic defaults.
A spec default that diverges from the model default (as wecom's
connection_mode once did) advertises a minimal setup that the headless
path then validates in a *different* mode, guaranteeing failure.
"""
from pydantic_core import PydanticUndefined
spec = get_channel_setup_spec(type_name)
model = ENTRY_MODELS[type_name]
for field in spec.fields:
finfo = model.model_fields.get(field.name)
if finfo is None or finfo.default is PydanticUndefined or field.default is None:
continue
assert field.default == finfo.default, (
f"{type_name}.{field.name}: spec default {field.default!r} "
f"diverges from pydantic default {finfo.default!r}"
)
_DUMMY_FIELD_VALUES = {
"text": "dummy-value",
"password": "dummy-secret",
"int": 1,
"float": 1.0,
"bool": True,
}
@pytest.mark.parametrize("type_name", sorted(ALL_TYPES))
def test_advertised_minimal_setup_passes_model_validation(type_name: str):
"""The catalog's minimal recipe must satisfy the pydantic validators.
Mirrors the headless path: fill exactly the required fields that are
visible under the spec defaults (what the catalog Try command asks for),
leave everything else to model defaults, and expect a valid entry.
"""
spec = get_channel_setup_spec(type_name)
defaults = {field.name: field.default for field in spec.fields}
entry: dict[str, object] = {"name": "test-entry"}
for field in spec.fields:
if not field.required or field.name == "name":
continue
if field.show_when and any(
str(defaults.get(key, "")) != expected
for key, expected in field.show_when.items()
):
continue
if field.field_type == "select":
entry[field.name] = field.default
else:
entry[field.name] = _DUMMY_FIELD_VALUES[field.field_type]
model = ENTRY_MODELS[type_name]
validated = model(**entry)
assert validated.name == "test-entry"