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
|