349 lines
12 KiB
Python
349 lines
12 KiB
Python
|
|
"""MSTeams 辅助工具类单元测试 (media/mentions/secret_input/user_agent/debounce/doctor)。"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import time
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from yuxi.channels.adapters.msteams.media_helpers import (
|
||
|
|
get_mime_type,
|
||
|
|
extract_filename,
|
||
|
|
extract_message_id,
|
||
|
|
)
|
||
|
|
from yuxi.channels.adapters.msteams.media_tools import (
|
||
|
|
get_mime_type as mt_get_mime_type,
|
||
|
|
extract_filename as mt_extract_filename,
|
||
|
|
extract_message_id as mt_extract_message_id,
|
||
|
|
extract_attachment_urls,
|
||
|
|
get_edited_timestamp,
|
||
|
|
)
|
||
|
|
from yuxi.channels.adapters.msteams.mentions import (
|
||
|
|
parse_outbound_mentions,
|
||
|
|
build_mention_entity,
|
||
|
|
apply_mentions_to_activity,
|
||
|
|
)
|
||
|
|
from yuxi.channels.adapters.msteams.secret_input import (
|
||
|
|
normalize_secret_input,
|
||
|
|
has_configured_secret,
|
||
|
|
)
|
||
|
|
from yuxi.channels.adapters.msteams.user_agent import (
|
||
|
|
build_user_agent,
|
||
|
|
ensure_user_agent,
|
||
|
|
get_default_user_agent,
|
||
|
|
)
|
||
|
|
from yuxi.channels.adapters.msteams.debounce import DebounceManager
|
||
|
|
from yuxi.channels.adapters.msteams.doctor import (
|
||
|
|
is_mutable_allowlist_entry,
|
||
|
|
collect_mutable_allowlist_warnings,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestMediaHelpersMimeType:
|
||
|
|
def test_known_extension(self):
|
||
|
|
assert get_mime_type("file.png") == "image/png"
|
||
|
|
|
||
|
|
def test_jpeg(self):
|
||
|
|
assert get_mime_type("photo.jpg") == "image/jpeg"
|
||
|
|
|
||
|
|
def test_pdf(self):
|
||
|
|
assert get_mime_type("doc.pdf") == "application/pdf"
|
||
|
|
|
||
|
|
def test_docx(self):
|
||
|
|
assert get_mime_type("doc.docx") == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||
|
|
|
||
|
|
def test_xlsx(self):
|
||
|
|
assert get_mime_type("sheet.xlsx") == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||
|
|
|
||
|
|
def test_unknown_extension(self):
|
||
|
|
assert get_mime_type("file.xyz") == "application/octet-stream"
|
||
|
|
|
||
|
|
def test_no_extension(self):
|
||
|
|
result = get_mime_type("noextension")
|
||
|
|
assert result in ("application/octet-stream", "text/plain")
|
||
|
|
|
||
|
|
def test_uppercase_extension(self):
|
||
|
|
assert get_mime_type("FILE.PNG") == "image/png"
|
||
|
|
|
||
|
|
|
||
|
|
class TestMediaHelpersExtractFilename:
|
||
|
|
def test_basic(self):
|
||
|
|
result = extract_filename("https://example.com/path/file.txt")
|
||
|
|
assert result == "file.txt"
|
||
|
|
|
||
|
|
def test_with_query_params(self):
|
||
|
|
result = extract_filename("https://example.com/file.txt?token=abc")
|
||
|
|
assert result == "file.txt"
|
||
|
|
|
||
|
|
def test_with_fragment(self):
|
||
|
|
result = extract_filename("https://example.com/file.txt#section")
|
||
|
|
assert result == "file.txt"
|
||
|
|
|
||
|
|
def test_empty_string(self):
|
||
|
|
assert extract_filename("") == "unknown"
|
||
|
|
|
||
|
|
def test_no_filename(self):
|
||
|
|
result = extract_filename("https://example.com/")
|
||
|
|
assert result == "example.com"
|
||
|
|
|
||
|
|
|
||
|
|
class TestMediaHelpersExtractMessageId:
|
||
|
|
def test_standard_format(self):
|
||
|
|
result = extract_message_id("123456789@thread.skype")
|
||
|
|
assert result == "123456789"
|
||
|
|
|
||
|
|
def test_tacv2_format(self):
|
||
|
|
result = extract_message_id("987654321@thread.tacv2")
|
||
|
|
assert result == "987654321"
|
||
|
|
|
||
|
|
def test_plain_id(self):
|
||
|
|
result = extract_message_id("simple-id")
|
||
|
|
assert result == "simple-id"
|
||
|
|
|
||
|
|
def test_empty_string(self):
|
||
|
|
assert extract_message_id("") == ""
|
||
|
|
|
||
|
|
|
||
|
|
class TestMediaToolsExtract:
|
||
|
|
def test_extract_filename(self):
|
||
|
|
activity = {"attachments": [{"name": "report.pdf"}]}
|
||
|
|
result = mt_extract_filename(activity)
|
||
|
|
assert result == "report.pdf"
|
||
|
|
|
||
|
|
def test_extract_filename_from_content(self):
|
||
|
|
activity = {"attachments": [{"content": {"name": "doc.txt"}}]}
|
||
|
|
result = mt_extract_filename(activity)
|
||
|
|
assert result == "doc.txt"
|
||
|
|
|
||
|
|
def test_extract_filename_empty(self):
|
||
|
|
activity = {"attachments": []}
|
||
|
|
result = mt_extract_filename(activity)
|
||
|
|
assert result == ""
|
||
|
|
|
||
|
|
def test_extract_message_id(self):
|
||
|
|
activity = {"id": "msg-123"}
|
||
|
|
result = mt_extract_message_id(activity)
|
||
|
|
assert result == "msg-123"
|
||
|
|
|
||
|
|
def test_extract_message_id_from_channel_data(self):
|
||
|
|
activity = {"channelData": {"id": "ch-msg-001"}}
|
||
|
|
result = mt_extract_message_id(activity)
|
||
|
|
assert result == "ch-msg-001"
|
||
|
|
|
||
|
|
def test_extract_attachment_urls(self):
|
||
|
|
activity = {
|
||
|
|
"attachments": [
|
||
|
|
{
|
||
|
|
"contentType": "image/png",
|
||
|
|
"contentUrl": "https://example.com/img.png",
|
||
|
|
"name": "photo.png",
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
urls = extract_attachment_urls(activity)
|
||
|
|
assert len(urls) == 1
|
||
|
|
assert urls[0]["url"] == "https://example.com/img.png"
|
||
|
|
|
||
|
|
def test_get_edited_timestamp(self):
|
||
|
|
activity = {"channelData": {"editedTimestamp": "2024-01-01T00:00:00Z"}}
|
||
|
|
assert get_edited_timestamp(activity) == "2024-01-01T00:00:00Z"
|
||
|
|
|
||
|
|
def test_get_edited_timestamp_fallback(self):
|
||
|
|
activity = {"editedTimestamp": "2024-01-01T00:00:00Z"}
|
||
|
|
assert get_edited_timestamp(activity) == "2024-01-01T00:00:00Z"
|
||
|
|
|
||
|
|
|
||
|
|
class TestMentions:
|
||
|
|
def test_parse_outbound_mentions_bot_prefix(self):
|
||
|
|
text = "Hello @[Bot](28:bot-id-123)"
|
||
|
|
result_text, entities = parse_outbound_mentions(text)
|
||
|
|
assert len(entities) == 1
|
||
|
|
assert entities[0]["mentioned"]["id"] == "28:bot-id-123"
|
||
|
|
|
||
|
|
def test_parse_outbound_mentions_uuid(self):
|
||
|
|
text = "Hello @[User](12345678-1234-1234-1234-123456789abc)"
|
||
|
|
result_text, entities = parse_outbound_mentions(text)
|
||
|
|
assert len(entities) == 1
|
||
|
|
assert entities[0]["mentioned"]["name"] == "User"
|
||
|
|
|
||
|
|
def test_parse_outbound_mentions_invalid(self):
|
||
|
|
text = "Hello @[Test](ab)"
|
||
|
|
result_text, entities = parse_outbound_mentions(text)
|
||
|
|
assert len(entities) == 0
|
||
|
|
|
||
|
|
def test_parse_outbound_mentions_email(self):
|
||
|
|
text = "Hey @[User](user@example.com)"
|
||
|
|
result_text, entities = parse_outbound_mentions(text)
|
||
|
|
assert len(entities) == 1
|
||
|
|
|
||
|
|
def test_build_mention_entity(self):
|
||
|
|
entity = build_mention_entity("Test", "user-1")
|
||
|
|
assert entity["type"] == "mention"
|
||
|
|
assert entity["mentioned"]["name"] == "Test"
|
||
|
|
assert entity["mentioned"]["id"] == "user-1"
|
||
|
|
|
||
|
|
def test_apply_mentions_to_activity(self):
|
||
|
|
activity = {"type": "message", "text": "hello"}
|
||
|
|
mentions = [build_mention_entity("User", "user-1")]
|
||
|
|
result = apply_mentions_to_activity(activity, mentions)
|
||
|
|
assert len(result["entities"]) == 1
|
||
|
|
|
||
|
|
|
||
|
|
class TestSecretInput:
|
||
|
|
def test_normalize_basic(self):
|
||
|
|
result = normalize_secret_input(" my-secret ")
|
||
|
|
assert result == "my-secret"
|
||
|
|
|
||
|
|
def test_normalize_multiline(self):
|
||
|
|
result = normalize_secret_input("line1\n line2 \n\nline3")
|
||
|
|
assert result == "line1 line2 line3"
|
||
|
|
|
||
|
|
def test_normalize_empty(self):
|
||
|
|
assert normalize_secret_input("") == ""
|
||
|
|
|
||
|
|
def test_normalize_strips_non_printable(self):
|
||
|
|
result = normalize_secret_input("hello\x00world")
|
||
|
|
assert result == "helloworld"
|
||
|
|
|
||
|
|
def test_has_configured_secret_valid(self):
|
||
|
|
assert has_configured_secret("my-secret-key") is True
|
||
|
|
|
||
|
|
def test_has_configured_secret_too_short(self):
|
||
|
|
assert has_configured_secret("abc") is False
|
||
|
|
|
||
|
|
def test_has_configured_secret_placeholder(self):
|
||
|
|
assert has_configured_secret("password") is False
|
||
|
|
assert has_configured_secret("changeme") is False
|
||
|
|
assert has_configured_secret("secret") is False
|
||
|
|
|
||
|
|
def test_has_configured_secret_empty(self):
|
||
|
|
assert has_configured_secret("") is False
|
||
|
|
|
||
|
|
|
||
|
|
class TestUserAgent:
|
||
|
|
def test_build_user_agent(self):
|
||
|
|
ua = build_user_agent()
|
||
|
|
assert "ForcePilot-MSTeams" in ua
|
||
|
|
|
||
|
|
def test_build_custom_product(self):
|
||
|
|
ua = build_user_agent(product="CustomApp", version="2.0")
|
||
|
|
assert "CustomApp/2.0" in ua
|
||
|
|
|
||
|
|
def test_build_with_extra(self):
|
||
|
|
ua = build_user_agent(extra="test-suite")
|
||
|
|
assert "test-suite" in ua
|
||
|
|
|
||
|
|
def test_ensure_user_agent_adds_header(self):
|
||
|
|
headers = ensure_user_agent()
|
||
|
|
assert "User-Agent" in headers
|
||
|
|
|
||
|
|
def test_ensure_user_agent_preserves_existing(self):
|
||
|
|
headers = ensure_user_agent({"Content-Type": "application/json"})
|
||
|
|
assert "Content-Type" in headers
|
||
|
|
assert "User-Agent" in headers
|
||
|
|
|
||
|
|
def test_get_default_user_agent(self):
|
||
|
|
assert "ForcePilot-MSTeams" in get_default_user_agent()
|
||
|
|
|
||
|
|
|
||
|
|
class TestDebounceManager:
|
||
|
|
@pytest.fixture
|
||
|
|
def mgr(self):
|
||
|
|
return DebounceManager(ttl=5.0)
|
||
|
|
|
||
|
|
def test_make_key(self):
|
||
|
|
key = DebounceManager.make_key("app-1", "conv-1", "user-1")
|
||
|
|
assert key == "msteams:app-1:conv-1:user-1"
|
||
|
|
|
||
|
|
def test_merge_first_time(self, mgr):
|
||
|
|
result = mgr.merge("key-1", "hello")
|
||
|
|
assert result is not None
|
||
|
|
assert result["text"] == "hello"
|
||
|
|
assert result["merge_count"] == 1
|
||
|
|
|
||
|
|
def test_merge_within_ttl(self, mgr):
|
||
|
|
mgr.merge("key-1", "hello")
|
||
|
|
result = mgr.merge("key-1", "world")
|
||
|
|
assert result is None
|
||
|
|
entry = mgr.get("key-1")
|
||
|
|
assert "hello\nworld" in entry["text"]
|
||
|
|
assert entry["merge_count"] == 2
|
||
|
|
|
||
|
|
def test_merge_after_ttl_expires(self, mgr):
|
||
|
|
quick_mgr = DebounceManager(ttl=0.01)
|
||
|
|
quick_mgr.merge("key-1", "first")
|
||
|
|
time.sleep(0.02)
|
||
|
|
result = quick_mgr.merge("key-1", "second")
|
||
|
|
assert result is not None
|
||
|
|
assert result["text"] == "second"
|
||
|
|
assert result["merge_count"] == 1
|
||
|
|
|
||
|
|
def test_merge_max_text_length(self, mgr):
|
||
|
|
long_text = "A" * 4500
|
||
|
|
mgr.merge("key-1", long_text)
|
||
|
|
result = mgr.merge("key-1", "appended")
|
||
|
|
entry = mgr.get("key-1")
|
||
|
|
assert len(entry["text"]) <= 4000
|
||
|
|
|
||
|
|
def test_consume(self, mgr):
|
||
|
|
mgr.merge("key-1", "hello")
|
||
|
|
consumed = mgr.consume("key-1")
|
||
|
|
assert consumed is not None
|
||
|
|
assert consumed["text"] == "hello"
|
||
|
|
assert mgr.get("key-1") is None
|
||
|
|
|
||
|
|
def test_consume_nonexistent(self, mgr):
|
||
|
|
assert mgr.consume("nonexistent") is None
|
||
|
|
|
||
|
|
def test_pending_count(self, mgr):
|
||
|
|
mgr.merge("key-1", "a")
|
||
|
|
mgr.merge("key-2", "b")
|
||
|
|
assert mgr.pending_count == 2
|
||
|
|
|
||
|
|
def test_clear(self, mgr):
|
||
|
|
mgr.merge("key-1", "hello")
|
||
|
|
mgr.clear()
|
||
|
|
assert mgr.pending_count == 0
|
||
|
|
|
||
|
|
|
||
|
|
class TestDoctor:
|
||
|
|
def test_is_mutable_email(self):
|
||
|
|
assert is_mutable_allowlist_entry("user@example.com") is True
|
||
|
|
|
||
|
|
def test_is_mutable_uuid(self):
|
||
|
|
assert is_mutable_allowlist_entry("12345678-1234-1234-1234-123456789abc") is False
|
||
|
|
|
||
|
|
def test_is_mutable_wildcard(self):
|
||
|
|
assert is_mutable_allowlist_entry("*") is True
|
||
|
|
|
||
|
|
def test_is_mutable_display_name(self):
|
||
|
|
assert is_mutable_allowlist_entry("张三") is True
|
||
|
|
|
||
|
|
def test_is_mutable_name_with_spaces(self):
|
||
|
|
assert is_mutable_allowlist_entry("John Doe") is True
|
||
|
|
|
||
|
|
def test_is_mutable_empty(self):
|
||
|
|
assert is_mutable_allowlist_entry("") is False
|
||
|
|
|
||
|
|
def test_collect_warnings_dm(self):
|
||
|
|
warnings = collect_mutable_allowlist_warnings(
|
||
|
|
allow_from=["user@example.com", "12345678-1234-1234-1234-123456789abc"],
|
||
|
|
)
|
||
|
|
assert len(warnings) == 1
|
||
|
|
assert warnings[0]["source"] == "allow_from"
|
||
|
|
|
||
|
|
def test_collect_warnings_group(self):
|
||
|
|
warnings = collect_mutable_allowlist_warnings(
|
||
|
|
group_allow_from=["Team Name"],
|
||
|
|
)
|
||
|
|
assert len(warnings) == 1
|
||
|
|
assert warnings[0]["source"] == "group_allow_from"
|
||
|
|
|
||
|
|
def test_collect_warnings_name_matching(self):
|
||
|
|
warnings = collect_mutable_allowlist_warnings(allow_name_matching=True)
|
||
|
|
assert len(warnings) == 1
|
||
|
|
assert warnings[0]["source"] == "allow_name_matching"
|
||
|
|
|
||
|
|
def test_collect_warnings_empty(self):
|
||
|
|
warnings = collect_mutable_allowlist_warnings()
|
||
|
|
assert len(warnings) == 0
|