from __future__ import annotations import json import os import tempfile from unittest.mock import AsyncMock, MagicMock, patch import pytest from yuxi.channels.adapters.feishu.dedup import FeishuDedupStore from yuxi.channels.adapters.feishu.reply_dispatcher import extract_urls from yuxi.channels.adapters.feishu.secret_resolver import resolve_secret from yuxi.channels.adapters.feishu.comment_handler import handle_comment_event from yuxi.channels.adapters.feishu.directory_static import FeishuStaticDirectory from yuxi.channels.adapters.feishu.cards import ( make_date_picker, make_datetime_picker, make_select_person, make_time_picker, make_overflow_menu, ) from yuxi.channels.models import ChannelType class TestDedupPersistence: def test_persist_and_load(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: persist_path = f.name try: store = FeishuDedupStore(persist_path=persist_path, ttl_s=3600) event = {"event": {"event_id": "evt_001", "message": {"message_id": "msg_001"}}} assert not store.has_processed(event) store.record_processed(event) store.finalize_processing(event) store._persist_to_disk() store2 = FeishuDedupStore(persist_path=persist_path, ttl_s=3600) assert store2.has_processed(event) finally: try: os.unlink(persist_path) except OSError: pass def test_persist_skips_expired(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: persist_path = f.name try: store = FeishuDedupStore(persist_path=persist_path, ttl_s=0) event = {"event": {"event_id": "evt_002", "message": {"message_id": "msg_002"}}} store.record_processed(event) store.finalize_processing(event) store._persist_to_disk() store2 = FeishuDedupStore(persist_path=persist_path, ttl_s=3600) assert not store2.has_processed(event) finally: try: os.unlink(persist_path) except OSError: pass def test_no_persist_path_no_error(self): store = FeishuDedupStore() event = {"event": {"event_id": "evt_003"}} store.record_processed(event) store.finalize_processing(event) assert store.has_processed(event) class TestURLUnfurl: def test_extract_urls_basic(self): content = "Check https://example.com and http://test.org/path" urls = extract_urls(content) assert "https://example.com" in urls assert "http://test.org/path" in urls def test_extract_urls_max(self): content = ( "https://a.com https://b.com https://c.com " "https://d.com https://e.com https://f.com" ) urls = extract_urls(content) assert len(urls) <= 5 def test_extract_urls_no_urls(self): assert extract_urls("Hello world") == [] def test_extract_urls_dedup(self): content = "https://a.com https://a.com" assert len(extract_urls(content)) == 1 def test_extract_urls_strips_trailing_punctuation(self): content = "See https://example.com. And more." urls = extract_urls(content) assert "https://example.com" in urls class TestSecretResolver: def test_env_resolution(self, monkeypatch): monkeypatch.setenv("TEST_SECRET", "env_value") result = resolve_secret({}, "test_secret", "TEST_SECRET") assert result == "env_value" def test_file_source(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: f.write("file_secret_value") path = f.name try: result = resolve_secret( {"test_key": {"source": "file", "path": path}}, "test_key" ) assert result == "file_secret_value" finally: os.unlink(path) def test_raw_source(self): result = resolve_secret( {"test_key": {"source": "raw", "value": "raw_value"}}, "test_key" ) assert result == "raw_value" def test_exec_source(self): result = resolve_secret( {"test_key": {"source": "exec", "command": "echo exec_value"}}, "test_key" ) assert result == "exec_value" def test_plain_string(self): result = resolve_secret({"test_key": "plain_value"}, "test_key") assert result == "plain_value" def test_missing_file(self): result = resolve_secret( {"test_key": {"source": "file", "path": "/nonexistent/file"}}, "test_key" ) assert result == "" class TestCommentHandler: def test_comment_event(self): raw = { "event": { "comment": { "comment_id": "cmt_001", "content": { "elements": [ {"textRun": {"text": "这是一个测试评论"}} ] }, "commenter": {"open_id": "ou_user1"}, }, "doc": {"name": "测试文档", "url": "https://doc.example.com/abc", "type": "doc"}, } } msg = handle_comment_event("feishu", ChannelType.FEISHU, raw, "bot_001") assert msg is not None assert "测试文档" in msg.content assert "测试评论" in msg.content assert msg.metadata["doc_type"] == "doc" assert msg.metadata["comment_id"] == "cmt_001" def test_comment_event_no_comment(self): raw = {"event": {}} msg = handle_comment_event("feishu", ChannelType.FEISHU, raw) assert msg is None def test_comment_plain_text_content(self): raw = { "event": { "comment": { "comment_id": "cmt_002", "content": {"text": "纯文本评论"}, "commenter": {"open_id": "ou_user2"}, } } } msg = handle_comment_event("feishu", ChannelType.FEISHU, raw) assert msg is not None assert "纯文本评论" in msg.content class TestStaticDirectory: def test_peers_loaded_from_config(self): config = { "static_directory": { "peers": [{"open_id": "ou_static1", "name": "静态用户1"}], "groups": [{"chat_id": "oc_static1", "name": "静态群组1"}], } } sd = FeishuStaticDirectory(config) assert len(sd.peers) == 1 assert sd.peers[0]["open_id"] == "ou_static1" assert sd.peers[0]["static"] is True assert len(sd.groups) == 1 assert sd.groups[0]["chat_id"] == "oc_static1" def test_merge_peers_no_duplicate(self): config = { "static_directory": { "peers": [ {"open_id": "ou_dynamic", "name": "Static Override"}, {"open_id": "ou_static_only", "name": "Static Only"}, ] } } sd = FeishuStaticDirectory(config) dynamic = [{"open_id": "ou_dynamic", "name": "Dynamic User"}] merged = sd.merge_peers(dynamic) assert len(merged) == 2 names = {p["name"] for p in merged} assert "Dynamic User" in names assert "Static Only" in names def test_merge_groups_no_duplicate(self): config = {"static_directory": {"groups": [{"chat_id": "oc_only", "name": "Static Group"}]}} sd = FeishuStaticDirectory(config) dynamic = [{"chat_id": "oc_existing", "name": "Dynamic Group"}] merged = sd.merge_groups(dynamic) assert len(merged) == 2 def test_empty_config(self): sd = FeishuStaticDirectory({}) assert sd.peers == [] assert sd.groups == [] class TestSelectors: def test_make_date_picker(self): sel = make_date_picker(placeholder="选择日期", value="2026-05-10") assert sel["tag"] == "date_picker" assert sel["placeholder"]["content"] == "选择日期" assert sel["value"] == "2026-05-10" def test_make_time_picker(self): sel = make_time_picker(placeholder="选择时间") assert sel["tag"] == "time_picker" def test_make_datetime_picker(self): sel = make_datetime_picker(initial_datetime="2026-05-10 12:00") assert sel["tag"] == "datetime_picker" assert sel["initial_datetime"] == "2026-05-10 12:00" def test_make_select_person(self): sel = make_select_person(placeholder="选择成员") assert sel["tag"] == "select_person" def test_make_overflow_menu(self): sel = make_overflow_menu(options=[{"text": "选项1", "value": "opt1"}]) assert sel["tag"] == "overflow" assert len(sel["options"]) == 1