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重连相关测试
1816 lines
60 KiB
Python
1816 lines
60 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.urbit.cites import cite_to_text, parse_cite
|
|
from yuxi.channels.adapters.urbit.errors import (
|
|
UrbitAuthError,
|
|
UrbitError,
|
|
UrbitHttpError,
|
|
UrbitUrlError,
|
|
)
|
|
from yuxi.channels.adapters.urbit.format import (
|
|
build_media_content,
|
|
is_valid_ship,
|
|
)
|
|
from yuxi.channels.adapters.urbit.history import MessageCache
|
|
from yuxi.channels.adapters.urbit.invites import InviteManager
|
|
from yuxi.channels.adapters.urbit.monitor import (
|
|
_detect_bot_mentions,
|
|
_extract_cites_from_message,
|
|
_is_self_message,
|
|
)
|
|
from yuxi.channels.adapters.urbit.probe import (
|
|
fetch_with_ssrf_guard,
|
|
validate_urbit_url,
|
|
)
|
|
from yuxi.channels.adapters.urbit.session import (
|
|
cleanup_expired_sessions,
|
|
clear_unsafe_sessions,
|
|
detect_unsafe_session,
|
|
get_unsafe_sessions,
|
|
)
|
|
from yuxi.channels.adapters.urbit.settings import SettingsStore
|
|
from yuxi.channels.adapters.urbit.story import (
|
|
markdown_to_story,
|
|
story_to_plain_text,
|
|
)
|
|
from yuxi.channels.adapters.urbit.summarizer import Summarizer
|
|
from yuxi.channels.adapters.urbit.targets import (
|
|
format_target_hint,
|
|
parse_tlon_target,
|
|
)
|
|
from yuxi.channels.adapters.urbit.threads import ThreadManager
|
|
from yuxi.channels.adapters.urbit.upload import (
|
|
assert_safe_upload_result_url,
|
|
assert_trusted_upload_url,
|
|
sanitize_filename,
|
|
)
|
|
from yuxi.channels.adapters.urbit.vision import build_vision_context
|
|
from yuxi.channels.models import (
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelType,
|
|
ChatType,
|
|
MentionsInfo,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# errors.py
|
|
# =============================================================================
|
|
|
|
|
|
class TestUrbitErrors:
|
|
def test_urbit_error_base(self):
|
|
err = UrbitError("test error")
|
|
assert str(err) == "test error"
|
|
assert isinstance(err, Exception)
|
|
|
|
def test_urbit_url_error(self):
|
|
err = UrbitUrlError("bad url")
|
|
assert str(err) == "bad url"
|
|
assert isinstance(err, UrbitError)
|
|
|
|
def test_urbit_http_error_with_message(self):
|
|
err = UrbitHttpError(500, "Server error")
|
|
assert err.status_code == 500
|
|
assert str(err) == "Server error"
|
|
assert isinstance(err, UrbitError)
|
|
|
|
def test_urbit_http_error_default_message(self):
|
|
err = UrbitHttpError(404)
|
|
assert err.status_code == 404
|
|
assert "404" in str(err)
|
|
assert isinstance(err, UrbitError)
|
|
|
|
def test_urbit_auth_error(self):
|
|
err = UrbitAuthError()
|
|
assert isinstance(err, UrbitError)
|
|
|
|
|
|
# =============================================================================
|
|
# cites.py - cite_to_text
|
|
# =============================================================================
|
|
|
|
|
|
class TestCiteToText:
|
|
def test_chan_cite(self):
|
|
result = cite_to_text({"type": "chan", "group": "test", "chan": "mychan"})
|
|
assert "test/mychan" in result
|
|
assert result.startswith("[channel:")
|
|
|
|
def test_group_cite(self):
|
|
result = cite_to_text({"type": "group", "group": "mygroup"})
|
|
assert result == "[group: mygroup]"
|
|
|
|
def test_desk_cite(self):
|
|
result = cite_to_text({"type": "desk", "desk": "mydesk"})
|
|
assert result == "[desk: mydesk]"
|
|
|
|
def test_bait_cite(self):
|
|
result = cite_to_text({"type": "bait", "bait": "mybait"})
|
|
assert result == "[bait: mybait]"
|
|
|
|
def test_unknown_cite_type(self):
|
|
result = cite_to_text({"type": "unknown"})
|
|
assert result == "[unknown reference]"
|
|
|
|
def test_parse_cite_group(self):
|
|
result = parse_cite({"cite": "group", "group": "test"})
|
|
assert result is not None
|
|
assert result["type"] == "group"
|
|
|
|
def test_parse_cite_desk(self):
|
|
result = parse_cite({"cite": "desk", "desk": "mydesk"})
|
|
assert result is not None
|
|
assert result["type"] == "desk"
|
|
|
|
def test_parse_cite_bait(self):
|
|
result = parse_cite({"cite": "bait", "bait": "mybait"})
|
|
assert result is not None
|
|
assert result["type"] == "bait"
|
|
|
|
|
|
# =============================================================================
|
|
# format.py - edge cases for build_media_content
|
|
# =============================================================================
|
|
|
|
|
|
class TestBuildMediaContentEdgeCases:
|
|
def test_video(self):
|
|
result = build_media_content("video", "https://video.com/v.mp4")
|
|
assert result[0]["video"]["src"] == "https://video.com/v.mp4"
|
|
|
|
def test_audio(self):
|
|
result = build_media_content("audio", "https://audio.com/a.mp3")
|
|
assert result[0]["audio"]["src"] == "https://audio.com/a.mp3"
|
|
|
|
def test_reference(self):
|
|
result = build_media_content("reference", "some text")
|
|
assert result[0]["reference"]["text"] == "some text"
|
|
|
|
|
|
# =============================================================================
|
|
# format.py - is_valid_ship edge cases
|
|
# =============================================================================
|
|
|
|
|
|
class TestShipFormatEdgeCases:
|
|
def test_is_valid_ship_uppercase(self):
|
|
assert is_valid_ship("~ZOD")
|
|
|
|
def test_is_valid_ship_single_word(self):
|
|
assert is_valid_ship("~zod")
|
|
|
|
def test_is_valid_ship_multi_word(self):
|
|
assert is_valid_ship("~sampel-palnet")
|
|
|
|
def test_is_valid_ship_invalid_short(self):
|
|
assert not is_valid_ship("~ab")
|
|
|
|
def test_is_valid_ship_invalid_numbers(self):
|
|
assert not is_valid_ship("~12")
|
|
|
|
def test_is_valid_ship_invalid_special(self):
|
|
assert not is_valid_ship("~zod!")
|
|
|
|
|
|
# =============================================================================
|
|
# story.py - edge cases for markdown_to_story and story_to_plain_text
|
|
# =============================================================================
|
|
|
|
|
|
class TestMarkdownToStoryEdgeCases:
|
|
def test_empty_content(self):
|
|
result = markdown_to_story("")
|
|
assert result == [{"text": ""}]
|
|
|
|
def test_code_block_no_lang(self):
|
|
result = markdown_to_story("```\ncode\n```")
|
|
has_code = any(
|
|
isinstance(item, dict)
|
|
and "code" in item
|
|
and item["code"]["code"] == "code"
|
|
for item in result
|
|
)
|
|
assert has_code
|
|
|
|
def test_code_block_with_lang(self):
|
|
result = markdown_to_story("```python\nprint('hi')\n```")
|
|
has_python_code = any(
|
|
isinstance(item, dict)
|
|
and "code" in item
|
|
and item["code"]["lang"] == "python"
|
|
for item in result
|
|
)
|
|
assert has_python_code
|
|
|
|
def test_header_h1(self):
|
|
result = markdown_to_story("# Title")
|
|
has_h1 = any(
|
|
isinstance(item, dict)
|
|
and "header" in item
|
|
and item["header"]["tag"] == "h1"
|
|
for item in result
|
|
)
|
|
assert has_h1
|
|
|
|
def test_header_h2(self):
|
|
result = markdown_to_story("## Section")
|
|
has_h2 = any(
|
|
isinstance(item, dict)
|
|
and "header" in item
|
|
and item["header"]["tag"] == "h2"
|
|
for item in result
|
|
)
|
|
assert has_h2
|
|
|
|
def test_header_h3(self):
|
|
result = markdown_to_story("### Subsection")
|
|
has_h3 = any(
|
|
isinstance(item, dict)
|
|
and "header" in item
|
|
and item["header"]["tag"] == "h3"
|
|
for item in result
|
|
)
|
|
assert has_h3
|
|
|
|
def test_bold_text(self):
|
|
result = markdown_to_story("**bold**")
|
|
assert len(result) > 0
|
|
|
|
def test_italic_text(self):
|
|
result = markdown_to_story("*italic*")
|
|
assert len(result) > 0
|
|
|
|
def test_strikethrough_text(self):
|
|
result = markdown_to_story("~~strike~~")
|
|
assert len(result) > 0
|
|
|
|
def test_inline_code(self):
|
|
result = markdown_to_story("`code`")
|
|
assert len(result) > 0
|
|
|
|
def test_link(self):
|
|
result = markdown_to_story("[link](https://example.com)")
|
|
assert len(result) > 0
|
|
|
|
def test_unordered_list(self):
|
|
result = markdown_to_story("- item1\n- item2\n**trigger**")
|
|
has_listing = any(
|
|
isinstance(item, dict) and "listing" in item for item in result
|
|
)
|
|
assert has_listing
|
|
|
|
def test_ordered_list(self):
|
|
result = markdown_to_story("1. first\n2. second\n**trigger**")
|
|
has_listing = any(
|
|
isinstance(item, dict) and "listing" in item for item in result
|
|
)
|
|
assert has_listing
|
|
|
|
def test_multiline_text(self):
|
|
result = markdown_to_story("line1\nline2")
|
|
assert isinstance(result, list)
|
|
has_break = any(
|
|
isinstance(item, dict) and "break" in item for item in result
|
|
) or len(result) >= 2
|
|
assert has_break
|
|
|
|
def test_rule_separator(self):
|
|
result = markdown_to_story("text\n---\nmore text")
|
|
found_rule = False
|
|
for item in result:
|
|
if isinstance(item, dict) and "block" in item:
|
|
if item["block"].get("rule") is None:
|
|
found_rule = True
|
|
assert found_rule
|
|
|
|
|
|
class TestStoryToPlainText:
|
|
def test_simple_text(self):
|
|
story = [{"text": "hello"}]
|
|
result = story_to_plain_text(story)
|
|
assert result == "hello"
|
|
|
|
def test_bold(self):
|
|
story = [{"bold": [{"text": "bold text"}]}]
|
|
result = story_to_plain_text(story)
|
|
assert result == "bold text"
|
|
|
|
def test_italic(self):
|
|
story = [{"italics": [{"text": "italic text"}]}]
|
|
result = story_to_plain_text(story)
|
|
assert result == "italic text"
|
|
|
|
def test_strike(self):
|
|
story = [{"strike": [{"text": "strike text"}]}]
|
|
result = story_to_plain_text(story)
|
|
assert result == "strike text"
|
|
|
|
def test_inline_code(self):
|
|
story = [{"inline-code": "code"}]
|
|
result = story_to_plain_text(story)
|
|
assert result == "code"
|
|
|
|
def test_header(self):
|
|
story = [{"header": {"content": "Title", "tag": "h1"}}]
|
|
result = story_to_plain_text(story)
|
|
assert result == "Title"
|
|
|
|
def test_code_block(self):
|
|
story = [{"code": {"code": "print('hi')", "lang": "python"}}]
|
|
result = story_to_plain_text(story)
|
|
assert "print('hi')" in result
|
|
|
|
def test_link(self):
|
|
story = [{"link": {"content": "click", "href": "https://example.com"}}]
|
|
result = story_to_plain_text(story)
|
|
assert "click" in result
|
|
|
|
def test_break(self):
|
|
story = [{"text": "a"}, {"break": None}, {"text": "b"}]
|
|
result = story_to_plain_text(story)
|
|
assert "\n" in result
|
|
|
|
def test_ship(self):
|
|
story = [{"ship": "~zod"}]
|
|
result = story_to_plain_text(story)
|
|
assert result == "~zod"
|
|
|
|
def test_listing(self):
|
|
story = [
|
|
{
|
|
"listing": {
|
|
"type": "unordered",
|
|
"items": ["item1", "item2"],
|
|
"contents": [],
|
|
}
|
|
}
|
|
]
|
|
result = story_to_plain_text(story)
|
|
assert "item1" in result
|
|
assert "item2" in result
|
|
|
|
def test_blockquote(self):
|
|
story = [{"blockquote": [{"text": "quoted text"}]}]
|
|
result = story_to_plain_text(story)
|
|
assert "quoted text" in result
|
|
|
|
def test_empty_list(self):
|
|
result = story_to_plain_text([])
|
|
assert result == ""
|
|
|
|
|
|
# =============================================================================
|
|
# session.py
|
|
# =============================================================================
|
|
|
|
|
|
class TestUnsafeSession:
|
|
def test_detect_unsafe_session_two_participants(self):
|
|
clear_unsafe_sessions()
|
|
identity = ChannelIdentity(
|
|
channel_id="urbit",
|
|
channel_type=ChannelType.URBIT,
|
|
channel_user_id="~zod",
|
|
channel_chat_id="dm--marzod",
|
|
)
|
|
msg = ChannelMessage(
|
|
identity=identity,
|
|
content="hi",
|
|
metadata={"chat_type": "direct", "urbit_ship": "zod"},
|
|
)
|
|
result = detect_unsafe_session(msg)
|
|
assert result == []
|
|
|
|
def test_detect_unsafe_session_group_returns_empty(self):
|
|
clear_unsafe_sessions()
|
|
identity = ChannelIdentity(
|
|
channel_id="urbit",
|
|
channel_type=ChannelType.URBIT,
|
|
channel_user_id="~zod",
|
|
channel_chat_id="/~zod/my-group/chat/1",
|
|
)
|
|
msg = ChannelMessage(
|
|
identity=identity,
|
|
content="hi",
|
|
metadata={"chat_type": "group", "urbit_ship": "zod"},
|
|
)
|
|
result = detect_unsafe_session(msg)
|
|
assert result == []
|
|
|
|
def test_detect_unsafe_session_no_ship(self):
|
|
clear_unsafe_sessions()
|
|
identity = ChannelIdentity(
|
|
channel_id="urbit",
|
|
channel_type=ChannelType.URBIT,
|
|
channel_user_id="~zod",
|
|
channel_chat_id="dm--marzod",
|
|
)
|
|
msg = ChannelMessage(
|
|
identity=identity,
|
|
content="hi",
|
|
metadata={"chat_type": "direct"},
|
|
)
|
|
result = detect_unsafe_session(msg)
|
|
assert result == []
|
|
|
|
def test_detect_unsafe_session_many_participants(self):
|
|
clear_unsafe_sessions()
|
|
identity = ChannelIdentity(
|
|
channel_id="urbit",
|
|
channel_type=ChannelType.URBIT,
|
|
channel_user_id="~zod",
|
|
channel_chat_id="dm--marzod",
|
|
)
|
|
for ship in ["zod", "marzod", "dev"]:
|
|
msg = ChannelMessage(
|
|
identity=identity,
|
|
content="hi",
|
|
metadata={"chat_type": "direct", "urbit_ship": ship},
|
|
)
|
|
detect_unsafe_session(msg)
|
|
result = detect_unsafe_session(msg)
|
|
assert len(result) > 2
|
|
|
|
def test_get_unsafe_sessions(self):
|
|
clear_unsafe_sessions()
|
|
identity = ChannelIdentity(
|
|
channel_id="urbit",
|
|
channel_type=ChannelType.URBIT,
|
|
channel_user_id="~zod",
|
|
channel_chat_id="dm--marzod",
|
|
)
|
|
msg = ChannelMessage(
|
|
identity=identity,
|
|
content="hi",
|
|
metadata={"chat_type": "direct", "urbit_ship": "zod"},
|
|
)
|
|
detect_unsafe_session(msg)
|
|
sessions = get_unsafe_sessions()
|
|
assert len(sessions) > 0
|
|
|
|
def test_clear_unsafe_sessions(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="urbit",
|
|
channel_type=ChannelType.URBIT,
|
|
channel_user_id="~zod",
|
|
channel_chat_id="dm--marzod",
|
|
)
|
|
msg = ChannelMessage(
|
|
identity=identity,
|
|
content="hi",
|
|
metadata={"chat_type": "direct", "urbit_ship": "zod"},
|
|
)
|
|
detect_unsafe_session(msg)
|
|
clear_unsafe_sessions()
|
|
sessions = get_unsafe_sessions()
|
|
assert len(sessions) == 0
|
|
|
|
def test_cleanup_expired_sessions_returns_int(self):
|
|
clear_unsafe_sessions()
|
|
count = cleanup_expired_sessions()
|
|
assert isinstance(count, int)
|
|
|
|
|
|
# =============================================================================
|
|
# authorization.py
|
|
# =============================================================================
|
|
|
|
|
|
class TestChannelAuthorization:
|
|
def test_is_channel_allowed_open_default(self):
|
|
from yuxi.channels.adapters.urbit.authorization import ChannelAuthorization
|
|
|
|
auth = ChannelAuthorization({})
|
|
assert auth.is_channel_allowed("chat/test", "zod") is True
|
|
|
|
def test_is_channel_allowed_disabled(self):
|
|
from yuxi.channels.adapters.urbit.authorization import ChannelAuthorization
|
|
|
|
config = {
|
|
"authorization": {
|
|
"channel_rules": {"chat/test": {"policy": "disabled"}}
|
|
}
|
|
}
|
|
auth = ChannelAuthorization(config)
|
|
assert auth.is_channel_allowed("chat/test", "zod") is False
|
|
|
|
def test_is_channel_allowed_allowlist_match(self):
|
|
from yuxi.channels.adapters.urbit.authorization import ChannelAuthorization
|
|
|
|
config = {
|
|
"authorization": {
|
|
"channel_rules": {
|
|
"chat/test": {"policy": "allowlist", "allow_from": ["urbit:zod"]}
|
|
}
|
|
}
|
|
}
|
|
auth = ChannelAuthorization(config)
|
|
assert auth.is_channel_allowed("chat/test", "zod") is True
|
|
assert auth.is_channel_allowed("chat/test", "marzod") is False
|
|
|
|
def test_is_channel_allowed_default_authorized(self):
|
|
from yuxi.channels.adapters.urbit.authorization import ChannelAuthorization
|
|
|
|
config = {
|
|
"authorization": {
|
|
"default_authorized_ships": ["urbit:zod"]
|
|
}
|
|
}
|
|
auth = ChannelAuthorization(config)
|
|
assert auth.is_channel_allowed("chat/test", "zod") is True
|
|
|
|
def test_is_channel_allowed_group_allow_from(self):
|
|
from yuxi.channels.adapters.urbit.authorization import ChannelAuthorization
|
|
|
|
config = {"group_allow_from": ["urbit:zod"]}
|
|
auth = ChannelAuthorization(config)
|
|
assert auth.is_channel_allowed("chat/test", "zod") is True
|
|
|
|
def test_get_channel_policy(self):
|
|
from yuxi.channels.adapters.urbit.authorization import ChannelAuthorization
|
|
|
|
config = {
|
|
"authorization": {
|
|
"channel_rules": {"chat/test": {"policy": "allowlist"}}
|
|
}
|
|
}
|
|
auth = ChannelAuthorization(config)
|
|
assert auth.get_channel_policy("chat/test") == "allowlist"
|
|
assert auth.get_channel_policy("chat/nonexistent") == "open"
|
|
|
|
def test_add_channel_rule(self):
|
|
from yuxi.channels.adapters.urbit.authorization import ChannelAuthorization
|
|
|
|
auth = ChannelAuthorization({})
|
|
auth.add_channel_rule("chat/new", "allowlist", ["urbit:zod"])
|
|
assert auth.get_channel_policy("chat/new") == "allowlist"
|
|
assert auth.is_channel_allowed("chat/new", "zod") is True
|
|
|
|
def test_remove_channel_rule(self):
|
|
from yuxi.channels.adapters.urbit.authorization import ChannelAuthorization
|
|
|
|
config = {
|
|
"authorization": {
|
|
"channel_rules": {"chat/test": {"policy": "disabled"}}
|
|
}
|
|
}
|
|
auth = ChannelAuthorization(config)
|
|
auth.remove_channel_rule("chat/test")
|
|
assert auth.is_channel_allowed("chat/test", "zod") is True
|
|
|
|
def test_invalid_policy_returns_false(self):
|
|
from yuxi.channels.adapters.urbit.authorization import ChannelAuthorization
|
|
|
|
config = {
|
|
"authorization": {
|
|
"channel_rules": {"chat/test": {"policy": "invalid_policy"}}
|
|
}
|
|
}
|
|
auth = ChannelAuthorization(config)
|
|
assert auth.is_channel_allowed("chat/test", "zod") is False
|
|
|
|
|
|
# =============================================================================
|
|
# accounts.py - AccountInfo and more AccountManager methods
|
|
# =============================================================================
|
|
|
|
|
|
class TestAccountInfo:
|
|
def test_init(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountInfo
|
|
|
|
info = AccountInfo("default", "~zod", "http://localhost:8080", "code123")
|
|
assert info.name == "default"
|
|
assert info.ship == "zod"
|
|
assert info.url == "http://localhost:8080"
|
|
assert info.code == "code123"
|
|
assert info.status == "disconnected"
|
|
assert info.last_probe_at == 0.0
|
|
|
|
def test_ship_with_tilde(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountInfo
|
|
|
|
info = AccountInfo("default", "zod", "http://localhost:8080", "code")
|
|
assert info.ship_with_tilde == "~zod"
|
|
|
|
def test_ship_with_tilde_already_has_tilde(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountInfo
|
|
|
|
info = AccountInfo("default", "~zod", "http://localhost:8080", "code")
|
|
assert info.ship_with_tilde == "~zod"
|
|
|
|
def test_to_dict(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountInfo
|
|
|
|
info = AccountInfo("default", "zod", "http://localhost:8080", "code")
|
|
d = info.to_dict()
|
|
assert d["name"] == "default"
|
|
assert d["ship"] == "~zod"
|
|
assert d["url"] == "http://localhost:8080"
|
|
assert d["status"] == "disconnected"
|
|
|
|
def test_with_metadata(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountInfo
|
|
|
|
info = AccountInfo("default", "zod", "http://localhost:8080", "code", {"key": "val"})
|
|
assert info.metadata == {"key": "val"}
|
|
|
|
|
|
class TestAccountManagerEdgeCases:
|
|
def test_add_account(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager()
|
|
info = mgr.add_account("test", "zod", "http://localhost", "code")
|
|
assert info.name == "test"
|
|
assert mgr.count == 1
|
|
|
|
def test_remove_account(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager()
|
|
mgr.add_account("test", "zod", "http://localhost", "code")
|
|
assert mgr.remove_account("test") is True
|
|
assert mgr.count == 0
|
|
|
|
def test_remove_nonexistent(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager()
|
|
assert mgr.remove_account("nonexistent") is False
|
|
|
|
def test_get_default_empty(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager()
|
|
assert mgr.get() is None
|
|
|
|
def test_get(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager()
|
|
mgr.add_account("test", "zod", "http://localhost", "code")
|
|
assert mgr.get("test") is not None
|
|
assert mgr.get("test").ship == "zod"
|
|
|
|
def test_get_all(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager()
|
|
mgr.add_account("a", "zod", "http://localhost", "code")
|
|
mgr.add_account("b", "marzod", "http://localhost", "code")
|
|
all_accounts = mgr.get_all()
|
|
assert len(all_accounts) == 2
|
|
|
|
def test_list_ships(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager()
|
|
mgr.add_account("a", "zod", "http://localhost", "code")
|
|
mgr.add_account("b", "~marzod", "http://localhost", "code")
|
|
ships = mgr.list_ships()
|
|
assert "~zod" in ships
|
|
assert "~marzod" in ships
|
|
|
|
def test_set_status(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager()
|
|
mgr.add_account("test", "zod", "http://localhost", "code")
|
|
mgr.set_status("test", "connected")
|
|
assert mgr.get("test").status == "connected"
|
|
|
|
def test_set_status_nonexistent(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager()
|
|
mgr.set_status("nonexistent", "connected")
|
|
|
|
def test_is_empty(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager()
|
|
assert mgr.is_empty is True
|
|
mgr.add_account("test", "zod", "http://localhost", "code")
|
|
assert mgr.is_empty is False
|
|
|
|
def test_on_status_change(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager()
|
|
mgr.add_account("test", "zod", "http://localhost", "code")
|
|
calls: list[tuple[str, str]] = []
|
|
|
|
def handler(name: str, status: str):
|
|
calls.append((name, status))
|
|
|
|
mgr.on_status_change(handler)
|
|
mgr.set_status("test", "connected")
|
|
assert len(calls) == 1
|
|
assert calls[0] == ("test", "connected")
|
|
|
|
def test_load_from_config(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager([
|
|
{"ship": "zod", "url": "http://localhost", "code": "code1"},
|
|
{"ship": "marzod", "url": "http://localhost", "code": "code2"},
|
|
])
|
|
assert mgr.count == 2
|
|
|
|
def test_load_invalid_skipped(self):
|
|
from yuxi.channels.adapters.urbit.accounts import AccountManager
|
|
|
|
mgr = AccountManager([
|
|
{"ship": "", "url": "http://localhost", "code": "x"},
|
|
{"ship": "zod", "url": "", "code": "x"},
|
|
{"ship": "zod", "url": "http://localhost", "code": ""},
|
|
])
|
|
assert mgr.count == 0
|
|
|
|
|
|
# =============================================================================
|
|
# history.py - MessageCache
|
|
# =============================================================================
|
|
|
|
|
|
class TestMessageCache:
|
|
@pytest.mark.asyncio
|
|
async def test_cache_message(self):
|
|
cache = MessageCache()
|
|
await cache.cache_message("m1", "chat-1", "hello", "zod")
|
|
history = await cache.get_recent("chat-1")
|
|
assert len(history) == 1
|
|
assert history[0]["content"] == "hello"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_recent_empty(self):
|
|
cache = MessageCache()
|
|
history = await cache.get_recent("nonexistent")
|
|
assert history == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_channel_history(self):
|
|
cache = MessageCache()
|
|
await cache.cache_message("m1", "chat-1", "msg1", "zod")
|
|
await cache.cache_message("m2", "chat-1", "msg2", "marzod")
|
|
history = await cache.get_channel_history("chat-1")
|
|
assert len(history) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_eviction(self):
|
|
cache = MessageCache()
|
|
for i in range(110):
|
|
await cache.cache_message(f"m{i}", "chat-1", f"msg{i}", "zod")
|
|
history = await cache.get_recent("chat-1")
|
|
assert len(history) <= 100
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_track_sent_message(self):
|
|
cache = MessageCache()
|
|
await cache.track_sent_message("m1")
|
|
assert await cache.is_sent("m1") is True
|
|
assert await cache.is_sent("m2") is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_message(self):
|
|
cache = MessageCache()
|
|
await cache.cache_message("m1", "chat-1", "original", "zod")
|
|
updated = await cache.update_message("m1", "modified")
|
|
assert updated is True
|
|
history = await cache.get_recent("chat-1")
|
|
assert history[0]["content"] == "modified"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_nonexistent(self):
|
|
cache = MessageCache()
|
|
updated = await cache.update_message("nx", "modified")
|
|
assert updated is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_channel(self):
|
|
cache = MessageCache()
|
|
await cache.cache_message("m1", "chat-1", "hello", "zod")
|
|
await cache.clear_channel("chat-1")
|
|
history = await cache.get_recent("chat-1")
|
|
assert history == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_channel_history_with_limit(self):
|
|
cache = MessageCache()
|
|
for i in range(10):
|
|
await cache.cache_message(f"m{i}", "chat-1", f"msg{i}", "zod")
|
|
history = await cache.get_channel_history("chat-1", limit=5)
|
|
assert len(history) == 5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sent_eviction(self):
|
|
cache = MessageCache()
|
|
for i in range(210):
|
|
await cache.track_sent_message(f"m{i}")
|
|
assert await cache.is_sent("m0") is False
|
|
|
|
|
|
# =============================================================================
|
|
# summarize.py - more Summarizer edge cases
|
|
# =============================================================================
|
|
|
|
|
|
class TestSummarizerEdgeCases:
|
|
def test_build_summary_request(self):
|
|
cache = MessageCache()
|
|
summ = Summarizer(cache)
|
|
history = [
|
|
{"author": "zod", "content": "hello"},
|
|
{"author": "marzod", "content": "hi there"},
|
|
]
|
|
result = summ.build_summary_request("chat-1", history)
|
|
assert result["channel_id"] == "chat-1"
|
|
assert result["message_count"] == 2
|
|
assert "hello" in result["messages"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_recent_history_empty(self):
|
|
summ = Summarizer()
|
|
history = await summ.get_recent_history("nonexistent")
|
|
assert history == []
|
|
|
|
def test_bind_cache(self):
|
|
cache = MessageCache()
|
|
summ = Summarizer()
|
|
summ.bind_cache(cache)
|
|
assert summ._message_cache is cache
|
|
|
|
|
|
# =============================================================================
|
|
# settings.py - SettingsStore methods
|
|
# =============================================================================
|
|
|
|
|
|
class TestSettingsStore:
|
|
def _make_client_mock(self):
|
|
class MockClient:
|
|
ship_name = "zod"
|
|
ship_url = "http://localhost"
|
|
|
|
async def get(self, path, **kwargs):
|
|
class MockResp:
|
|
status_code = 200
|
|
|
|
def json(self):
|
|
return {}
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
return MockResp()
|
|
|
|
async def put(self, path, **kwargs):
|
|
class MockResp:
|
|
status_code = 200
|
|
|
|
def json(self):
|
|
return {}
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
return MockResp()
|
|
|
|
return MockClient()
|
|
|
|
def test_get_from_config(self):
|
|
store = SettingsStore(self._make_client_mock())
|
|
store._file_config = {"key1": "val1"}
|
|
assert store.get("key1") == "val1"
|
|
|
|
def test_get_default(self):
|
|
store = SettingsStore(self._make_client_mock())
|
|
assert store.get("nonexistent", "default") == "default"
|
|
|
|
def test_get_from_store(self):
|
|
store = SettingsStore(self._make_client_mock())
|
|
store._store = {"key1": "store_val"}
|
|
store._file_config = {"key1": "config_val"}
|
|
assert store.get("key1") == "store_val"
|
|
|
|
def test_get_all(self):
|
|
store = SettingsStore(self._make_client_mock())
|
|
store._file_config = {"a": 1}
|
|
store._store = {"b": 2}
|
|
all_data = store.get_all()
|
|
assert all_data["a"] == 1
|
|
assert all_data["b"] == 2
|
|
|
|
def test_merge_key(self):
|
|
store = SettingsStore(self._make_client_mock())
|
|
result = store.merge_key("key1", "val1")
|
|
assert result is True
|
|
assert store.get("key1") == "val1"
|
|
|
|
def test_merge_key_version_conflict(self):
|
|
store = SettingsStore(self._make_client_mock())
|
|
store.merge_key("key1", "v1")
|
|
result = store.merge_key("key1", "v2", expected_version=0)
|
|
assert result is False
|
|
assert store.get("key1") == "v1"
|
|
|
|
def test_replace_all(self):
|
|
store = SettingsStore(self._make_client_mock())
|
|
store.replace_all({"new": "data"})
|
|
assert store.get("new") == "data"
|
|
|
|
def test_update_key(self):
|
|
store = SettingsStore(self._make_client_mock())
|
|
store.update_key("key1", "val1")
|
|
assert store.get("key1") == "val1"
|
|
|
|
def test_should_migrate_setting_true(self):
|
|
store = SettingsStore(self._make_client_mock())
|
|
store._file_config = {"key1": "val1"}
|
|
assert store.should_migrate_setting("key1") is True
|
|
|
|
def test_should_migrate_setting_false_already_in_store(self):
|
|
store = SettingsStore(self._make_client_mock())
|
|
store._file_config = {"key1": "val1"}
|
|
store._store = {"key1": "val1"}
|
|
assert store.should_migrate_setting("key1") is False
|
|
|
|
def test_should_migrate_setting_no_file_val(self):
|
|
store = SettingsStore(self._make_client_mock())
|
|
assert store.should_migrate_setting("nonexistent") is False
|
|
|
|
def test_is_loaded(self):
|
|
store = SettingsStore(self._make_client_mock())
|
|
assert store.is_loaded is False
|
|
store._loaded = True
|
|
assert store.is_loaded is True
|
|
|
|
def test_version(self):
|
|
store = SettingsStore(self._make_client_mock())
|
|
assert store.version == 0
|
|
store._version = 5
|
|
assert store.version == 5
|
|
|
|
|
|
# =============================================================================
|
|
# threads.py - ThreadManager
|
|
# =============================================================================
|
|
|
|
|
|
class TestThreadManager:
|
|
def test_is_thread_reply_true(self):
|
|
tm = ThreadManager()
|
|
raw = {
|
|
"graph-update": {
|
|
"additions": {
|
|
"1": {"post": {"contents": [{"reply": {"id": "parent-1"}}]}}
|
|
}
|
|
}
|
|
}
|
|
assert tm.is_thread_reply(raw) is True
|
|
|
|
def test_is_thread_reply_false(self):
|
|
tm = ThreadManager()
|
|
raw = {
|
|
"graph-update": {
|
|
"additions": {
|
|
"1": {"post": {"contents": [{"text": "hello"}]}}
|
|
}
|
|
}
|
|
}
|
|
assert tm.is_thread_reply(raw) is False
|
|
|
|
def test_is_thread_reply_empty(self):
|
|
tm = ThreadManager()
|
|
assert tm.is_thread_reply({}) is False
|
|
|
|
def test_get_reply_parent_id(self):
|
|
tm = ThreadManager()
|
|
raw = {
|
|
"graph-update": {
|
|
"additions": {
|
|
"1": {
|
|
"post": {
|
|
"contents": [{"reply": {"id": "parent-1"}}]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
parent_id = tm.get_reply_parent_id(raw)
|
|
assert parent_id == "parent-1"
|
|
|
|
def test_get_reply_parent_id_none(self):
|
|
tm = ThreadManager()
|
|
raw = {
|
|
"graph-update": {
|
|
"additions": {
|
|
"1": {"post": {"contents": [{"text": "hello"}]}}
|
|
}
|
|
}
|
|
}
|
|
assert tm.get_reply_parent_id(raw) is None
|
|
|
|
def test_add_and_has_participated(self):
|
|
tm = ThreadManager()
|
|
tm.add_participated("thread-1")
|
|
assert tm.has_participated("thread-1") is True
|
|
assert tm.has_participated("thread-2") is False
|
|
|
|
def test_build_thread_context(self):
|
|
tm = ThreadManager(context_lines=3)
|
|
history = [
|
|
{"author": "zod", "content": "msg1"},
|
|
{"author": "marzod", "content": "msg2"},
|
|
{"author": "dev", "content": "msg3"},
|
|
{"author": "zod", "content": "msg4"},
|
|
]
|
|
context = tm.build_thread_context(history)
|
|
assert "msg2" in context
|
|
assert "msg3" in context
|
|
assert "msg4" in context
|
|
assert "[Thread conversation" in context
|
|
|
|
def test_build_thread_context_empty(self):
|
|
tm = ThreadManager()
|
|
assert tm.build_thread_context([]) == ""
|
|
|
|
def test_bind_settings_store(self):
|
|
tm = ThreadManager()
|
|
mock_store = object()
|
|
tm.bind_settings_store(mock_store)
|
|
assert tm._settings_store is mock_store
|
|
|
|
def test_init_defaults(self):
|
|
tm = ThreadManager()
|
|
assert tm._max_history == 20
|
|
assert tm._context_lines == 10
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_from_store_empty(self):
|
|
tm = ThreadManager()
|
|
await tm.restore_from_store()
|
|
assert len(tm._participated) == 0
|
|
|
|
|
|
# =============================================================================
|
|
# monitor.py - _is_self_message, _detect_bot_mentions, _extract_cites
|
|
# =============================================================================
|
|
|
|
|
|
class TestMonitorHelpers:
|
|
def _make_msg(self, ship: str, content: str = "hello", additions=None):
|
|
identity = ChannelIdentity(
|
|
channel_id="urbit",
|
|
channel_type=ChannelType.URBIT,
|
|
channel_user_id=f"~{ship}",
|
|
channel_chat_id="chat/test",
|
|
)
|
|
metadata = {"urbit_ship": ship}
|
|
if additions:
|
|
metadata["raw_additions"] = additions
|
|
return ChannelMessage(
|
|
identity=identity,
|
|
content=content,
|
|
metadata=metadata,
|
|
)
|
|
|
|
def test_is_self_message_true(self):
|
|
msg = self._make_msg("zod")
|
|
assert _is_self_message(msg, "zod") is True
|
|
|
|
def test_is_self_message_false(self):
|
|
msg = self._make_msg("marzod")
|
|
assert _is_self_message(msg, "zod") is False
|
|
|
|
def test_is_self_message_no_bot_ship(self):
|
|
msg = self._make_msg("zod")
|
|
assert _is_self_message(msg, "") is False
|
|
|
|
def test_is_self_message_case_insensitive(self):
|
|
msg = self._make_msg("ZOD")
|
|
assert _is_self_message(msg, "zod") is True
|
|
|
|
def test_detect_bot_mentions_at_all(self):
|
|
msg = self._make_msg("marzod", "hello @all")
|
|
result = _detect_bot_mentions(msg, "zod")
|
|
assert result is not None
|
|
assert result.is_bot_mentioned is True
|
|
assert "@all" in result.mentioned_user_ids
|
|
|
|
def test_detect_bot_mentions_at_ship(self):
|
|
msg = self._make_msg("marzod", "hello @zod")
|
|
result = _detect_bot_mentions(msg, "zod")
|
|
assert result is not None
|
|
assert result.is_bot_mentioned is True
|
|
|
|
def test_detect_bot_mentions_with_tilde(self):
|
|
msg = self._make_msg("marzod", "hello ~zod")
|
|
result = _detect_bot_mentions(msg, "zod")
|
|
assert result is not None
|
|
assert result.is_bot_mentioned is True
|
|
|
|
def test_detect_bot_mentions_graph_mention(self):
|
|
additions = {
|
|
"1": {"post": {"contents": [{"mention": "zod"}]}}
|
|
}
|
|
msg = self._make_msg("marzod", "hello", additions)
|
|
result = _detect_bot_mentions(msg, "zod")
|
|
assert result is not None
|
|
assert result.is_bot_mentioned is True
|
|
|
|
def test_detect_bot_mentions_none(self):
|
|
msg = self._make_msg("marzod", "hello there")
|
|
result = _detect_bot_mentions(msg, "zod")
|
|
assert result is None
|
|
|
|
def test_detect_bot_mentions_no_bot(self):
|
|
msg = self._make_msg("marzod", "@zod hi")
|
|
result = _detect_bot_mentions(msg, "")
|
|
assert result is None
|
|
|
|
def test_detect_bot_mentions_no_content_none(self):
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="urbit",
|
|
channel_type=ChannelType.URBIT,
|
|
channel_user_id="~marzod",
|
|
channel_chat_id="chat/test",
|
|
),
|
|
content="",
|
|
metadata={"urbit_ship": "marzod"},
|
|
)
|
|
result = _detect_bot_mentions(msg, "zod")
|
|
assert result is None
|
|
|
|
def test_extract_cites_from_message(self):
|
|
additions = {
|
|
"1": {"post": {"contents": [{"cite": "chan", "group": "g", "chan": "c"}]}}
|
|
}
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="urbit",
|
|
channel_type=ChannelType.URBIT,
|
|
channel_user_id="~zod",
|
|
channel_chat_id="chat/test",
|
|
),
|
|
content="",
|
|
metadata={"raw_additions": additions},
|
|
)
|
|
cites = _extract_cites_from_message(msg)
|
|
assert len(cites) == 1
|
|
assert cites[0]["type"] == "chan"
|
|
|
|
def test_extract_cites_empty(self):
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="urbit",
|
|
channel_type=ChannelType.URBIT,
|
|
channel_user_id="~zod",
|
|
channel_chat_id="chat/test",
|
|
),
|
|
content="",
|
|
metadata={},
|
|
)
|
|
cites = _extract_cites_from_message(msg)
|
|
assert cites == []
|
|
|
|
|
|
# =============================================================================
|
|
# probe.py - fetch_with_ssrf_guard
|
|
# =============================================================================
|
|
|
|
|
|
class TestSSRFGuard:
|
|
def test_fetch_with_ssrf_guard_local_blocked(self):
|
|
is_valid, warnings = fetch_with_ssrf_guard("http://127.0.0.1/test")
|
|
assert is_valid is False
|
|
|
|
def test_fetch_with_ssrf_guard_local_allowed(self):
|
|
is_valid, _ = fetch_with_ssrf_guard(
|
|
"http://127.0.0.1/test", dangerously_allow_private_network=True
|
|
)
|
|
assert is_valid is True
|
|
|
|
def test_fetch_with_ssrf_guard_https(self):
|
|
is_valid, _ = fetch_with_ssrf_guard("https://example.com/test")
|
|
assert is_valid is True
|
|
|
|
def test_validate_url_empty(self):
|
|
is_valid, warnings = validate_urbit_url("")
|
|
assert is_valid is False
|
|
assert len(warnings) > 0
|
|
|
|
def test_validate_url_invalid_scheme(self):
|
|
is_valid, warnings = validate_urbit_url("ftp://example.com")
|
|
assert is_valid is False
|
|
|
|
def test_validate_url_no_hostname(self):
|
|
is_valid, warnings = validate_urbit_url("http://")
|
|
assert is_valid is False
|
|
|
|
|
|
# =============================================================================
|
|
# upload.py - assert functions
|
|
# =============================================================================
|
|
|
|
|
|
class TestUploadAssertions:
|
|
def test_assert_trusted_upload_url_valid(self):
|
|
assert assert_trusted_upload_url("https://example.com/file.png") is True
|
|
|
|
def test_assert_trusted_upload_url_invalid(self):
|
|
assert assert_trusted_upload_url("http://127.0.0.1/file") is False
|
|
|
|
def test_assert_safe_upload_result_url_valid(self):
|
|
assert assert_safe_upload_result_url("https://example.com/file.png") is True
|
|
|
|
def test_assert_safe_upload_result_url_invalid(self):
|
|
assert assert_safe_upload_result_url("ftp://example.com/file") is False
|
|
|
|
def test_assert_safe_upload_result_url_no_scheme(self):
|
|
assert assert_safe_upload_result_url("just/a/path") is False
|
|
|
|
|
|
# =============================================================================
|
|
# vision.py - build_vision_context
|
|
# =============================================================================
|
|
|
|
|
|
class TestVision:
|
|
@pytest.mark.asyncio
|
|
async def test_build_vision_context(self):
|
|
result = await build_vision_context(
|
|
["https://example.com/img1.png", "https://example.com/img2.png"],
|
|
"Describe these images",
|
|
)
|
|
assert result["source"] == "urbit_vision"
|
|
assert len(result["images"]) == 2
|
|
assert result["images"][0]["url"] == "https://example.com/img1.png"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_vision_context_default_prompt(self):
|
|
result = await build_vision_context(["https://example.com/img.png"])
|
|
assert "do you see" in result["prompt"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_vision_context_empty(self):
|
|
result = await build_vision_context([])
|
|
assert result["images"] == []
|
|
|
|
|
|
# =============================================================================
|
|
# targets.py - parse_tlon_target edge cases
|
|
# =============================================================================
|
|
|
|
|
|
class TestTargetFormatEdgeCases:
|
|
def test_parse_nest_chat(self):
|
|
result = parse_tlon_target("chat/zod/test-group")
|
|
assert result["chat_type"] == "group"
|
|
assert result["channel_type"] == "chat"
|
|
|
|
def test_parse_nest_diary(self):
|
|
result = parse_tlon_target("diary/zod/test-diary")
|
|
assert result["chat_type"] == "group"
|
|
assert result["channel_type"] == "diary"
|
|
|
|
def test_parse_nest_heap(self):
|
|
result = parse_tlon_target("heap/zod/test-heap")
|
|
assert result["chat_type"] == "group"
|
|
assert result["channel_type"] == "heap"
|
|
|
|
def test_parse_dm_short(self):
|
|
result = parse_tlon_target("dm/~zod")
|
|
assert result["chat_type"] == "direct"
|
|
assert result["peer_id"] == "~zod"
|
|
|
|
def test_parse_group_with_prefix(self):
|
|
result = parse_tlon_target("group:chat/~zod/test-group")
|
|
assert result["chat_type"] == "group"
|
|
assert "chat" in str(result.get("channel_type", ""))
|
|
|
|
def test_parse_simple_group(self):
|
|
result = parse_tlon_target("group:~zod/test-group")
|
|
assert result["chat_type"] == "group"
|
|
|
|
def test_parse_fallback(self):
|
|
result = parse_tlon_target("~unknown-format")
|
|
assert result["chat_type"] == "direct"
|
|
assert result["peer_id"] == "unknown-format"
|
|
|
|
def test_format_target_hint_direct_with_tilde(self):
|
|
hint = format_target_hint("direct", "~zod", "~host")
|
|
assert "dm/~zod" in hint
|
|
|
|
def test_format_target_hint_group_with_tilde(self):
|
|
hint = format_target_hint("group", "test-group", "~host")
|
|
assert "host/test-group" in hint
|
|
|
|
|
|
# =============================================================================
|
|
# invites.py - InviteManager (non-async methods)
|
|
# =============================================================================
|
|
|
|
|
|
class TestInviteManager:
|
|
def test_should_auto_accept_group_invite_disabled(self):
|
|
mgr = InviteManager(auto_accept_groups=False)
|
|
assert mgr.should_auto_accept_group_invite("zod") is False
|
|
|
|
def test_should_auto_accept_group_invite_empty_allowlist(self):
|
|
mgr = InviteManager(auto_accept_groups=True)
|
|
assert mgr.should_auto_accept_group_invite("zod") is False
|
|
|
|
def test_should_auto_accept_group_invite_match(self):
|
|
mgr = InviteManager(
|
|
auto_accept_groups=True, group_invite_allowlist=["~zod"]
|
|
)
|
|
assert mgr.should_auto_accept_group_invite("~zod") is True
|
|
|
|
def test_should_auto_accept_group_invite_no_match(self):
|
|
mgr = InviteManager(
|
|
auto_accept_groups=True, group_invite_allowlist=["~zod"]
|
|
)
|
|
assert mgr.should_auto_accept_group_invite("~marzod") is False
|
|
|
|
def test_should_auto_accept_dm_disabled(self):
|
|
mgr = InviteManager(auto_accept_dm=False)
|
|
assert mgr.should_auto_accept_dm("zod") is False
|
|
|
|
def test_should_auto_accept_dm_empty_allowlist(self):
|
|
mgr = InviteManager(auto_accept_dm=True)
|
|
assert mgr.should_auto_accept_dm("zod") is False
|
|
|
|
def test_should_auto_accept_dm_match(self):
|
|
mgr = InviteManager(auto_accept_dm=True, dm_allowlist=["~zod"])
|
|
assert mgr.should_auto_accept_dm("~zod") is True
|
|
|
|
def test_get_pending_invites_empty(self):
|
|
mgr = InviteManager()
|
|
assert mgr.get_pending_invites() == []
|
|
|
|
|
|
# =============================================================================
|
|
# discovery.py - ChannelDiscovery
|
|
# =============================================================================
|
|
|
|
|
|
class TestChannelDiscovery:
|
|
def test_init_default(self):
|
|
from yuxi.channels.adapters.urbit.discovery import ChannelDiscovery
|
|
|
|
class MockClient:
|
|
ship_name = "zod"
|
|
ship_url = "http://localhost"
|
|
|
|
discovery = ChannelDiscovery(MockClient())
|
|
assert discovery._auto_discover is False
|
|
assert len(discovery.channels) == 0
|
|
|
|
def test_init_with_group_channels(self):
|
|
from yuxi.channels.adapters.urbit.discovery import ChannelDiscovery
|
|
|
|
class MockClient:
|
|
ship_name = "zod"
|
|
ship_url = "http://localhost"
|
|
|
|
discovery = ChannelDiscovery(
|
|
MockClient(), group_channels=["chat/test", "chat/other"]
|
|
)
|
|
assert "chat/test" in discovery._group_channels
|
|
assert "chat/other" in discovery._group_channels
|
|
|
|
def test_add_channel(self):
|
|
from yuxi.channels.adapters.urbit.discovery import ChannelDiscovery
|
|
|
|
class MockClient:
|
|
ship_name = "zod"
|
|
ship_url = "http://localhost"
|
|
|
|
discovery = ChannelDiscovery(MockClient())
|
|
discovery.add_channel("chat/test")
|
|
assert "chat/test" in discovery.channels
|
|
|
|
def test_add_channel_duplicate(self):
|
|
from yuxi.channels.adapters.urbit.discovery import ChannelDiscovery
|
|
|
|
class MockClient:
|
|
ship_name = "zod"
|
|
ship_url = "http://localhost"
|
|
|
|
discovery = ChannelDiscovery(MockClient())
|
|
discovery.add_channel("chat/test")
|
|
discovery.add_channel("chat/test")
|
|
assert len(discovery.channels) == 1
|
|
|
|
def test_get_chat_nests(self):
|
|
from yuxi.channels.adapters.urbit.discovery import ChannelDiscovery
|
|
|
|
class MockClient:
|
|
ship_name = "zod"
|
|
ship_url = "http://localhost"
|
|
|
|
discovery = ChannelDiscovery(
|
|
MockClient(), group_channels=["chat/test-group"]
|
|
)
|
|
nests = discovery.get_chat_nests("zod")
|
|
assert len(nests) == 1
|
|
assert "~zod/test-group/chat" in nests[0]
|
|
|
|
|
|
# =============================================================================
|
|
# poke_api.py - HttpPokeApiClient
|
|
# =============================================================================
|
|
|
|
|
|
class TestHttpPokeApiClient:
|
|
def _make_client_mock(self):
|
|
class MockClient:
|
|
ship_name = "zod"
|
|
ship_url = "http://localhost"
|
|
|
|
async def get(self, path, **kwargs):
|
|
class MockResp:
|
|
status_code = 200
|
|
|
|
def json(self):
|
|
return {}
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
return MockResp()
|
|
|
|
async def put(self, path, **kwargs):
|
|
class MockResp:
|
|
status_code = 200
|
|
|
|
def json(self):
|
|
return {}
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
return MockResp()
|
|
|
|
async def delete(self, path, **kwargs):
|
|
class MockResp:
|
|
status_code = 204
|
|
|
|
def json(self):
|
|
return {}
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
return MockResp()
|
|
|
|
return MockClient()
|
|
|
|
def test_init(self):
|
|
from yuxi.channels.adapters.urbit.poke_api import HttpPokeApiClient
|
|
|
|
api = HttpPokeApiClient(self._make_client_mock(), "channel-1")
|
|
assert api.channel_id == "channel-1"
|
|
assert api._closed is False
|
|
|
|
def test_is_closed_initially_false(self):
|
|
from yuxi.channels.adapters.urbit.poke_api import HttpPokeApiClient
|
|
|
|
api = HttpPokeApiClient(self._make_client_mock(), "channel-1")
|
|
assert api.is_closed is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_closed_poke_raises(self):
|
|
from yuxi.channels.adapters.urbit.poke_api import HttpPokeApiClient
|
|
|
|
api = HttpPokeApiClient(self._make_client_mock(), "channel-1")
|
|
api._closed = True
|
|
with pytest.raises(RuntimeError, match="closed"):
|
|
await api.poke("chat", "chat-message", {})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_closed_scry_raises(self):
|
|
from yuxi.channels.adapters.urbit.poke_api import HttpPokeApiClient
|
|
|
|
api = HttpPokeApiClient(self._make_client_mock(), "channel-1")
|
|
api._closed = True
|
|
with pytest.raises(RuntimeError, match="closed"):
|
|
await api.scry("chat", "/path")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_closed_subscribe_raises(self):
|
|
from yuxi.channels.adapters.urbit.poke_api import HttpPokeApiClient
|
|
|
|
api = HttpPokeApiClient(self._make_client_mock(), "channel-1")
|
|
api._closed = True
|
|
with pytest.raises(RuntimeError, match="closed"):
|
|
await api.subscribe("chat", "/path")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close(self):
|
|
from yuxi.channels.adapters.urbit.poke_api import HttpPokeApiClient
|
|
|
|
api = HttpPokeApiClient(self._make_client_mock(), "channel-1")
|
|
await api.close()
|
|
assert api._closed is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_idempotent(self):
|
|
from yuxi.channels.adapters.urbit.poke_api import HttpPokeApiClient
|
|
|
|
api = HttpPokeApiClient(self._make_client_mock(), "channel-1")
|
|
await api.close()
|
|
await api.close()
|
|
|
|
|
|
# =============================================================================
|
|
# setup.py
|
|
# =============================================================================
|
|
|
|
|
|
class TestSetupSchema:
|
|
def test_get_setup_schema(self):
|
|
from yuxi.channels.adapters.urbit.setup import get_setup_schema
|
|
|
|
schema = get_setup_schema()
|
|
assert schema["name"] == "urbit"
|
|
assert len(schema["required_fields"]) == 3
|
|
keys = [f["key"] for f in schema["required_fields"]]
|
|
assert "ship_url" in keys
|
|
assert "ship_name" in keys
|
|
assert "ship_code" in keys
|
|
assert len(schema["optional_fields"]) > 0
|
|
|
|
def test_quick_setup(self):
|
|
from yuxi.channels.adapters.urbit.setup import quick_setup
|
|
|
|
result = quick_setup(
|
|
"http://localhost:8080", "zod", "XXXX-XXXX-XXXX-XXXX"
|
|
)
|
|
assert result["ship_url"] == "http://localhost:8080"
|
|
assert result["ship_name"] == "zod"
|
|
assert result["ship_code"] == "XXXX-XXXX-XXXX-XXXX"
|
|
assert "dm_allowlist" in result
|
|
|
|
|
|
# =============================================================================
|
|
# doctor.py - is_legacy more edge cases
|
|
# =============================================================================
|
|
|
|
|
|
class TestDoctorEdgeCases:
|
|
def test_legacy_allow_private_network_snake(self):
|
|
from yuxi.channels.adapters.urbit.doctor import (
|
|
is_legacy_private_network_config,
|
|
)
|
|
|
|
assert is_legacy_private_network_config({"allow_private_network": True})
|
|
|
|
def test_not_legacy(self):
|
|
from yuxi.channels.adapters.urbit.doctor import (
|
|
is_legacy_private_network_config,
|
|
)
|
|
|
|
assert is_legacy_private_network_config({}) is False
|
|
assert is_legacy_private_network_config({"other": True}) is False
|
|
|
|
def test_create_migrations_empty(self):
|
|
from yuxi.channels.adapters.urbit.doctor import (
|
|
create_legacy_private_network_doctor_contract,
|
|
)
|
|
|
|
assert create_legacy_private_network_doctor_contract({}) == []
|
|
|
|
|
|
# =============================================================================
|
|
# RateLimiter - wait_and_acquire test
|
|
# =============================================================================
|
|
|
|
|
|
class TestRateLimiterEdgeCases:
|
|
@pytest.mark.asyncio
|
|
async def test_wait_and_acquire_success(self):
|
|
from yuxi.channels.adapters.urbit.rate_limiter import RateLimiter
|
|
|
|
limiter = RateLimiter(limit=100, window=60.0)
|
|
result = await limiter.wait_and_acquire(timeout=0.5)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_and_acquire_exhausted_timeout(self):
|
|
from yuxi.channels.adapters.urbit.rate_limiter import RateLimiter
|
|
|
|
limiter = RateLimiter(limit=0, window=60.0)
|
|
result = await limiter.wait_and_acquire(timeout=0.1)
|
|
assert result is False
|
|
|
|
|
|
# =============================================================================
|
|
# UrbitClient - normalize_ship_url
|
|
# =============================================================================
|
|
|
|
|
|
class TestNormalizeShipUrl:
|
|
def test_strips_trailing_slash(self):
|
|
from yuxi.channels.adapters.urbit.client import UrbitClient
|
|
|
|
client = UrbitClient(ship_url="http://localhost:8080/", ship_name="~zod")
|
|
assert client.ship_url == "http://localhost:8080"
|
|
|
|
def test_adds_https_when_no_scheme(self):
|
|
from yuxi.channels.adapters.urbit.client import UrbitClient
|
|
|
|
client = UrbitClient(ship_url="ship.arvo.network", ship_name="~zod")
|
|
assert client.ship_url.startswith("https://")
|
|
|
|
def test_empty_ship_name(self):
|
|
from yuxi.channels.adapters.urbit.client import UrbitClient
|
|
|
|
client = UrbitClient(ship_url="http://localhost:8080", ship_name="")
|
|
assert client.ship_name == ""
|
|
assert client.auth_cookie_name == "urbauth-~"
|
|
|
|
|
|
# =============================================================================
|
|
# format.py - build_poke_payload with direct chat_type
|
|
# =============================================================================
|
|
|
|
|
|
class TestBuildPokePayloadDirectChat:
|
|
def test_direct_chat_mark(self):
|
|
from yuxi.channels.adapters.urbit.format import build_poke_payload
|
|
|
|
payload = build_poke_payload(
|
|
host_ship="zod",
|
|
content="hello",
|
|
channel_path="/dm/~marzod",
|
|
chat_type="direct",
|
|
)
|
|
assert payload["mark"] == "chat-dm-action"
|
|
|
|
|
|
# =============================================================================
|
|
# __init__.py - exports
|
|
# =============================================================================
|
|
|
|
|
|
class TestModuleExports:
|
|
def test_all_exports_defined(self):
|
|
from yuxi.channels.adapters.urbit import (
|
|
AccountManager,
|
|
DualPluginRegistry,
|
|
HttpPokeApiClient,
|
|
InviteManager,
|
|
MultiAccountConnectionManager,
|
|
UrbitAccountPlugin,
|
|
UrbitAdapter,
|
|
UrbitSetupPlugin,
|
|
create_http_poke_api,
|
|
get_categories,
|
|
get_config_ui_hints,
|
|
get_global_registry,
|
|
get_hints_by_category,
|
|
with_http_poke_api,
|
|
)
|
|
|
|
assert UrbitAdapter is not None
|
|
assert AccountManager is not None
|
|
assert HttpPokeApiClient is not None
|
|
assert InviteManager is not None
|
|
assert DualPluginRegistry is not None
|
|
assert get_global_registry is not None
|
|
|
|
|
|
# =============================================================================
|
|
# format.py - _extract_graph_content edge cases (diary, blockquote, image, ship)
|
|
# =============================================================================
|
|
|
|
|
|
class TestParseGraphUpdateEdgeCases:
|
|
def test_parse_diary_with_title(self):
|
|
from yuxi.channels.adapters.urbit.format import parse_graph_update
|
|
|
|
raw = {
|
|
"graph-update": {
|
|
"resource": {"path": "/~zod/diary-1", "type": "diary"},
|
|
"ship": "zod",
|
|
"additions": {
|
|
"1": {
|
|
"post": {
|
|
"title": "My Diary Entry",
|
|
"contents": [{"text": "body text"}],
|
|
}
|
|
}
|
|
},
|
|
}
|
|
}
|
|
result = parse_graph_update(raw)
|
|
assert "My Diary Entry" in result["content"]
|
|
assert "body text" in result["content"]
|
|
|
|
def test_parse_with_image(self):
|
|
from yuxi.channels.adapters.urbit.format import parse_graph_update
|
|
|
|
raw = {
|
|
"graph-update": {
|
|
"additions": {
|
|
"1": {
|
|
"post": {
|
|
"contents": [
|
|
{"image": {"src": "https://img.com/a.png"}}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
result = parse_graph_update(raw)
|
|
assert "[Image:" in result["content"]
|
|
|
|
def test_parse_with_ship(self):
|
|
from yuxi.channels.adapters.urbit.format import parse_graph_update
|
|
|
|
raw = {
|
|
"graph-update": {
|
|
"additions": {
|
|
"1": {"post": {"contents": [{"ship": "marzod"}]}}
|
|
}
|
|
}
|
|
}
|
|
result = parse_graph_update(raw)
|
|
assert "~marzod" in result["content"]
|
|
|
|
def test_parse_with_blockquote(self):
|
|
from yuxi.channels.adapters.urbit.format import parse_graph_update
|
|
|
|
raw = {
|
|
"graph-update": {
|
|
"additions": {
|
|
"1": {
|
|
"post": {
|
|
"contents": [
|
|
{"blockquote": [{"text": "quoted"}]}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
result = parse_graph_update(raw)
|
|
assert "quoted" in result["content"]
|
|
|
|
def test_parse_with_blockquote_string(self):
|
|
from yuxi.channels.adapters.urbit.format import parse_graph_update
|
|
|
|
raw = {
|
|
"graph-update": {
|
|
"additions": {
|
|
"1": {
|
|
"post": {
|
|
"contents": [
|
|
{"blockquote": "simple quote"}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
result = parse_graph_update(raw)
|
|
assert "simple quote" in result["content"]
|
|
|
|
|
|
# =============================================================================
|
|
# summarizer.py - is_summarization_request edge cases
|
|
# =============================================================================
|
|
|
|
|
|
class TestSummarizerTriggerEdgeCases:
|
|
def test_exact_match_summarise(self):
|
|
cache = MessageCache()
|
|
summ = Summarizer(cache)
|
|
assert summ.is_summarization_request("summarise") is True
|
|
|
|
def test_case_insensitive(self):
|
|
cache = MessageCache()
|
|
summ = Summarizer(cache)
|
|
assert summ.is_summarization_request("TLDR") is True
|
|
|
|
def test_leading_whitespace(self):
|
|
cache = MessageCache()
|
|
summ = Summarizer(cache)
|
|
assert summ.is_summarization_request(" summary ") is True
|
|
|
|
def test_not_summary(self):
|
|
cache = MessageCache()
|
|
summ = Summarizer(cache)
|
|
assert summ.is_summarization_request("how are you today") is False |