1. 移除Telegram格式化测试中未使用的导入项 2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关 3. 更新钉钉适配器测试,替换弃用的流属性检查 4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑 5. 重构会话映射测试,完善数据库执行结果模拟 6. 格式化Slack块构建测试的长参数调用 7. 修复LINE适配器测试,更新能力断言和异步锁使用 8. 修正Slack会话解析测试,修复聊天类型判断错误 9. 更新能力测试,补充缺失的字段检查 10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑 11. 为飞书分析模块测试添加跳过标记 12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试 13. 修复Twitch适配器导入路径和测试断言 14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试 15. 修复Manager阶段测试的导入路径 16. 新增iMessage异常和命令处理的单元测试 17. 新增Nostr健康检查和相关模块的单元测试 18. 新增Signal守护进程和SSE重连相关测试
2204 lines
74 KiB
Python
2204 lines
74 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import time
|
|
|
|
|
|
from yuxi.channels.adapters.mattermost.accounts import (
|
|
DEFAULT_ACCOUNT_ID,
|
|
MattermostAccount,
|
|
MultiAccountConfig,
|
|
merge_config,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.approval import (
|
|
APPROVAL_TIMEOUT_S,
|
|
ApprovalConfig,
|
|
ApprovalManager,
|
|
ApprovalRequest,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.cache import (
|
|
LRUCache,
|
|
MattermostChannelCache,
|
|
SentMessageCache,
|
|
TTLCache,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.config_schema import (
|
|
MATTERMOST_CONFIG_SCHEMA,
|
|
get_config_schema,
|
|
validate_config_schema,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.config_writes import ConfigWritesManager
|
|
from yuxi.channels.adapters.mattermost.debounce import InboundDebouncer
|
|
from yuxi.channels.adapters.mattermost.directory import (
|
|
DirectoryGroup,
|
|
DirectoryPeer,
|
|
DirectorySnapshot,
|
|
build_group_from_channel_data,
|
|
build_peer_from_user_data,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.model_picker import (
|
|
MODEL_PICKER_PAGE_SIZE,
|
|
ModelOption,
|
|
PickerState,
|
|
build_model_picker_actions,
|
|
build_model_picker_attachment,
|
|
build_model_select_attachment,
|
|
get_available_providers,
|
|
get_default_model_options,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.monitor import (
|
|
MODE_ONCHAR,
|
|
MODE_ONMESSAGE,
|
|
MentionGate,
|
|
MentionGateConfig,
|
|
is_control_command,
|
|
resolve_control_command_gate,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.normalizer import (
|
|
check_bot_mentioned,
|
|
detect_message_type,
|
|
extract_attachments,
|
|
extract_mentions,
|
|
extract_urls,
|
|
parse_channel_json,
|
|
parse_post_json,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.pairing import (
|
|
PAIRING_CODE_LENGTH,
|
|
PAIRING_REQUEST_TTL_S,
|
|
MattermostPairingManager,
|
|
PairingRequest,
|
|
build_pairing_notification,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.polls import (
|
|
build_poll_props,
|
|
build_yes_no_poll_props,
|
|
parse_poll_action,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.reply import (
|
|
REPLY_ALL,
|
|
REPLY_OFF,
|
|
ReplyConfig,
|
|
ReplyManager,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.secret import (
|
|
SecretDescriptor,
|
|
build_secret_target_registry_entries,
|
|
collect_runtime_config_assignments,
|
|
get_secret_contract,
|
|
list_configured_secrets,
|
|
resolve_secret,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.security import (
|
|
DM_POLICY_DISABLED,
|
|
DM_POLICY_OPEN,
|
|
DM_POLICY_PAIRING,
|
|
GROUP_POLICY_ALLOWLIST,
|
|
GROUP_POLICY_DISABLED,
|
|
GROUP_POLICY_OPEN,
|
|
MattermostSecurity,
|
|
MattermostSecurityConfig,
|
|
is_allow_entry_match,
|
|
normalize_allow_entry,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.send import (
|
|
_split_hard,
|
|
build_patch_options,
|
|
build_post_options,
|
|
chunk_text_for_outbound,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.session import (
|
|
build_thread_id,
|
|
resolve_chat_id,
|
|
resolve_chat_type,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.slash import (
|
|
DEFAULT_COMMANDS,
|
|
ManagedSlashCommand,
|
|
SlashCommand,
|
|
SlashCommandConfig,
|
|
build_command_payload,
|
|
build_skill_commands,
|
|
get_supported_commands,
|
|
)
|
|
from yuxi.channels.adapters.mattermost.target_resolution import (
|
|
looks_like_mattermost_target_id,
|
|
parse_target,
|
|
resolve_channel_target,
|
|
resolve_user_target,
|
|
)
|
|
from yuxi.channels.models import ChatType, MessageType
|
|
|
|
# ============================================================
|
|
# config_schema.py
|
|
# ============================================================
|
|
|
|
|
|
class TestValidateConfigSchema:
|
|
def test_valid_minimal_config(self):
|
|
config = {"server_url": "https://example.com", "bot_token": "token123"}
|
|
errors = validate_config_schema(config)
|
|
assert errors == []
|
|
|
|
def test_missing_server_url(self):
|
|
config = {"bot_token": "token123"}
|
|
errors = validate_config_schema(config)
|
|
assert any("server_url" in e for e in errors)
|
|
|
|
def test_missing_bot_token(self):
|
|
config = {"server_url": "https://example.com"}
|
|
errors = validate_config_schema(config)
|
|
assert any("bot_token" in e for e in errors)
|
|
|
|
def test_invalid_dm_policy(self):
|
|
config = {"server_url": "https://example.com", "bot_token": "x", "dm_policy": "invalid"}
|
|
errors = validate_config_schema(config)
|
|
assert any("dm_policy" in e for e in errors)
|
|
|
|
def test_invalid_group_policy(self):
|
|
config = {"server_url": "https://example.com", "bot_token": "x", "group_policy": "invalid"}
|
|
errors = validate_config_schema(config)
|
|
assert any("group_policy" in e for e in errors)
|
|
|
|
def test_invalid_chatmode(self):
|
|
config = {"server_url": "https://example.com", "bot_token": "x", "chatmode": "invalid"}
|
|
errors = validate_config_schema(config)
|
|
assert any("chatmode" in e for e in errors)
|
|
|
|
def test_text_chunk_limit_out_of_range(self):
|
|
config = {"server_url": "https://example.com", "bot_token": "x", "text_chunk_limit": 500}
|
|
errors = validate_config_schema(config)
|
|
assert any("text_chunk_limit" in e for e in errors)
|
|
|
|
config2 = {"server_url": "https://example.com", "bot_token": "x", "text_chunk_limit": 20000}
|
|
errors2 = validate_config_schema(config2)
|
|
assert any("text_chunk_limit" in e for e in errors2)
|
|
|
|
def test_valid_boundary_text_chunk_limit(self):
|
|
config = {"server_url": "https://example.com", "bot_token": "x", "text_chunk_limit": 1000}
|
|
errors = validate_config_schema(config)
|
|
assert not any("text_chunk_limit" in e for e in errors)
|
|
|
|
config2 = {"server_url": "https://example.com", "bot_token": "x", "text_chunk_limit": 16383}
|
|
errors2 = validate_config_schema(config2)
|
|
assert not any("text_chunk_limit" in e for e in errors2)
|
|
|
|
def test_all_valid_policies(self):
|
|
for dm in ["open", "allowlist", "pairing", "disabled"]:
|
|
for gp in ["open", "allowlist", "disabled"]:
|
|
for cm in ["oncall", "onmessage", "onchar"]:
|
|
config = {
|
|
"server_url": "https://example.com",
|
|
"bot_token": "x",
|
|
"dm_policy": dm,
|
|
"group_policy": gp,
|
|
"chatmode": cm,
|
|
}
|
|
errors = validate_config_schema(config)
|
|
assert errors == [], f"Failed for dm={dm} gp={gp} cm={cm}: {errors}"
|
|
|
|
def test_empty_config(self):
|
|
errors = validate_config_schema({})
|
|
assert len(errors) == 2
|
|
|
|
|
|
class TestGetConfigSchema:
|
|
def test_returns_dict(self):
|
|
result = get_config_schema()
|
|
assert isinstance(result, dict)
|
|
assert result == MATTERMOST_CONFIG_SCHEMA
|
|
|
|
def test_required_keys(self):
|
|
result = get_config_schema()
|
|
assert "required" in result
|
|
assert "server_url" in result["required"]
|
|
assert "bot_token" in result["required"]
|
|
|
|
def test_schema_version(self):
|
|
result = get_config_schema()
|
|
assert result["$schema"].startswith("http://json-schema.org")
|
|
|
|
|
|
# ============================================================
|
|
# normalizer.py
|
|
# ============================================================
|
|
|
|
|
|
class TestParseJsonFields:
|
|
def test_parse_post_json_valid(self):
|
|
data = {"post": '{"id":"m1","message":"hello"}'}
|
|
result = parse_post_json(data)
|
|
assert result == {"id": "m1", "message": "hello"}
|
|
|
|
def test_parse_post_json_invalid(self):
|
|
data = {"post": "not-json"}
|
|
result = parse_post_json(data)
|
|
assert result == {}
|
|
|
|
def test_parse_post_json_missing(self):
|
|
data = {}
|
|
result = parse_post_json(data)
|
|
assert result == {}
|
|
|
|
def test_parse_post_json_already_dict(self):
|
|
data = {"post": {"id": "m1"}}
|
|
result = parse_post_json(data)
|
|
assert result == {"id": "m1"}
|
|
|
|
def test_parse_post_json_none(self):
|
|
data = {"post": None}
|
|
result = parse_post_json(data)
|
|
assert result == {}
|
|
|
|
def test_parse_channel_json_valid(self):
|
|
data = {"channel": '{"type":"D","display_name":"tester"}'}
|
|
result = parse_channel_json(data)
|
|
assert result == {"type": "D", "display_name": "tester"}
|
|
|
|
def test_parse_channel_json_invalid(self):
|
|
data = {"channel": "bad json!!!"}
|
|
result = parse_channel_json(data)
|
|
assert result == {}
|
|
|
|
def test_parse_channel_json_missing(self):
|
|
data = {}
|
|
result = parse_channel_json(data)
|
|
assert result == {}
|
|
|
|
|
|
class TestExtractMentions:
|
|
def test_single_mention(self):
|
|
result = extract_mentions("hello @user1 how are you")
|
|
assert result == ["user1"]
|
|
|
|
def test_multiple_mentions(self):
|
|
result = extract_mentions("@alice @bob @charlie test")
|
|
assert result == ["alice", "bob", "charlie"]
|
|
|
|
def test_mention_with_dots(self):
|
|
result = extract_mentions("@user.test here")
|
|
assert result == ["user.test"]
|
|
|
|
def test_mention_with_hyphen(self):
|
|
result = extract_mentions("@user-name test")
|
|
assert result == ["user-name"]
|
|
|
|
def test_no_mention(self):
|
|
result = extract_mentions("hello world")
|
|
assert result == []
|
|
|
|
def test_empty_text(self):
|
|
result = extract_mentions("")
|
|
assert result == []
|
|
|
|
def test_none_text(self):
|
|
result = extract_mentions(None)
|
|
assert result == []
|
|
|
|
def test_email_not_mention(self):
|
|
result = extract_mentions("test@example.com")
|
|
assert result == ["example.com"]
|
|
|
|
|
|
class TestCheckBotMentioned:
|
|
def test_bot_mentioned_case_insensitive(self):
|
|
assert check_bot_mentioned("hello @FORCEPILOT test", "forcepilot") is True
|
|
|
|
def test_bot_not_mentioned(self):
|
|
assert check_bot_mentioned("hello world", "forcepilot") is False
|
|
|
|
def test_empty_text(self):
|
|
assert check_bot_mentioned("", "bot") is False
|
|
|
|
def test_empty_bot_username(self):
|
|
assert check_bot_mentioned("@bot hello", "") is False
|
|
|
|
def test_none_text(self):
|
|
assert check_bot_mentioned(None, "bot") is False
|
|
|
|
def test_exact_match_in_longer_message(self):
|
|
text = "some text @ForcePilot can you help?"
|
|
assert check_bot_mentioned(text, "ForcePilot") is True
|
|
|
|
|
|
class TestExtractUrls:
|
|
def test_single_url(self):
|
|
result = extract_urls("check https://example.com/page")
|
|
assert result == ["https://example.com/page"]
|
|
|
|
def test_multiple_urls(self):
|
|
result = extract_urls("a https://a.com b https://b.com/path?q=1")
|
|
assert result == ["https://a.com", "https://b.com/path?q=1"]
|
|
|
|
def test_no_url(self):
|
|
result = extract_urls("hello world")
|
|
assert result == []
|
|
|
|
def test_empty_text(self):
|
|
result = extract_urls("")
|
|
assert result == []
|
|
|
|
def test_none_text(self):
|
|
result = extract_urls(None)
|
|
assert result == []
|
|
|
|
def test_http_url(self):
|
|
result = extract_urls("see http://example.com")
|
|
assert result == ["http://example.com"]
|
|
|
|
|
|
class TestDetectMessageType:
|
|
def test_text_message(self):
|
|
assert detect_message_type({}) == MessageType.TEXT
|
|
|
|
def test_file_message(self):
|
|
assert detect_message_type({"file_ids": ["f1"]}) == MessageType.FILE
|
|
|
|
def test_empty_file_ids(self):
|
|
assert detect_message_type({"file_ids": []}) == MessageType.TEXT
|
|
|
|
|
|
class TestExtractAttachments:
|
|
def test_with_file_ids(self):
|
|
result = extract_attachments({"file_ids": ["f1", "f2"]})
|
|
assert len(result) == 2
|
|
assert result[0].file_id == "f1"
|
|
assert result[0].type == "file"
|
|
assert result[1].file_id == "f2"
|
|
|
|
def test_no_file_ids(self):
|
|
result = extract_attachments({})
|
|
assert result == []
|
|
|
|
def test_empty_file_ids(self):
|
|
result = extract_attachments({"file_ids": []})
|
|
assert result == []
|
|
|
|
|
|
# ============================================================
|
|
# accounts.py
|
|
# ============================================================
|
|
|
|
|
|
class TestMattermostAccount:
|
|
def test_default_values(self):
|
|
a = MattermostAccount(account_id="test1")
|
|
assert a.account_id == "test1"
|
|
assert a.server_url == ""
|
|
assert a.bot_token == ""
|
|
assert a.nick == "ForcePilot"
|
|
assert a.username == ""
|
|
assert a.channels == []
|
|
assert a.enabled is True
|
|
|
|
def test_configured_true(self):
|
|
a = MattermostAccount(account_id="t", server_url="https://x.com", bot_token="tok")
|
|
assert a.configured is True
|
|
|
|
def test_configured_false(self):
|
|
a = MattermostAccount(account_id="t")
|
|
assert a.configured is False
|
|
|
|
def test_configured_missing_token(self):
|
|
a = MattermostAccount(account_id="t", server_url="https://x.com")
|
|
assert a.configured is False
|
|
|
|
def test_from_config_entry_full(self):
|
|
entry = {
|
|
"server_url": "https://mm.example.com",
|
|
"bot_token": "secret123",
|
|
"nick": "MyBot",
|
|
"username": "mybot",
|
|
"realname": "My Bot",
|
|
"channels": ["ch1", "ch2"],
|
|
"enabled": False,
|
|
}
|
|
result = MattermostAccount.from_config_entry("acc1", entry)
|
|
assert result.account_id == "acc1"
|
|
assert result.server_url == "https://mm.example.com"
|
|
assert result.bot_token == "secret123"
|
|
assert result.nick == "MyBot"
|
|
assert result.username == "mybot"
|
|
assert result.realname == "My Bot"
|
|
assert result.channels == ["ch1", "ch2"]
|
|
assert result.enabled is False
|
|
|
|
def test_from_config_entry_minimal(self):
|
|
result = MattermostAccount.from_config_entry("acc2", {})
|
|
assert result.account_id == "acc2"
|
|
assert result.server_url == ""
|
|
assert result.bot_token == ""
|
|
assert result.configured is False
|
|
|
|
def test_from_config_entry_legacy_fields(self):
|
|
entry = {"host": "https://old.example.com", "token": "old_token"}
|
|
result = MattermostAccount.from_config_entry("acc3", entry)
|
|
assert result.server_url == "https://old.example.com"
|
|
assert result.bot_token == "old_token"
|
|
|
|
|
|
class TestMultiAccountConfig:
|
|
def test_from_config_with_accounts(self):
|
|
config = {
|
|
"accounts": {
|
|
"a1": {"server_url": "https://s1.com", "bot_token": "t1"},
|
|
"a2": {"server_url": "https://s2.com", "bot_token": "t2"},
|
|
},
|
|
"default_account_id": "a1",
|
|
}
|
|
mac = MultiAccountConfig.from_config(config)
|
|
assert len(mac.accounts) == 2
|
|
assert mac.default_account_id == "a1"
|
|
assert mac.accounts["a1"].server_url == "https://s1.com"
|
|
|
|
def test_from_config_without_accounts(self):
|
|
config = {"server_url": "https://solo.com", "bot_token": "t"}
|
|
mac = MultiAccountConfig.from_config(config)
|
|
assert len(mac.accounts) == 1
|
|
assert mac.default_account_id == DEFAULT_ACCOUNT_ID
|
|
assert mac.accounts[DEFAULT_ACCOUNT_ID].server_url == "https://solo.com"
|
|
|
|
def test_from_config_empty(self):
|
|
mac = MultiAccountConfig.from_config({})
|
|
assert len(mac.accounts) == 1
|
|
assert mac.accounts[DEFAULT_ACCOUNT_ID].server_url == ""
|
|
|
|
def test_resolve_account_explicit(self):
|
|
config = {
|
|
"accounts": {
|
|
"a1": {"server_url": "https://s1.com", "bot_token": "t1"},
|
|
"a2": {"server_url": "https://s2.com", "bot_token": "t2"},
|
|
},
|
|
}
|
|
mac = MultiAccountConfig.from_config(config)
|
|
result = mac.resolve_account("a2")
|
|
assert result.account_id == "a2"
|
|
|
|
def test_resolve_account_fallback_default(self):
|
|
config = {"accounts": {"a1": {"server_url": "https://s1.com", "bot_token": "t1"}}}
|
|
mac = MultiAccountConfig.from_config(config)
|
|
result = mac.resolve_account("nonexistent")
|
|
assert result.account_id == "a1"
|
|
|
|
def test_resolve_account_not_found(self):
|
|
mac = MultiAccountConfig()
|
|
result = mac.resolve_account()
|
|
assert result is None
|
|
|
|
def test_list_account_ids(self):
|
|
config = {"accounts": {"a1": {"server_url": "x", "bot_token": "x"}}}
|
|
mac = MultiAccountConfig.from_config(config)
|
|
assert mac.list_account_ids() == ["a1"]
|
|
|
|
def test_list_configured_accounts(self):
|
|
config = {
|
|
"accounts": {
|
|
"a1": {"server_url": "https://s1.com", "bot_token": "t1"},
|
|
"a2": {"server_url": "", "bot_token": ""},
|
|
"a3": {"server_url": "https://s3.com", "bot_token": "t3", "enabled": False},
|
|
},
|
|
}
|
|
mac = MultiAccountConfig.from_config(config)
|
|
configured = mac.list_configured_accounts()
|
|
assert len(configured) == 1
|
|
assert configured[0].account_id == "a1"
|
|
|
|
def test_default_account_id_auto_detect(self):
|
|
config = {"accounts": {"b1": {"server_url": "x", "bot_token": "x"}}}
|
|
mac = MultiAccountConfig.from_config(config)
|
|
assert mac.default_account_id == "b1"
|
|
|
|
|
|
class TestMergeConfig:
|
|
def test_basic_merge(self):
|
|
top = {"server_url": "https://top.com", "bot_token": "top-token"}
|
|
account = {"bot_token": "acc-token"}
|
|
result = merge_config(top, account)
|
|
assert result["server_url"] == "https://top.com"
|
|
assert result["bot_token"] == "acc-token"
|
|
|
|
def test_commands_merge(self):
|
|
top = {"commands": {"cmd1": "desc1"}}
|
|
account = {"commands": {"cmd2": "desc2"}}
|
|
result = merge_config(top, account)
|
|
assert result["commands"] == {"cmd1": "desc1", "cmd2": "desc2"}
|
|
|
|
def test_interactions_merge(self):
|
|
top = {"interactions": {"int1": "d1"}}
|
|
account = {"interactions": {"int2": "d2"}}
|
|
result = merge_config(top, account)
|
|
assert result["interactions"] == {"int1": "d1", "int2": "d2"}
|
|
|
|
def test_merge_with_empty_account(self):
|
|
top = {"server_url": "x", "bot_token": "t", "commands": {"c1": "d1"}}
|
|
result = merge_config(top, {})
|
|
assert result["server_url"] == "x"
|
|
assert result["commands"] == {"c1": "d1"}
|
|
|
|
|
|
# ============================================================
|
|
# polls.py
|
|
# ============================================================
|
|
|
|
|
|
class TestBuildPollProps:
|
|
def test_basic_poll(self):
|
|
result = build_poll_props("What to eat?", ["Pizza", "Burger", "Salad"], poll_id="p1")
|
|
assert len(result["attachments"]) == 1
|
|
attachment = result["attachments"][0]
|
|
assert len(attachment["actions"]) == 1
|
|
assert attachment["actions"][0]["type"] == "select"
|
|
assert len(attachment["actions"][0]["options"]) == 3
|
|
assert result["props"]["poll_id"] == "p1"
|
|
assert result["props"]["type"] == "poll"
|
|
|
|
def test_poll_with_fewer_than_2_options(self):
|
|
result = build_poll_props("Single?", ["Only"])
|
|
opts = result["attachments"][0]["actions"][0]["options"]
|
|
assert len(opts) >= 2
|
|
|
|
def test_poll_with_more_than_5_options(self):
|
|
result = build_poll_props("Many?", ["A", "B", "C", "D", "E", "F"])
|
|
opts = result["attachments"][0]["actions"][0]["options"]
|
|
assert len(opts) == 5
|
|
|
|
def test_empty_options(self):
|
|
result = build_poll_props("What?", [])
|
|
opts = result["attachments"][0]["actions"][0]["options"]
|
|
assert len(opts) == 1
|
|
|
|
def test_poll_without_id(self):
|
|
result = build_poll_props("Q?", ["A", "B"])
|
|
assert result["props"]["poll_id"] == ""
|
|
|
|
|
|
class TestBuildYesNoPoll:
|
|
def test_basic(self):
|
|
result = build_yes_no_poll_props("Approve?", poll_id="p2")
|
|
assert len(result["attachments"]) == 1
|
|
actions = result["attachments"][0]["actions"]
|
|
assert len(actions) == 2
|
|
assert actions[0]["text"] == "✅ 是"
|
|
assert actions[1]["text"] == "❌ 否"
|
|
assert result["props"]["type"] == "yes_no_poll"
|
|
|
|
|
|
class TestParsePollAction:
|
|
def test_full_context(self):
|
|
context = {"action": "poll_vote", "poll_id": "p1", "vote": "yes"}
|
|
result = parse_poll_action(context)
|
|
assert result["action"] == "poll_vote"
|
|
assert result["poll_id"] == "p1"
|
|
assert result["vote"] == "yes"
|
|
|
|
def test_empty_context(self):
|
|
result = parse_poll_action({})
|
|
assert result["action"] == ""
|
|
assert result["poll_id"] == ""
|
|
assert result["vote"] == ""
|
|
|
|
|
|
# ============================================================
|
|
# cache.py
|
|
# ============================================================
|
|
|
|
|
|
class TestTTLCache:
|
|
def test_set_and_get(self):
|
|
cache = TTLCache(ttl_s=60)
|
|
cache.set("key1", "value1")
|
|
assert cache.get("key1") == "value1"
|
|
|
|
def test_get_missing(self):
|
|
cache = TTLCache()
|
|
assert cache.get("missing") is None
|
|
|
|
def test_delete(self):
|
|
cache = TTLCache()
|
|
cache.set("key1", "val1")
|
|
assert cache.delete("key1") is True
|
|
assert cache.get("key1") is None
|
|
|
|
def test_delete_missing(self):
|
|
cache = TTLCache()
|
|
assert cache.delete("missing") is False
|
|
|
|
def test_ttl_expiry(self):
|
|
cache = TTLCache(ttl_s=0)
|
|
cache.set("key1", "val1")
|
|
assert cache.get("key1") is None
|
|
|
|
def test_clear(self):
|
|
cache = TTLCache()
|
|
cache.set("a", 1)
|
|
cache.set("b", 2)
|
|
cache.clear()
|
|
assert cache.get("a") is None
|
|
assert cache.get("b") is None
|
|
|
|
def test_max_size_eviction(self):
|
|
cache = TTLCache(ttl_s=600, max_size=3)
|
|
for i in range(5):
|
|
cache.set(str(i), i)
|
|
# Oldest entries evicted, should keep 3
|
|
count = sum(1 for i in range(5) if cache.get(str(i)) is not None)
|
|
assert count <= 3
|
|
|
|
def test_value_types(self):
|
|
cache = TTLCache()
|
|
cache.set("int", 42)
|
|
cache.set("dict", {"a": 1})
|
|
cache.set("list", [1, 2, 3])
|
|
assert cache.get("int") == 42
|
|
assert cache.get("dict") == {"a": 1}
|
|
assert cache.get("list") == [1, 2, 3]
|
|
|
|
|
|
class TestLRUCache:
|
|
def test_set_and_get(self):
|
|
cache = LRUCache(max_size=5)
|
|
cache.set("k1", "v1")
|
|
assert cache.get("k1") == "v1"
|
|
|
|
def test_get_updates_lru_order(self):
|
|
cache = LRUCache(max_size=5)
|
|
cache.set("k1", "v1")
|
|
cache.set("k2", "v2")
|
|
cache.get("k1") # makes k1 most-recent
|
|
assert cache.size() == 2
|
|
|
|
def test_max_size_eviction(self):
|
|
cache = LRUCache(max_size=2)
|
|
cache.set("a", 1)
|
|
cache.set("b", 2)
|
|
cache.set("c", 3)
|
|
assert cache.size() <= 2
|
|
assert cache.get("a") is None # 'a' should be evicted
|
|
|
|
def test_ttl_expiry(self):
|
|
cache = LRUCache(ttl_s=0, max_size=10)
|
|
cache.set("k1", "v1")
|
|
assert cache.get("k1") is None
|
|
|
|
def test_clear(self):
|
|
cache = LRUCache()
|
|
cache.set("a", 1)
|
|
cache.clear()
|
|
assert cache.size() == 0
|
|
|
|
def test_size(self):
|
|
cache = LRUCache()
|
|
assert cache.size() == 0
|
|
cache.set("a", 1)
|
|
cache.set("b", 2)
|
|
assert cache.size() == 2
|
|
|
|
|
|
class TestMattermostChannelCache:
|
|
def test_initialization(self):
|
|
mcc = MattermostChannelCache()
|
|
assert mcc.bot_user is not None
|
|
assert mcc.user_by_name is not None
|
|
assert mcc.channel_by_name is not None
|
|
assert mcc.dm_channel is not None
|
|
|
|
def test_clear(self):
|
|
mcc = MattermostChannelCache()
|
|
mcc.user_by_name.set("user1", "id1")
|
|
mcc.clear()
|
|
assert mcc.user_by_name.get("user1") is None
|
|
|
|
def test_stats(self):
|
|
mcc = MattermostChannelCache()
|
|
stats = mcc.stats()
|
|
assert "bot_user" in stats
|
|
assert "user_by_name" in stats
|
|
assert "dm_channel" in stats
|
|
|
|
|
|
class TestSentMessageCache:
|
|
def test_record_and_get(self):
|
|
cache = SentMessageCache(ttl_s=600)
|
|
cache.record("msg1", "chat1")
|
|
entry = cache.get("msg1")
|
|
assert entry["msg_id"] == "msg1"
|
|
assert entry["chat_id"] == "chat1"
|
|
|
|
def test_get_missing(self):
|
|
cache = SentMessageCache()
|
|
assert cache.get("missing") is None
|
|
|
|
def test_get_thread_id(self):
|
|
cache = SentMessageCache()
|
|
cache.record("msg1", "chat1", thread_id="thread1")
|
|
assert cache.get_thread_id("msg1") == "thread1"
|
|
|
|
def test_get_thread_id_missing(self):
|
|
cache = SentMessageCache()
|
|
assert cache.get_thread_id("missing") is None
|
|
|
|
def test_get_thread_id_none(self):
|
|
cache = SentMessageCache()
|
|
cache.record("msg1", "chat1")
|
|
assert cache.get_thread_id("msg1") is None
|
|
|
|
def test_record_all_fields(self):
|
|
cache = SentMessageCache()
|
|
cache.record("msg1", "chat1", channel_id="ch1", thread_id="t1", user_id="u1")
|
|
entry = cache.get("msg1")
|
|
assert entry["channel_id"] == "ch1"
|
|
assert entry["thread_id"] == "t1"
|
|
assert entry["user_id"] == "u1"
|
|
|
|
def test_ttl_expiry(self):
|
|
cache = SentMessageCache(ttl_s=0)
|
|
cache.record("msg1", "chat1")
|
|
assert cache.get("msg1") is None
|
|
|
|
def test_max_size_eviction(self):
|
|
cache = SentMessageCache(max_size=2, ttl_s=600)
|
|
cache.record("a", "ca")
|
|
cache.record("b", "cb")
|
|
cache.record("c", "cc")
|
|
assert cache.size() <= 2
|
|
|
|
def test_clear(self):
|
|
cache = SentMessageCache()
|
|
cache.record("a", "ca")
|
|
cache.clear()
|
|
assert cache.size() == 0
|
|
|
|
def test_size(self):
|
|
cache = SentMessageCache()
|
|
assert cache.size() == 0
|
|
cache.record("a", "ca")
|
|
assert cache.size() == 1
|
|
|
|
|
|
# ============================================================
|
|
# debounce.py
|
|
# ============================================================
|
|
|
|
|
|
class TestInboundDebouncer:
|
|
def test_first_message_accepted(self):
|
|
debouncer = InboundDebouncer(ttl_ms=100)
|
|
assert debouncer.should_process("ch1", "", "hello") is True
|
|
|
|
def test_duplicate_rejected(self):
|
|
debouncer = InboundDebouncer(ttl_ms=5000)
|
|
assert debouncer.should_process("ch1", "", "hello") is True
|
|
assert debouncer.should_process("ch1", "", "hello") is False
|
|
|
|
def test_different_content_accepted(self):
|
|
debouncer = InboundDebouncer(ttl_ms=5000)
|
|
assert debouncer.should_process("ch1", "", "hello") is True
|
|
assert debouncer.should_process("ch1", "", "world") is True
|
|
|
|
def test_different_channel_accepted(self):
|
|
debouncer = InboundDebouncer(ttl_ms=5000)
|
|
assert debouncer.should_process("ch1", "", "hello") is True
|
|
assert debouncer.should_process("ch2", "", "hello") is True
|
|
|
|
def test_different_thread_accepted(self):
|
|
debouncer = InboundDebouncer(ttl_ms=5000)
|
|
assert debouncer.should_process("ch1", "t1", "hello") is True
|
|
assert debouncer.should_process("ch1", "t2", "hello") is True
|
|
|
|
def test_content_truncation(self):
|
|
debouncer = InboundDebouncer(ttl_ms=5000)
|
|
long_msg = "x" * 200
|
|
assert debouncer.should_process("ch1", "", long_msg) is True
|
|
assert debouncer.should_process("ch1", "", long_msg) is False
|
|
|
|
def test_prune_triggers_on_max(self):
|
|
debouncer = InboundDebouncer(ttl_ms=0, max_entries=2)
|
|
debouncer.should_process("a", "", "1")
|
|
debouncer.should_process("b", "", "2")
|
|
debouncer.should_process("c", "", "3")
|
|
assert len(debouncer._entries) <= 2
|
|
|
|
def test_clear(self):
|
|
debouncer = InboundDebouncer(ttl_ms=5000)
|
|
debouncer.should_process("ch1", "", "hello")
|
|
debouncer.clear()
|
|
assert len(debouncer._entries) == 0
|
|
|
|
def test_default_values(self):
|
|
debouncer = InboundDebouncer()
|
|
assert debouncer._ttl_ms == 1000
|
|
assert debouncer._max_entries == 2000
|
|
|
|
|
|
# ============================================================
|
|
# security.py
|
|
# ============================================================
|
|
|
|
|
|
class TestNormalizeAllowEntry:
|
|
def test_pure_user_id(self):
|
|
assert normalize_allow_entry("abc123") == "abc123"
|
|
|
|
def test_at_username(self):
|
|
assert normalize_allow_entry("@bob") == "@bob"
|
|
|
|
def test_user_prefix(self):
|
|
assert normalize_allow_entry("user:xyz") == "xyz"
|
|
|
|
def test_mattermost_prefix(self):
|
|
assert normalize_allow_entry("mattermost:abc") == "abc"
|
|
|
|
def test_channel_prefix_stripped(self):
|
|
assert normalize_allow_entry("channel:xyz") == "xyz"
|
|
|
|
def test_empty_string(self):
|
|
assert normalize_allow_entry("") == ""
|
|
|
|
def test_whitespace_only(self):
|
|
assert normalize_allow_entry(" ") == ""
|
|
|
|
def test_none(self):
|
|
assert normalize_allow_entry(None) == ""
|
|
|
|
def test_strip_whitespace(self):
|
|
assert normalize_allow_entry(" abc ") == "abc"
|
|
|
|
|
|
class TestIsAllowEntryMatch:
|
|
def test_exact_id_match(self):
|
|
assert is_allow_entry_match("abc123", "abc123") is True
|
|
|
|
def test_case_insensitive_id_match(self):
|
|
assert is_allow_entry_match("ABC123", "abc123") is True
|
|
|
|
def test_id_no_match(self):
|
|
assert is_allow_entry_match("abc123", "xyz789") is False
|
|
|
|
def test_at_username_match(self):
|
|
assert is_allow_entry_match("@bob", "any_id", "bob") is True
|
|
|
|
def test_at_username_case_insensitive(self):
|
|
assert is_allow_entry_match("@Bob", "any_id", "bob") is True
|
|
|
|
def test_at_username_no_match(self):
|
|
assert is_allow_entry_match("@alice", "any_id", "bob") is False
|
|
|
|
def test_empty_entry(self):
|
|
assert is_allow_entry_match("", "abc123") is False
|
|
|
|
|
|
class TestMattermostSecurityConfig:
|
|
def test_defaults(self):
|
|
cfg = MattermostSecurityConfig()
|
|
assert cfg.dm_policy == DM_POLICY_PAIRING
|
|
assert cfg.group_policy == GROUP_POLICY_ALLOWLIST
|
|
assert cfg.allow_from == frozenset()
|
|
assert cfg.group_allow_from == frozenset()
|
|
|
|
def test_from_config_full(self):
|
|
config = {
|
|
"dm_policy": "open",
|
|
"group_policy": "disabled",
|
|
"allow_from": ["@bob", "user:alice", "charlie_id"],
|
|
"group_allow_from": ["group1"],
|
|
"dangerously_allow_name_matching": True,
|
|
}
|
|
cfg = MattermostSecurityConfig.from_config(config)
|
|
assert cfg.dm_policy == DM_POLICY_OPEN
|
|
assert cfg.group_policy == GROUP_POLICY_DISABLED
|
|
assert "@bob" in cfg.allow_from
|
|
assert "alice" in cfg.allow_from # user: prefix stripped
|
|
assert "charlie_id" in cfg.allow_from
|
|
assert cfg.dangerously_allow_name_matching is True
|
|
|
|
def test_from_config_invalid_policy_defaults(self):
|
|
config = {"dm_policy": "nonsense", "group_policy": "nonsense"}
|
|
cfg = MattermostSecurityConfig.from_config(config)
|
|
assert cfg.dm_policy == DM_POLICY_PAIRING
|
|
assert cfg.group_policy == GROUP_POLICY_ALLOWLIST
|
|
|
|
|
|
class TestMattermostSecurity:
|
|
def test_dm_policy_open_allows(self):
|
|
sec = MattermostSecurity({"dm_policy": "open"})
|
|
result = sec.check_dm("u1")
|
|
assert result.allowed is True
|
|
|
|
def test_dm_policy_disabled_blocks(self):
|
|
sec = MattermostSecurity({"dm_policy": "disabled"})
|
|
result = sec.check_dm("u1")
|
|
assert result.allowed is False
|
|
|
|
def test_dm_policy_pairing_no_manager_blocks(self):
|
|
sec = MattermostSecurity({"dm_policy": "pairing"})
|
|
result = sec.check_dm("u1")
|
|
assert result.allowed is False
|
|
|
|
def test_dm_policy_pairing_with_manager(self):
|
|
pm = MattermostPairingManager()
|
|
pm.check_or_request("u1")
|
|
pm.approve("u1", pm._pending["u1"].code)
|
|
sec = MattermostSecurity({"dm_policy": "pairing"}, pairing_manager=pm)
|
|
result = sec.check_dm("u1")
|
|
assert result.allowed is True
|
|
|
|
def test_group_policy_open_allows(self):
|
|
sec = MattermostSecurity({"group_policy": "open"})
|
|
result = sec.check_group("u1")
|
|
assert result.allowed is True
|
|
|
|
def test_group_policy_disabled_blocks(self):
|
|
sec = MattermostSecurity({"group_policy": "disabled"})
|
|
result = sec.check_group("u1")
|
|
assert result.allowed is False
|
|
|
|
def test_group_allowlist_match(self):
|
|
sec = MattermostSecurity({"group_policy": "allowlist", "group_allow_from": ["u1"]})
|
|
result = sec.check_group("u1")
|
|
assert result.allowed is True
|
|
|
|
def test_group_allowlist_no_match(self):
|
|
sec = MattermostSecurity({"group_policy": "allowlist", "group_allow_from": ["u1"]})
|
|
result = sec.check_group("u2")
|
|
assert result.allowed is False
|
|
|
|
def test_check_inbound_direct(self):
|
|
sec = MattermostSecurity({"dm_policy": "open"})
|
|
result = sec.check_inbound("direct", "u1")
|
|
assert result.allowed is True
|
|
|
|
def test_check_inbound_group(self):
|
|
sec = MattermostSecurity({"group_policy": "open"})
|
|
result = sec.check_inbound("group", "u1")
|
|
assert result.allowed is True
|
|
|
|
def test_check_inbound_unknown_chat_type(self):
|
|
sec = MattermostSecurity()
|
|
result = sec.check_inbound("invalid_type", "u1")
|
|
assert result.allowed is False
|
|
|
|
def test_check_dm_allowlist_with_name_matching(self):
|
|
sec = MattermostSecurity(
|
|
{
|
|
"dm_policy": "allowlist",
|
|
"allow_from": ["@bob"],
|
|
"dangerously_allow_name_matching": True,
|
|
}
|
|
)
|
|
result = sec.check_dm("any_id", "bob")
|
|
assert result.allowed is True
|
|
|
|
def test_reload_allow_from(self):
|
|
sec = MattermostSecurity({"allow_from": ["u1"]})
|
|
sec.reload_config("allow_from", ["u1", "u2", "@alice"])
|
|
assert len(sec._config.allow_from) == 3
|
|
|
|
def test_reload_dm_policy(self):
|
|
sec = MattermostSecurity({"dm_policy": "open"})
|
|
sec.reload_config("dm_policy", "disabled")
|
|
assert sec._config.dm_policy == DM_POLICY_DISABLED
|
|
|
|
def test_reload_group_policy_invalid_ignores(self):
|
|
sec = MattermostSecurity({"group_policy": "open"})
|
|
sec.reload_config("group_policy", "bad_value")
|
|
assert sec._config.group_policy == GROUP_POLICY_OPEN
|
|
|
|
def test_reload_dangerously_allow_name(self):
|
|
sec = MattermostSecurity()
|
|
sec.reload_config("dangerously_allow_name_matching", True)
|
|
assert sec._config.dangerously_allow_name_matching is True
|
|
|
|
def test_authorize_command_invocation_allowed(self):
|
|
sec = MattermostSecurity({"allow_from": ["admin1"]})
|
|
assert sec.authorize_command_invocation("admin1", "restart") is True
|
|
|
|
def test_authorize_command_invocation_denied(self):
|
|
sec = MattermostSecurity({"allow_from": ["admin1"]})
|
|
assert sec.authorize_command_invocation("not_admin", "restart") is False
|
|
|
|
def test_authorize_command_non_managed(self):
|
|
sec = MattermostSecurity()
|
|
assert sec.authorize_command_invocation("anyone", "help") is True
|
|
|
|
def test_resolve_group_require_mention(self):
|
|
sec = MattermostSecurity({"groups": {"ch1": {"requireMention": False}}})
|
|
assert sec.resolve_group_require_mention("ch1") is False
|
|
assert sec.resolve_group_require_mention("unknown") is None
|
|
|
|
def test_audit_event(self):
|
|
sec = MattermostSecurity()
|
|
sec.audit_event("dm_blocked", "u1", "test detail", "ch1")
|
|
|
|
|
|
# ============================================================
|
|
# secret.py
|
|
# ============================================================
|
|
|
|
|
|
class TestSecretDescriptor:
|
|
def test_defaults(self):
|
|
sd = SecretDescriptor(name="test", description="Test secret")
|
|
assert sd.name == "test"
|
|
assert sd.description == "Test secret"
|
|
assert sd.required is True
|
|
assert sd.env_var == ""
|
|
assert sd.config_key == ""
|
|
|
|
|
|
class TestGetSecretContract:
|
|
def test_returns_list(self):
|
|
result = get_secret_contract()
|
|
assert isinstance(result, list)
|
|
assert len(result) >= 3
|
|
names = {s.name for s in result}
|
|
assert "bot_token" in names
|
|
|
|
|
|
class TestResolveSecret:
|
|
def test_from_config(self):
|
|
config = {"bot_token": "from-config"}
|
|
result = resolve_secret("bot_token", config)
|
|
assert result == "from-config"
|
|
|
|
def test_from_env(self, monkeypatch):
|
|
monkeypatch.setenv("MATTERMOST_BOT_TOKEN", "from-env")
|
|
result = resolve_secret("bot_token", {})
|
|
assert result == "from-env"
|
|
|
|
def test_config_takes_precedence(self, monkeypatch):
|
|
monkeypatch.setenv("MATTERMOST_BOT_TOKEN", "from-env")
|
|
result = resolve_secret("bot_token", {"bot_token": "from-config"})
|
|
assert result == "from-config"
|
|
|
|
def test_unknown_secret(self):
|
|
result = resolve_secret("nonexistent", {})
|
|
assert result is None
|
|
|
|
def test_no_value_returns_none(self):
|
|
result = resolve_secret("bot_token", {})
|
|
assert result is None
|
|
|
|
|
|
class TestListConfiguredSecrets:
|
|
def test_all_missing(self):
|
|
result = list_configured_secrets({})
|
|
for v in result.values():
|
|
assert v == "missing"
|
|
|
|
def test_from_config(self):
|
|
result = list_configured_secrets({"bot_token": "x"})
|
|
assert result["bot_token"] == "config"
|
|
|
|
def test_from_env(self, monkeypatch):
|
|
monkeypatch.setenv("MATTERMOST_BOT_TOKEN", "env-token")
|
|
result = list_configured_secrets({})
|
|
assert result["bot_token"] == "env"
|
|
|
|
|
|
class TestBuildSecretTargetRegistryEntries:
|
|
def test_entries(self):
|
|
entries = build_secret_target_registry_entries()
|
|
assert len(entries) >= 3
|
|
for entry in entries:
|
|
assert "channel_id" in entry
|
|
assert "secret_name" in entry
|
|
assert entry["channel_id"] == "mattermost"
|
|
|
|
|
|
class TestCollectRuntimeConfigAssignments:
|
|
def test_bot_token_from_config(self):
|
|
config = {"bot_token": "test-token"}
|
|
assignments = collect_runtime_config_assignments(config)
|
|
bt = [a for a in assignments if a["secret_name"] == "bot_token"]
|
|
assert len(bt) == 1
|
|
assert bt[0]["source"] == "config"
|
|
|
|
|
|
# ============================================================
|
|
# slash.py
|
|
# ============================================================
|
|
|
|
|
|
class TestSlashCommand:
|
|
def test_defaults(self):
|
|
cmd = SlashCommand(command="/test", description="A test command")
|
|
assert cmd.command == "/test"
|
|
assert cmd.description == "A test command"
|
|
assert cmd.auto_complete is True
|
|
assert cmd.hint == ""
|
|
assert cmd.auto_complete_desc == ""
|
|
assert cmd.auto_complete_hint == ""
|
|
|
|
|
|
class TestDefaultCommands:
|
|
def test_default_commands_present(self):
|
|
triggers = {c.command for c in DEFAULT_COMMANDS}
|
|
assert "/forcepilot" in triggers
|
|
assert "/fp" in triggers
|
|
assert "/model" in triggers
|
|
assert "/clear" in triggers
|
|
assert "/help" in triggers
|
|
|
|
|
|
class TestSlashCommandConfig:
|
|
def test_default_uses_default_commands(self):
|
|
cfg = SlashCommandConfig()
|
|
assert len(cfg.commands) == len(DEFAULT_COMMANDS)
|
|
|
|
def test_from_config_with_custom_dict_commands(self):
|
|
config = {
|
|
"commands": [
|
|
{"command": "/mycmd", "description": "My command", "auto_complete": False},
|
|
],
|
|
"auto_register": False,
|
|
}
|
|
cfg = SlashCommandConfig.from_config(config)
|
|
assert len(cfg.commands) == 1
|
|
assert cfg.commands[0].command == "/mycmd"
|
|
assert cfg.commands[0].auto_complete is False
|
|
|
|
def test_from_config_with_string_commands(self):
|
|
config = {"commands": ["/cmd1", "/cmd2"]}
|
|
cfg = SlashCommandConfig.from_config(config)
|
|
assert len(cfg.commands) == 2
|
|
assert cfg.commands[0].command == "/cmd1"
|
|
|
|
def test_from_config_empty_commands_uses_defaults(self):
|
|
cfg = SlashCommandConfig.from_config({})
|
|
assert len(cfg.commands) == len(DEFAULT_COMMANDS)
|
|
|
|
|
|
class TestBuildCommandPayload:
|
|
def test_basic(self):
|
|
cmd = SlashCommand(command="/test", description="Test desc", hint="[args]")
|
|
payload = build_command_payload(cmd, "team1", "https://cb.example.com")
|
|
assert payload["team_id"] == "team1"
|
|
assert payload["trigger"] == "test"
|
|
assert payload["url"] == "https://cb.example.com"
|
|
assert payload["hint"] == "[args]"
|
|
|
|
def test_without_hint(self):
|
|
cmd = SlashCommand(command="/simple", description="Simple")
|
|
payload = build_command_payload(cmd, "t1", "")
|
|
assert "hint" not in payload
|
|
|
|
|
|
class TestGetSupportedCommands:
|
|
def test_with_config(self):
|
|
cfg = SlashCommandConfig(commands=[SlashCommand(command="/custom", description="Custom")])
|
|
result = get_supported_commands(cfg)
|
|
assert len(result) == 1
|
|
assert result[0].command == "/custom"
|
|
|
|
def test_without_config(self):
|
|
result = get_supported_commands()
|
|
assert len(result) == len(DEFAULT_COMMANDS)
|
|
|
|
|
|
class TestBuildSkillCommands:
|
|
def test_multiple_skills(self):
|
|
skills = ["skill1", "skill2"]
|
|
cmds = build_skill_commands(skills)
|
|
assert len(cmds) == 2
|
|
assert cmds[0].command == "/oc_skill1"
|
|
assert cmds[1].command == "/oc_skill2"
|
|
|
|
def test_empty_skills(self):
|
|
cmds = build_skill_commands([])
|
|
assert cmds == []
|
|
|
|
|
|
class TestManagedSlashCommand:
|
|
def test_defaults(self):
|
|
cmd = ManagedSlashCommand(trigger="test", description="Test")
|
|
assert cmd.trigger == "test"
|
|
assert cmd.managed is True
|
|
assert cmd.method == "POST"
|
|
|
|
|
|
# ============================================================
|
|
# approval.py
|
|
# ============================================================
|
|
|
|
|
|
class TestApprovalRequest:
|
|
def test_expired_check(self):
|
|
req = ApprovalRequest(
|
|
request_id="r1",
|
|
action="restart",
|
|
description="Restarting...",
|
|
params={},
|
|
created_at=time.monotonic() - APPROVAL_TIMEOUT_S - 1,
|
|
)
|
|
assert req.expired is True
|
|
|
|
def test_not_expired(self):
|
|
req = ApprovalRequest(
|
|
request_id="r1",
|
|
action="restart",
|
|
description="Restarting...",
|
|
params={},
|
|
created_at=time.monotonic(),
|
|
)
|
|
assert req.expired is False
|
|
|
|
|
|
class TestApprovalConfig:
|
|
def test_defaults(self):
|
|
cfg = ApprovalConfig()
|
|
assert cfg.enabled is False
|
|
assert cfg.required_for == ["restart", "sudo", "exec", "delete"]
|
|
|
|
def test_from_config(self):
|
|
config = {"approval_enabled": True, "approval_timeout_s": 120, "approval_secret": "secret-key"}
|
|
cfg = ApprovalConfig.from_config(config)
|
|
assert cfg.enabled is True
|
|
assert cfg.approval_timeout_s == 120
|
|
assert cfg.approval_secret == "secret-key"
|
|
|
|
|
|
class TestApprovalManager:
|
|
def test_enabled_property(self):
|
|
mgr = ApprovalManager({"approval_enabled": True})
|
|
assert mgr.enabled is True
|
|
|
|
def test_requires_approval_when_disabled(self):
|
|
mgr = ApprovalManager()
|
|
assert mgr.requires_approval("restart") is False
|
|
|
|
def test_requires_approval_when_enabled(self):
|
|
mgr = ApprovalManager({"approval_enabled": True})
|
|
assert mgr.requires_approval("restart") is True
|
|
assert mgr.requires_approval("help") is False
|
|
|
|
def test_create_and_approve_request(self):
|
|
mgr = ApprovalManager()
|
|
req = mgr.create_request("restart", "Restart the bot", {}, from_user_id="u1")
|
|
assert req.status == "pending"
|
|
assert mgr.approve(req.request_id) is True
|
|
assert req.status == "approved"
|
|
|
|
def test_create_and_deny_request(self):
|
|
mgr = ApprovalManager()
|
|
req = mgr.create_request("restart", "Restart", {})
|
|
assert mgr.deny(req.request_id) is True
|
|
assert req.status == "denied"
|
|
|
|
def test_approve_nonexistent(self):
|
|
mgr = ApprovalManager()
|
|
assert mgr.approve("nonexistent") is False
|
|
|
|
def test_get_request_found(self):
|
|
mgr = ApprovalManager()
|
|
req = mgr.create_request("exec", "Run command", {"cmd": "ls"})
|
|
found = mgr.get_request(req.request_id)
|
|
assert found.request_id == req.request_id
|
|
|
|
def test_get_request_not_found(self):
|
|
mgr = ApprovalManager()
|
|
assert mgr.get_request("nonexistent") is None
|
|
|
|
def test_build_approval_message(self):
|
|
mgr = ApprovalManager()
|
|
req = mgr.create_request("exec", "Run ls", {"cmd": "ls"}, from_user_id="u1")
|
|
msg = mgr.build_approval_message(req)
|
|
assert "需要审批" in msg or "approve" in msg.casefold()
|
|
assert req.request_id in msg
|
|
|
|
def test_verify_hmac_valid(self):
|
|
import hmac as hmac_module
|
|
|
|
mgr = ApprovalManager({"approval_secret": "my-secret"})
|
|
payload = b"test-payload"
|
|
expected = hmac_module.new(b"my-secret", payload, hashlib.sha256).hexdigest()
|
|
assert mgr.verify_hmac(payload, expected) is True
|
|
|
|
def test_verify_hmac_invalid(self):
|
|
mgr = ApprovalManager({"approval_secret": "my-secret"})
|
|
assert mgr.verify_hmac(b"test", "bad-signature") is False
|
|
|
|
def test_verify_hmac_missing_secret(self):
|
|
mgr = ApprovalManager()
|
|
assert mgr.verify_hmac(b"test", "sig") is False
|
|
|
|
def test_approve_expired_request(self):
|
|
mgr = ApprovalManager()
|
|
req = mgr.create_request("restart", "Old request", {})
|
|
req.created_at = time.monotonic() - APPROVAL_TIMEOUT_S - 1
|
|
assert mgr.approve(req.request_id) is False
|
|
|
|
|
|
# ============================================================
|
|
# config_writes.py
|
|
# ============================================================
|
|
|
|
|
|
class TestConfigWritesManager:
|
|
def test_disabled_by_default(self):
|
|
mgr = ConfigWritesManager()
|
|
assert mgr.is_config_writes_enabled() is False
|
|
|
|
def test_write_disabled_returns_false(self):
|
|
mgr = ConfigWritesManager()
|
|
assert mgr.write_config("chatmode", "oncall") is False
|
|
|
|
def test_write_enabled(self):
|
|
mgr = ConfigWritesManager({"config_writes": True})
|
|
assert mgr.is_config_writes_enabled() is True
|
|
|
|
def test_write_allowed_key(self):
|
|
mgr = ConfigWritesManager({"config_writes": True})
|
|
assert mgr.write_config("chatmode", "oncall") is True
|
|
|
|
def test_write_disallowed_key(self):
|
|
mgr = ConfigWritesManager({"config_writes": True})
|
|
assert mgr.write_config("server_url", "evil") is False
|
|
|
|
def test_observer_notification(self):
|
|
mgr = ConfigWritesManager({"config_writes": True})
|
|
notified = []
|
|
|
|
def observer(key, value):
|
|
notified.append((key, value))
|
|
|
|
mgr.register_observer(observer)
|
|
mgr.write_config("chatmode", "oncall")
|
|
assert len(notified) == 1
|
|
assert notified[0] == ("chatmode", "oncall")
|
|
|
|
def test_observer_error_handling(self):
|
|
mgr = ConfigWritesManager({"config_writes": True})
|
|
|
|
def bad_observer(key, value):
|
|
raise RuntimeError("observer error")
|
|
|
|
mgr.register_observer(bad_observer)
|
|
assert mgr.write_config("chatmode", "oncall") is True # still succeeds
|
|
|
|
def test_add_to_allowlist(self):
|
|
mgr = ConfigWritesManager({"config_writes": True, "allow_from": ["u1"]})
|
|
assert mgr.add_to_allowlist("u2") is True
|
|
assert "u2" in mgr._config["allow_from"]
|
|
|
|
def test_add_duplicate_to_allowlist(self):
|
|
mgr = ConfigWritesManager({"config_writes": True, "allow_from": ["u1"]})
|
|
mgr.add_to_allowlist("u1")
|
|
assert mgr._config["allow_from"].count("u1") == 1
|
|
|
|
def test_add_to_allowlist_disabled(self):
|
|
mgr = ConfigWritesManager({"allow_from": ["u1"]})
|
|
assert mgr.add_to_allowlist("u2") is False
|
|
|
|
def test_remove_from_allowlist(self):
|
|
mgr = ConfigWritesManager({"config_writes": True, "allow_from": ["u1", "u2"]})
|
|
assert mgr.remove_from_allowlist("u1") is True
|
|
assert "u1" not in mgr._config["allow_from"]
|
|
|
|
def test_remove_from_allowlist_disabled(self):
|
|
mgr = ConfigWritesManager({"allow_from": ["u1"]})
|
|
assert mgr.remove_from_allowlist("u1") is False
|
|
|
|
def test_get_config(self):
|
|
mgr = ConfigWritesManager({"config_writes": True, "chatmode": "onmessage", "extra": "not_allowed"})
|
|
cfg = mgr.get_config()
|
|
assert "chatmode" in cfg
|
|
assert "extra" not in cfg
|
|
|
|
|
|
# ============================================================
|
|
# pairing.py
|
|
# ============================================================
|
|
|
|
|
|
class TestPairingRequest:
|
|
def test_expired(self):
|
|
req = PairingRequest(user_id="u1", code="ABC123", created_at=time.monotonic() - PAIRING_REQUEST_TTL_S - 1)
|
|
assert req.expired is True
|
|
|
|
def test_not_expired(self):
|
|
req = PairingRequest(user_id="u1", code="ABC123")
|
|
assert req.expired is False
|
|
|
|
|
|
class TestMattermostPairingManager:
|
|
def test_check_or_request_new_user(self):
|
|
pm = MattermostPairingManager()
|
|
result = pm.check_or_request("u1")
|
|
assert result.needs_pairing is True
|
|
assert len(result.code) == PAIRING_CODE_LENGTH
|
|
|
|
def test_check_or_request_twice_same_code(self):
|
|
pm = MattermostPairingManager()
|
|
r1 = pm.check_or_request("u1")
|
|
r2 = pm.check_or_request("u1")
|
|
assert r2.needs_pairing is True
|
|
assert r2.code == r1.code
|
|
|
|
def test_approve_success(self):
|
|
pm = MattermostPairingManager()
|
|
result = pm.check_or_request("u1")
|
|
assert pm.approve("u1", result.code) is True
|
|
|
|
def test_approve_wrong_code(self):
|
|
pm = MattermostPairingManager()
|
|
pm.check_or_request("u1")
|
|
assert pm.approve("u1", "WRONG") is False
|
|
|
|
def test_approve_nonexistent_user(self):
|
|
pm = MattermostPairingManager()
|
|
assert pm.approve("nonexistent", "ABC123") is False
|
|
|
|
def test_deny(self):
|
|
pm = MattermostPairingManager()
|
|
pm.check_or_request("u1")
|
|
assert pm.deny("u1") is True
|
|
|
|
def test_is_approved_after_approve(self):
|
|
pm = MattermostPairingManager()
|
|
result = pm.check_or_request("u1")
|
|
pm.approve("u1", result.code)
|
|
assert pm.is_approved("u1") is True
|
|
|
|
def test_is_approved_false_for_new(self):
|
|
pm = MattermostPairingManager()
|
|
assert pm.is_approved("u1") is False
|
|
|
|
def test_list_pending(self):
|
|
pm = MattermostPairingManager()
|
|
pm.check_or_request("u1")
|
|
pm.check_or_request("u2")
|
|
pending = pm.list_pending()
|
|
assert len(pending) == 2
|
|
|
|
def test_list_approved(self):
|
|
pm = MattermostPairingManager()
|
|
r = pm.check_or_request("u1")
|
|
pm.approve("u1", r.code)
|
|
approved = pm.list_approved()
|
|
assert len(approved) == 1
|
|
|
|
def test_cleanup_expired(self):
|
|
pm = MattermostPairingManager()
|
|
pm.check_or_request("u1")
|
|
pm._pending["u1"].created_at = time.monotonic() - PAIRING_REQUEST_TTL_S - 1
|
|
pm._cleanup_expired()
|
|
assert "u1" not in pm._pending
|
|
|
|
|
|
class TestBuildPairingNotification:
|
|
def test_includes_user_id_and_code(self):
|
|
msg = build_pairing_notification("user123", "ABC123")
|
|
assert "user123" in msg
|
|
assert "ABC123" in msg
|
|
|
|
|
|
# ============================================================
|
|
# send.py
|
|
# ============================================================
|
|
|
|
|
|
class TestBuildPostOptions:
|
|
def test_basic(self):
|
|
from yuxi.channels.models import ChannelIdentity, ChannelResponse, ChannelType
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id="mm",
|
|
channel_type=ChannelType.MATTERMOST,
|
|
channel_user_id="u1",
|
|
channel_chat_id="ch1",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="hello")
|
|
opts = build_post_options(response)
|
|
assert opts["channel_id"] == "ch1"
|
|
assert opts["message"] == "hello"
|
|
assert opts["root_id"] == ""
|
|
|
|
def test_with_reply_to(self):
|
|
from yuxi.channels.models import ChannelIdentity, ChannelResponse, ChannelType
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id="mm",
|
|
channel_type=ChannelType.MATTERMOST,
|
|
channel_user_id="u1",
|
|
channel_chat_id="ch1",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="reply", reply_to_message_id="root1")
|
|
opts = build_post_options(response)
|
|
assert opts["root_id"] == "root1"
|
|
|
|
def test_with_props_and_files(self):
|
|
from yuxi.channels.models import ChannelIdentity, ChannelResponse, ChannelType
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id="mm",
|
|
channel_type=ChannelType.MATTERMOST,
|
|
channel_user_id="u1",
|
|
channel_chat_id="ch1",
|
|
)
|
|
response = ChannelResponse(
|
|
identity=identity,
|
|
content="multi",
|
|
metadata={"props": {"k": "v"}, "file_ids": ["f1", "f2"]},
|
|
)
|
|
opts = build_post_options(response)
|
|
assert opts["props"] == {"k": "v"}
|
|
assert opts["file_ids"] == ["f1", "f2"]
|
|
|
|
|
|
class TestBuildPatchOptions:
|
|
def test_sets_message(self):
|
|
opts = build_patch_options("updated message")
|
|
assert opts["message"] == "updated message"
|
|
|
|
|
|
class TestChunkTextForOutbound:
|
|
def test_short_text_no_chunking(self):
|
|
result = chunk_text_for_outbound("hello world", 100)
|
|
assert result == ["hello world"]
|
|
|
|
def test_text_at_limit_no_chunking(self):
|
|
text = "x" * 100
|
|
result = chunk_text_for_outbound(text, 100)
|
|
assert len(result) == 1
|
|
assert result[0] == text
|
|
|
|
def test_paragraph_split(self):
|
|
text = "para1\n\npara2\n\npara3"
|
|
result = chunk_text_for_outbound(text, 10)
|
|
assert len(result) > 1
|
|
|
|
def test_single_long_paragraph(self):
|
|
text = "abcdefghij\nabcdefghij\nabcdefghij"
|
|
result = chunk_text_for_outbound(text, 12)
|
|
assert len(result) >= 1
|
|
|
|
def test_very_long_single_line(self):
|
|
text = "x" * 500
|
|
result = chunk_text_for_outbound(text, 100)
|
|
assert len(result) >= 5
|
|
# all chunks <= limit
|
|
for chunk in result:
|
|
assert len(chunk) <= 100
|
|
|
|
def test_always_returns_at_least_one_chunk(self):
|
|
result = chunk_text_for_outbound("test", 1)
|
|
assert len(result) >= 1
|
|
|
|
def test_empty_text(self):
|
|
result = chunk_text_for_outbound("", 100)
|
|
assert result == [""]
|
|
|
|
|
|
class TestSplitHard:
|
|
def test_exact_multiple(self):
|
|
result = _split_hard("abcdef", 3)
|
|
assert result == ["abc", "def"]
|
|
|
|
def test_partial_trailing(self):
|
|
result = _split_hard("abcde", 3)
|
|
assert result == ["abc", "de"]
|
|
|
|
def test_single_chunk(self):
|
|
result = _split_hard("ab", 10)
|
|
assert result == ["ab"]
|
|
|
|
|
|
# ============================================================
|
|
# reply.py
|
|
# ============================================================
|
|
|
|
|
|
class TestReplyConfig:
|
|
def test_defaults(self):
|
|
cfg = ReplyConfig()
|
|
assert cfg.mode == REPLY_OFF
|
|
assert cfg.thread_only is False
|
|
|
|
def test_from_config(self):
|
|
cfg = ReplyConfig.from_config({"reply_to_mode": "all", "reply_thread_only": True})
|
|
assert cfg.mode == REPLY_ALL
|
|
assert cfg.thread_only is True
|
|
|
|
def test_from_config_invalid_mode_falls_back(self):
|
|
cfg = ReplyConfig.from_config({"reply_to_mode": "invalid"})
|
|
assert cfg.mode == REPLY_OFF
|
|
|
|
|
|
class TestReplyManager:
|
|
def test_default_mode_is_off(self):
|
|
mgr = ReplyManager()
|
|
assert mgr.mode == REPLY_OFF
|
|
|
|
def test_should_reply_off(self):
|
|
mgr = ReplyManager({"reply_to_mode": "off"})
|
|
assert mgr.should_reply("group") is False
|
|
|
|
def test_should_reply_all(self):
|
|
mgr = ReplyManager({"reply_to_mode": "all"})
|
|
assert mgr.should_reply("group") is True
|
|
assert mgr.should_reply("direct") is False
|
|
|
|
def test_should_reply_batched(self):
|
|
mgr = ReplyManager({"reply_to_mode": "batched"})
|
|
assert mgr.should_reply("group") is True
|
|
|
|
def test_resolve_reply_target_off(self):
|
|
mgr = ReplyManager({"reply_to_mode": "off"})
|
|
assert mgr.resolve_reply_target("ch1", "root1") is None
|
|
|
|
def test_resolve_reply_target_all_with_root(self):
|
|
mgr = ReplyManager({"reply_to_mode": "all"})
|
|
assert mgr.resolve_reply_target("ch1", "root1") == "root1"
|
|
|
|
def test_resolve_reply_target_all_without_root(self):
|
|
mgr = ReplyManager({"reply_to_mode": "all"})
|
|
assert mgr.resolve_reply_target("ch1", None) is None
|
|
|
|
|
|
# ============================================================
|
|
# target_resolution.py
|
|
# ============================================================
|
|
|
|
|
|
class TestParseTarget:
|
|
def test_channel_prefix(self):
|
|
result = parse_target("channel:abc123")
|
|
assert result.target_type == "channel"
|
|
assert result.target_id == "abc123"
|
|
|
|
def test_hash_channel(self):
|
|
result = parse_target("#general")
|
|
assert result.target_type == "channel"
|
|
assert result.target_id == "general"
|
|
|
|
def test_channel_name_prefix(self):
|
|
result = parse_target("channel-name:general")
|
|
assert result.target_type == "channel"
|
|
assert result.target_id == "general"
|
|
|
|
def test_user_prefix(self):
|
|
result = parse_target("user:abc123")
|
|
assert result.target_type == "user"
|
|
assert result.target_id == "abc123"
|
|
|
|
def test_at_username(self):
|
|
result = parse_target("@bob")
|
|
assert result.target_type == "user"
|
|
assert result.target_id == "bob"
|
|
|
|
def test_nick_host_user(self):
|
|
result = parse_target("alice!~alice@server.com")
|
|
assert result.target_type == "user"
|
|
assert result.target_id == "alice"
|
|
|
|
def test_mattermost_prefix(self):
|
|
result = parse_target("mattermost:user:abc123")
|
|
assert result.target_type == "user"
|
|
assert result.target_id == "abc123"
|
|
|
|
def test_dm_channel_id(self):
|
|
dm_id = "abcdefghij0123456789abcdef__abcdefghij0123456789abcdef"
|
|
result = parse_target(dm_id)
|
|
assert result.target_type == "channel"
|
|
|
|
def test_26_char_alnum_channel(self):
|
|
result = parse_target("a" * 26)
|
|
assert result.target_type == "channel"
|
|
|
|
def test_empty_input(self):
|
|
result = parse_target("")
|
|
assert result.target_type == "unknown"
|
|
|
|
def test_none_input(self):
|
|
result = parse_target(None)
|
|
assert result.target_type == "unknown"
|
|
|
|
def test_unknown(self):
|
|
result = parse_target("random-text")
|
|
assert result.target_type == "unknown"
|
|
|
|
def test_whitespace_only(self):
|
|
result = parse_target(" ")
|
|
assert result.target_type == "unknown"
|
|
|
|
def test_is_channel_property(self):
|
|
result = parse_target("channel:ch1")
|
|
assert result.is_channel is True
|
|
assert result.is_user is False
|
|
|
|
def test_is_user_property(self):
|
|
result = parse_target("user:u1")
|
|
assert result.is_channel is False
|
|
assert result.is_user is True
|
|
|
|
|
|
class TestLooksLikeMattermostTargetId:
|
|
def test_26_char_alnum(self):
|
|
assert looks_like_mattermost_target_id("a" * 26) is True
|
|
|
|
def test_dm_channel_id(self):
|
|
dm = "abcdefghij0123456789abcdef__abcdefghij0123456789abcdef"
|
|
assert looks_like_mattermost_target_id(dm) is True
|
|
|
|
def test_channel_prefix(self):
|
|
assert looks_like_mattermost_target_id("channel:abc") is True
|
|
|
|
def test_user_prefix(self):
|
|
assert looks_like_mattermost_target_id("user:abc") is True
|
|
|
|
def test_channel_name(self):
|
|
assert looks_like_mattermost_target_id("channel-name:general") is True
|
|
|
|
def test_random_string(self):
|
|
assert looks_like_mattermost_target_id("random") is False
|
|
|
|
def test_empty(self):
|
|
assert looks_like_mattermost_target_id("") is False
|
|
assert looks_like_mattermost_target_id(None) is False
|
|
|
|
|
|
class TestResolveChannelTarget:
|
|
def test_channel_prefix(self):
|
|
result = resolve_channel_target("channel:ch1")
|
|
assert result == "ch1"
|
|
|
|
def test_with_known_channels(self):
|
|
result = resolve_channel_target("general", {"ch1": "general", "ch2": "random"})
|
|
assert result == "ch1"
|
|
|
|
def test_channel_name_format(self):
|
|
result = resolve_channel_target("channel-name:general", {})
|
|
assert result == "general"
|
|
|
|
def test_no_match(self):
|
|
result = resolve_channel_target("unknown", {})
|
|
assert result == "unknown"
|
|
|
|
def test_empty(self):
|
|
assert resolve_channel_target("") is None
|
|
|
|
|
|
class TestResolveUserTarget:
|
|
def test_user_prefix(self):
|
|
result = resolve_user_target("user:u1")
|
|
assert result == "u1"
|
|
|
|
def test_with_known_users(self):
|
|
result = resolve_user_target("@bob", {"user1": "bob", "user2": "alice"})
|
|
assert result == "user1"
|
|
|
|
def test_empty(self):
|
|
assert resolve_user_target("") is None
|
|
|
|
|
|
# ============================================================
|
|
# monitor.py
|
|
# ============================================================
|
|
|
|
|
|
class TestMentionGateConfig:
|
|
def test_defaults(self):
|
|
cfg = MentionGateConfig()
|
|
assert cfg.chat_mode == MODE_ONMESSAGE
|
|
assert cfg.require_mention is True
|
|
assert len(cfg.onchar_prefixes) > 0
|
|
|
|
def test_from_config_with_undefined_chatmode(self):
|
|
cfg = MentionGateConfig.from_config({"chatmode": "undefined"})
|
|
assert cfg.chat_mode == MODE_ONMESSAGE
|
|
|
|
def test_from_config_custom_prefixes(self):
|
|
cfg = MentionGateConfig.from_config({"onchar_prefixes": ["$", "%"]})
|
|
assert "$" in cfg.onchar_prefixes
|
|
assert "%" in cfg.onchar_prefixes
|
|
|
|
def test_from_config_empty_prefixes(self):
|
|
cfg = MentionGateConfig.from_config({"onchar_prefixes": []})
|
|
assert len(cfg.onchar_prefixes) > 0
|
|
|
|
|
|
class TestMentionGate:
|
|
def test_dm_onmessage_allows(self):
|
|
mg = MentionGate()
|
|
result = mg.check("direct", "hello", False)
|
|
assert result.should_respond is True
|
|
|
|
def test_dm_onchar_no_prefix_blocks(self):
|
|
mg = MentionGate({"chatmode": "onchar"})
|
|
result = mg.check("direct", "hello", False)
|
|
assert result.should_respond is False
|
|
|
|
def test_dm_onchar_with_prefix_allows(self):
|
|
mg = MentionGate({"chatmode": "onchar", "onchar_prefixes": [">"]})
|
|
result = mg.check("direct", ">help", False)
|
|
assert result.should_respond is True
|
|
|
|
def test_group_onmessage_without_mention_blocks(self):
|
|
mg = MentionGate({"require_mention": True})
|
|
result = mg.check("group", "hello", False)
|
|
assert result.should_respond is False
|
|
|
|
def test_group_onmessage_with_mention_allows(self):
|
|
mg = MentionGate({"require_mention": True})
|
|
result = mg.check("group", "@bot hello", True)
|
|
assert result.should_respond is True
|
|
|
|
def test_group_oncall_blocks(self):
|
|
mg = MentionGate({"chatmode": "oncall"})
|
|
result = mg.check("group", "hello", True)
|
|
assert result.should_respond is False
|
|
|
|
def test_group_onchar_without_prefix_blocks(self):
|
|
mg = MentionGate({"chatmode": "onchar", "onchar_prefixes": [">"]})
|
|
result = mg.check("group", "hello", True)
|
|
assert result.should_respond is False
|
|
|
|
def test_group_onchar_with_prefix_allows(self):
|
|
mg = MentionGate({"chatmode": "onchar", "onchar_prefixes": [">"]})
|
|
result = mg.check("group", "> hello", False)
|
|
assert result.should_respond is True
|
|
|
|
def test_reload_chatmode(self):
|
|
mg = MentionGate({"chatmode": "onmessage"})
|
|
mg.reload_config("chatmode", "onchar")
|
|
assert mg.chat_mode == MODE_ONCHAR
|
|
|
|
def test_reload_invalid_chatmode_ignored(self):
|
|
mg = MentionGate({"chatmode": "onmessage"})
|
|
mg.reload_config("chatmode", "invalid")
|
|
assert mg.chat_mode == MODE_ONMESSAGE
|
|
|
|
def test_reload_require_mention(self):
|
|
mg = MentionGate({"require_mention": True})
|
|
mg.reload_config("require_mention", False)
|
|
assert mg.require_mention is False
|
|
|
|
def test_reload_onchar_prefixes(self):
|
|
mg = MentionGate({"onchar_prefixes": [">"]})
|
|
mg.reload_config("onchar_prefixes", ["$", "%"])
|
|
assert "$" in mg._config.onchar_prefixes
|
|
|
|
|
|
class TestIsControlCommand:
|
|
def test_restart(self):
|
|
assert is_control_command("/restart") is True
|
|
|
|
def test_sudo(self):
|
|
assert is_control_command("/sudo rm -rf /") is True
|
|
|
|
def test_exec(self):
|
|
assert is_control_command("/exec cmd") is True
|
|
|
|
def test_delete(self):
|
|
assert is_control_command("/delete something") is True
|
|
|
|
def test_config(self):
|
|
assert is_control_command("/config set key=val") is True
|
|
|
|
def test_approve(self):
|
|
assert is_control_command("/approve req123") is True
|
|
|
|
def test_deny(self):
|
|
assert is_control_command("/deny req123") is True
|
|
|
|
def test_help_not_control(self):
|
|
assert is_control_command("/help") is False
|
|
|
|
def test_whitespace_padded(self):
|
|
assert is_control_command(" /restart ") is True
|
|
|
|
def test_empty(self):
|
|
assert is_control_command("") is False
|
|
|
|
|
|
class TestResolveControlCommandGate:
|
|
def test_control_command_in_dm(self):
|
|
assert resolve_control_command_gate("/restart", "direct") is True
|
|
|
|
def test_control_command_in_group_mentioned(self):
|
|
assert resolve_control_command_gate("/restart", "group", True) is True
|
|
|
|
def test_control_command_in_group_not_mentioned(self):
|
|
assert resolve_control_command_gate("/restart", "group", False) is False
|
|
|
|
def test_non_control_command(self):
|
|
assert resolve_control_command_gate("/help", "direct") is False
|
|
|
|
|
|
# ============================================================
|
|
# model_picker.py
|
|
# ============================================================
|
|
|
|
|
|
class TestModelOption:
|
|
def test_button_id_deterministic(self):
|
|
m = ModelOption("gpt-4o", "GPT-4o", "openai")
|
|
bid = m.button_id
|
|
assert len(bid) == 8
|
|
|
|
def test_button_id_same_for_same_model(self):
|
|
m1 = ModelOption("gpt-4o", "GPT-4o", "openai")
|
|
m2 = ModelOption("gpt-4o", "GPT-4o", "openai")
|
|
assert m1.button_id == m2.button_id
|
|
|
|
def test_button_id_different_for_different_model(self):
|
|
m1 = ModelOption("gpt-4o", "GPT-4o")
|
|
m2 = ModelOption("gpt-4o-mini", "GPT-4o Mini")
|
|
assert m1.button_id != m2.button_id
|
|
|
|
|
|
class TestGetDefaultModelOptions:
|
|
def test_returns_list(self):
|
|
models = get_default_model_options()
|
|
assert len(models) >= 4
|
|
for m in models:
|
|
assert m.model_id
|
|
assert m.display_name
|
|
|
|
|
|
class TestGetAvailableProviders:
|
|
def test_default_models(self):
|
|
providers = get_available_providers()
|
|
assert "openai" in providers
|
|
|
|
def test_empty_models(self):
|
|
providers = get_available_providers([])
|
|
assert providers == []
|
|
|
|
def test_deduplicates(self):
|
|
models = [
|
|
ModelOption("a", "A", "p1"),
|
|
ModelOption("b", "B", "p1"),
|
|
ModelOption("c", "C", "p2"),
|
|
]
|
|
providers = get_available_providers(models)
|
|
assert providers == ["p1", "p2"]
|
|
|
|
|
|
class TestBuildModelPickerActions:
|
|
def test_provider_state(self):
|
|
actions = build_model_picker_actions(state=PickerState.PROVIDERS)
|
|
# should have provider buttons + view all
|
|
assert len(actions) >= 2
|
|
|
|
def test_list_state_single_page(self):
|
|
models = get_default_model_options()
|
|
actions = build_model_picker_actions(models, state=PickerState.LIST, page=0)
|
|
# should have model buttons + back button
|
|
assert any(a["id"] == "back_to_providers" for a in actions)
|
|
|
|
def test_list_state_with_pagination(self):
|
|
many_models = [ModelOption(f"m{i}", f"M{i}", "p") for i in range(MODEL_PICKER_PAGE_SIZE + 2)]
|
|
actions = build_model_picker_actions(many_models, state=PickerState.LIST, page=0)
|
|
assert any(a["id"] == "next_page" for a in actions)
|
|
assert not any(a["id"] == "prev_page" for a in actions)
|
|
|
|
def test_prev_page_present_on_later_page(self):
|
|
many_models = [ModelOption(f"m{i}", f"M{i}", "p") for i in range(MODEL_PICKER_PAGE_SIZE + 2)]
|
|
actions = build_model_picker_actions(many_models, state=PickerState.LIST, page=1)
|
|
assert any(a["id"] == "prev_page" for a in actions)
|
|
assert not any(a["id"] == "next_page" for a in actions)
|
|
|
|
|
|
class TestBuildModelPickerAttachment:
|
|
def test_provider_state(self):
|
|
attachment = build_model_picker_attachment(state=PickerState.PROVIDERS)
|
|
assert "模型选择" in attachment["title"]
|
|
assert len(attachment["actions"]) > 0
|
|
|
|
def test_list_state(self):
|
|
attachment = build_model_picker_attachment(state=PickerState.LIST, page=0)
|
|
assert "模型" in attachment["title"]
|
|
|
|
def test_with_provider_filter(self):
|
|
attachment = build_model_picker_attachment(provider_filter="openai")
|
|
assert "模型" in attachment["title"]
|
|
|
|
def test_current_model_marker(self):
|
|
attachment = build_model_picker_attachment(
|
|
current_model="gpt-4o-mini",
|
|
state=PickerState.LIST,
|
|
page=0,
|
|
)
|
|
assert "Mini" in attachment["text"]
|
|
assert "✅" in attachment["text"]
|
|
|
|
|
|
class TestBuildModelSelectAttachment:
|
|
def test_openai(self):
|
|
attachment = build_model_select_attachment("openai")
|
|
assert "模型" in attachment["title"]
|
|
assert len(attachment["actions"]) > 0
|
|
|
|
|
|
# ============================================================
|
|
# session.py
|
|
# ============================================================
|
|
|
|
|
|
class TestResolveChatId:
|
|
def test_direct_message(self):
|
|
post = {"channel_id": "ch1", "user_id": "u1"}
|
|
channel_data = {"type": "D"}
|
|
result = resolve_chat_id(post, channel_data)
|
|
assert result == "dm_u1"
|
|
|
|
def test_group_channel(self):
|
|
post = {"channel_id": "ch1", "user_id": "u1"}
|
|
channel_data = {"type": "O"}
|
|
result = resolve_chat_id(post, channel_data)
|
|
assert result == "channel_ch1"
|
|
|
|
def test_thread_message(self):
|
|
post = {"channel_id": "ch1", "user_id": "u1", "root_id": "root1"}
|
|
channel_data = {"type": "O"}
|
|
result = resolve_chat_id(post, channel_data)
|
|
assert result == "channel_ch1:thread_root1"
|
|
|
|
|
|
class TestResolveChatType:
|
|
def test_direct(self):
|
|
post = {"channel_id": "ch1"}
|
|
channel_data = {"type": "D"}
|
|
result = resolve_chat_type(post, channel_data)
|
|
assert result == ChatType.DIRECT
|
|
|
|
def test_group(self):
|
|
post = {"channel_id": "ch1"}
|
|
channel_data = {"type": "O"}
|
|
result = resolve_chat_type(post, channel_data)
|
|
assert result == ChatType.GROUP
|
|
|
|
def test_thread(self):
|
|
post = {"channel_id": "ch1", "root_id": "r1"}
|
|
channel_data = {"type": "D"}
|
|
result = resolve_chat_type(post, channel_data)
|
|
assert result == ChatType.THREAD
|
|
|
|
|
|
class TestBuildThreadId:
|
|
def test_new_style_without_root(self):
|
|
result = build_thread_id("ch1", None)
|
|
assert result == "channel_ch1"
|
|
|
|
def test_new_style_with_root(self):
|
|
result = build_thread_id("ch1", "root1")
|
|
assert result == "channel_ch1:thread_root1"
|
|
|
|
def test_old_style_direct(self):
|
|
result = build_thread_id(ChatType.DIRECT, user_id="u1", agent_id="main")
|
|
assert "dm" in result
|
|
assert "u1" in result
|
|
|
|
def test_old_style_group(self):
|
|
result = build_thread_id(ChatType.GROUP, channel_id="ch1", agent_id="main")
|
|
assert "ch1" in result
|
|
|
|
def test_old_style_thread(self):
|
|
result = build_thread_id(ChatType.THREAD, channel_id="ch1", agent_id="main")
|
|
assert "ch1" in result
|
|
|
|
|
|
# ============================================================
|
|
# directory.py
|
|
# ============================================================
|
|
|
|
|
|
class TestDirectoryPeer:
|
|
def test_display_name_fallback_to_username(self):
|
|
peer = DirectoryPeer(user_id="u1", username="bob")
|
|
assert peer.display_name == "bob"
|
|
|
|
def test_display_name_prefers_nickname(self):
|
|
peer = DirectoryPeer(user_id="u1", username="bob", nickname="Bobby")
|
|
assert peer.display_name == "Bobby"
|
|
|
|
def test_opaque_id(self):
|
|
peer = DirectoryPeer(user_id="u1", username="b")
|
|
assert peer.opaque_id == "user:u1"
|
|
|
|
|
|
class TestDirectoryGroup:
|
|
def test_defaults(self):
|
|
group = DirectoryGroup(channel_id="ch1", name="general")
|
|
assert group.group_type == "O"
|
|
assert group.member_count == 0
|
|
|
|
def test_opaque_id(self):
|
|
group = DirectoryGroup(channel_id="ch1", name="g")
|
|
assert group.opaque_id == "channel:ch1"
|
|
|
|
|
|
class TestDirectorySnapshot:
|
|
def test_peer_count(self):
|
|
snap = DirectorySnapshot()
|
|
assert snap.peer_count == 0
|
|
|
|
def test_group_count(self):
|
|
snap = DirectorySnapshot(groups=[DirectoryGroup("ch1", "g")])
|
|
assert snap.group_count == 1
|
|
|
|
def test_find_peer(self):
|
|
p = DirectoryPeer("u1", "bob")
|
|
snap = DirectorySnapshot(peers=[p])
|
|
assert snap.find_peer("u1") == p
|
|
assert snap.find_peer("u2") is None
|
|
|
|
def test_find_group(self):
|
|
g = DirectoryGroup("ch1", "general")
|
|
snap = DirectorySnapshot(groups=[g])
|
|
assert snap.find_group("ch1") == g
|
|
assert snap.find_group("ch2") is None
|
|
|
|
def test_list_peers_by_name(self):
|
|
p1 = DirectoryPeer("u1", "bob", "Bobby")
|
|
p2 = DirectoryPeer("u2", "alice")
|
|
snap = DirectorySnapshot(peers=[p1, p2])
|
|
results = snap.list_peers_by_name("bob")
|
|
assert len(results) == 1
|
|
assert results[0].user_id == "u1"
|
|
|
|
def test_list_groups_by_name(self):
|
|
g1 = DirectoryGroup("ch1", "general", "General")
|
|
g2 = DirectoryGroup("ch2", "random")
|
|
snap = DirectorySnapshot(groups=[g1, g2])
|
|
results = snap.list_groups_by_name("gen")
|
|
assert len(results) == 1
|
|
assert results[0].channel_id == "ch1"
|
|
|
|
|
|
class TestBuildPeerFromUserData:
|
|
def test_full_data(self):
|
|
user = {
|
|
"id": "u1",
|
|
"username": "bob",
|
|
"nickname": "Bobby",
|
|
"email": "bob@test.com",
|
|
"first_name": "Bob",
|
|
"last_name": "Smith",
|
|
}
|
|
peer = build_peer_from_user_data(user)
|
|
assert peer.user_id == "u1"
|
|
assert peer.username == "bob"
|
|
assert peer.nickname == "Bobby"
|
|
assert peer.email == "bob@test.com"
|
|
assert peer.first_name == "Bob"
|
|
assert peer.last_name == "Smith"
|
|
|
|
def test_empty_data(self):
|
|
peer = build_peer_from_user_data({})
|
|
assert peer.user_id == ""
|
|
|
|
|
|
class TestBuildGroupFromChannelData:
|
|
def test_full_data(self):
|
|
ch = {"id": "ch1", "name": "general", "display_name": "General", "type": "P"}
|
|
group = build_group_from_channel_data(ch)
|
|
assert group.channel_id == "ch1"
|
|
assert group.name == "general"
|
|
assert group.group_type == "P"
|
|
|
|
def test_empty_data(self):
|
|
group = build_group_from_channel_data({})
|
|
assert group.channel_id == ""
|