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重连相关测试
496 lines
17 KiB
Python
496 lines
17 KiB
Python
"""MSTeams 存储类单元测试 (polls/pairing/feedback/sso/conv_store/sent_cache)。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.msteams.polls import PollStore
|
|
from yuxi.channels.adapters.msteams.pairing import PairingManager, build_pairing_request_card, _make_pair_id
|
|
from yuxi.channels.adapters.msteams.feedback import (
|
|
FeedbackLearningsStore,
|
|
process_feedback,
|
|
parse_reflection_response,
|
|
build_feedback_channel_data,
|
|
build_feedback_activity,
|
|
is_feedback_invoke,
|
|
parse_feedback_value,
|
|
build_reflection_prompt,
|
|
)
|
|
from yuxi.channels.adapters.msteams.sso import SSOTokenStore
|
|
from yuxi.channels.adapters.msteams.proactive import (
|
|
ConversationStore,
|
|
MemoryConversationStore,
|
|
extract_conversation_ref,
|
|
)
|
|
from yuxi.channels.adapters.msteams.sent_message_cache import SentMessageCache
|
|
|
|
|
|
class TestPollStore:
|
|
@pytest.fixture
|
|
def store(self, tmp_path):
|
|
return PollStore(storage_dir=str(tmp_path))
|
|
|
|
def test_create_poll(self, store):
|
|
poll = store.create_poll("poll-1", "Test Poll", ["A", "B", "C"])
|
|
assert poll["poll_id"] == "poll-1"
|
|
assert poll["title"] == "Test Poll"
|
|
assert len(poll["options"]) == 3
|
|
|
|
def test_create_poll_with_creator(self, store):
|
|
poll = store.create_poll("poll-2", "Poll", ["A", "B"], creator_id="user-1")
|
|
assert poll["creator_id"] == "user-1"
|
|
|
|
def test_create_poll_multi_select(self, store):
|
|
poll = store.create_poll("poll-3", "Poll", ["A", "B"], multi_select=True, max_selections=2)
|
|
assert poll["multi_select"] is True
|
|
assert poll["max_selections"] == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cast_vote(self, store):
|
|
store.create_poll("poll-1", "Test", ["A", "B"])
|
|
result = await store.cast_vote("poll-1", "user-1", "A")
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cast_vote_invalid_option(self, store):
|
|
store.create_poll("poll-1", "Test", ["A", "B"])
|
|
result = await store.cast_vote("poll-1", "user-1", "C")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cast_vote_poll_not_found(self, store):
|
|
result = await store.cast_vote("nonexistent", "user-1", "A")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cast_vote_duplicate(self, store):
|
|
store.create_poll("poll-1", "Test", ["A", "B"])
|
|
await store.cast_vote("poll-1", "user-1", "A")
|
|
result = await store.cast_vote("poll-1", "user-1", "B")
|
|
assert result is False
|
|
|
|
def test_get_results(self, store):
|
|
store.create_poll("poll-1", "Test", ["A", "B"])
|
|
|
|
import asyncio
|
|
|
|
async def _vote():
|
|
await store.cast_vote("poll-1", "user-1", "A")
|
|
await store.cast_vote("poll-1", "user-2", "A")
|
|
await store.cast_vote("poll-1", "user-3", "B")
|
|
|
|
asyncio.run(_vote())
|
|
|
|
results = store.get_results("poll-1")
|
|
assert results is not None
|
|
assert results["total_votes"] == 3
|
|
assert results["tally"]["A"] == 2
|
|
assert results["tally"]["B"] == 1
|
|
|
|
def test_get_results_nonexistent(self, store):
|
|
assert store.get_results("nonexistent") is None
|
|
|
|
def test_get_poll(self, store):
|
|
store.create_poll("poll-1", "Test", ["A"])
|
|
poll = store.get_poll("poll-1")
|
|
assert poll is not None
|
|
assert poll["title"] == "Test"
|
|
|
|
def test_has_voted(self, store):
|
|
store.create_poll("poll-1", "Test", ["A"])
|
|
|
|
import asyncio
|
|
|
|
async def _vote():
|
|
await store.cast_vote("poll-1", "user-1", "A")
|
|
|
|
asyncio.run(_vote())
|
|
|
|
assert store.has_voted("poll-1", "user-1") is True
|
|
assert store.has_voted("poll-1", "user-2") is False
|
|
|
|
def test_active_count(self, store):
|
|
store.create_poll("poll-1", "Test", ["A"])
|
|
store.create_poll("poll-2", "Test", ["B"])
|
|
assert store.active_count == 2
|
|
|
|
def test_cleanup(self, store):
|
|
store.create_poll("poll-1", "Test", ["A"])
|
|
store.cleanup()
|
|
assert store.active_count == 1
|
|
|
|
|
|
class TestPairingManager:
|
|
@pytest.fixture
|
|
def manager(self, tmp_path):
|
|
return PairingManager(storage_dir=str(tmp_path))
|
|
|
|
def test_request_pairing(self, manager):
|
|
pair_id = manager.request_pairing("user-1", "Test User", "chat-1")
|
|
assert pair_id.startswith("req_")
|
|
assert pair_id.endswith(str(int(time.time())))
|
|
assert manager.is_pending("user-1") is True
|
|
|
|
def test_approve_pairing(self, manager):
|
|
pair_id = manager.request_pairing("user-1", "Test User")
|
|
result = manager.approve(pair_id)
|
|
assert result is True
|
|
assert manager.is_approved("user-1") is True
|
|
assert manager.is_pending("user-1") is False
|
|
|
|
def test_approve_nonexistent(self, manager):
|
|
result = manager.approve("nonexistent")
|
|
assert result is False
|
|
|
|
def test_deny_pairing(self, manager):
|
|
pair_id = manager.request_pairing("user-1", "Test User")
|
|
result = manager.deny(pair_id)
|
|
assert result is True
|
|
assert manager.is_approved("user-1") is False
|
|
assert manager.is_pending("user-1") is False
|
|
|
|
def test_deny_nonexistent(self, manager):
|
|
result = manager.deny("nonexistent")
|
|
assert result is False
|
|
|
|
def test_get_pending_requests(self, manager):
|
|
manager.request_pairing("user-1", "User One")
|
|
manager.request_pairing("user-2", "User Two")
|
|
pending = manager.get_pending_requests()
|
|
assert len(pending) == 2
|
|
|
|
def test_cleanup(self, manager):
|
|
pair_id = manager.request_pairing("user-1", "User")
|
|
count = manager.cleanup()
|
|
assert count > 0
|
|
|
|
def test_persistence(self, tmp_path):
|
|
mgr1 = PairingManager(storage_dir=str(tmp_path))
|
|
pair_id = mgr1.request_pairing("user-1", "Test")
|
|
mgr1.approve(pair_id)
|
|
|
|
mgr2 = PairingManager(storage_dir=str(tmp_path))
|
|
assert mgr2.is_approved("user-1") is True
|
|
|
|
|
|
class TestBuildPairingRequestCard:
|
|
def test_card_structure(self):
|
|
card = build_pairing_request_card("Test User", "user-001", "req_123")
|
|
assert card["type"] == "AdaptiveCard"
|
|
assert card["version"] == "1.5"
|
|
assert len(card["actions"]) == 2
|
|
assert card["actions"][0]["title"] == "批准"
|
|
assert card["actions"][1]["title"] == "拒绝"
|
|
|
|
|
|
class TestMakePairId:
|
|
def test_format(self):
|
|
result = _make_pair_id("user-001-abc", 1000000.0)
|
|
assert result.startswith("req_user-001-abc")
|
|
assert str(int(1000000)) in result
|
|
|
|
|
|
class TestFeedbackLearningsStore:
|
|
@pytest.fixture
|
|
def store(self, tmp_path):
|
|
return FeedbackLearningsStore(storage_dir=str(tmp_path))
|
|
|
|
def test_is_reflection_allowed_initial(self, store):
|
|
assert store.is_reflection_allowed("session-1") is True
|
|
|
|
def test_store_and_load_learnings(self, store):
|
|
store.store_session_learning("session-1", "user msg", "bot reply", "reflection text")
|
|
learnings = store.load_session_learnings("session-1")
|
|
assert len(learnings) == 1
|
|
assert learnings[0]["reflection"] == "reflection text"
|
|
|
|
def test_session_count(self, store):
|
|
store.store_session_learning("session-1", "msg", "reply", "reflect")
|
|
store.store_session_learning("session-2", "msg", "reply", "reflect")
|
|
assert store.session_count == 2
|
|
|
|
def test_clear(self, store):
|
|
store.store_session_learning("session-1", "msg", "reply", "reflect")
|
|
store.clear()
|
|
assert store.session_count == 0
|
|
|
|
def test_cooldown(self, store):
|
|
store.store_session_learning("session-1", "msg", "reply", "reflect")
|
|
assert store.is_reflection_allowed("session-1") is False
|
|
|
|
def test_clear_cooldowns(self, store):
|
|
store.store_session_learning("session-1", "msg", "reply", "reflect")
|
|
store.clear_reflection_cooldowns("session-1")
|
|
assert store.is_reflection_allowed("session-1") is True
|
|
|
|
def test_load_session_learnings_empty(self, store):
|
|
assert store.load_session_learnings("nonexistent") == []
|
|
|
|
|
|
class TestFeedbackFunctions:
|
|
def test_build_feedback_channel_data_disabled(self):
|
|
result = build_feedback_channel_data(feedback_enabled=False)
|
|
assert result is None
|
|
|
|
def test_build_feedback_channel_data_enabled(self):
|
|
result = build_feedback_channel_data(feedback_enabled=True)
|
|
assert result is not None
|
|
assert result["feedbackLoopEnabled"] is True
|
|
|
|
def test_build_feedback_channel_data_with_reflection(self):
|
|
result = build_feedback_channel_data(feedback_enabled=True, feedback_reflection=True)
|
|
assert result["feedbackReflection"] is True
|
|
|
|
def test_build_feedback_activity(self):
|
|
activity = build_feedback_activity("hello", feedback_enabled=True)
|
|
assert activity["text"] == "hello"
|
|
assert "channelData" in activity
|
|
|
|
def test_is_feedback_invoke(self):
|
|
assert is_feedback_invoke({"name": "message/submitAction"}) is True
|
|
assert is_feedback_invoke({"name": "other"}) is False
|
|
|
|
def test_parse_feedback_value(self):
|
|
result = parse_feedback_value({"value": {"feedbackValue": "like"}})
|
|
assert result == "like"
|
|
|
|
def test_parse_feedback_value_missing(self):
|
|
result = parse_feedback_value({})
|
|
assert result == ""
|
|
|
|
def test_build_reflection_prompt(self):
|
|
prompt = build_reflection_prompt("user text", "bot text")
|
|
assert "user text" in prompt
|
|
assert "bot text" in prompt
|
|
assert "👎" in prompt
|
|
|
|
def test_parse_reflection_json(self):
|
|
raw = json.dumps({"reflection": "I should improve"})
|
|
result = parse_reflection_response(raw)
|
|
assert result == "I should improve"
|
|
|
|
def test_parse_reflection_code_block(self):
|
|
raw = '```json\n{"reflection": "from code"}\n```'
|
|
result = parse_reflection_response(raw)
|
|
assert result == "from code"
|
|
|
|
def test_parse_reflection_fallback(self):
|
|
raw = "plain text reflection"
|
|
result = parse_reflection_response(raw)
|
|
assert result == "plain text reflection"
|
|
|
|
|
|
class TestProcessFeedback:
|
|
def test_positive_feedback(self):
|
|
activity = {
|
|
"name": "message/submitAction",
|
|
"value": {"feedbackValue": "positive"},
|
|
"from": {"id": "user-1", "name": "Test", "aadObjectId": "aad-1"},
|
|
}
|
|
result = process_feedback(activity)
|
|
assert result["is_positive"] is True
|
|
assert result["is_negative"] is False
|
|
assert result["user_id"] == "aad-1"
|
|
|
|
def test_negative_feedback(self):
|
|
activity = {
|
|
"name": "message/submitAction",
|
|
"value": {"feedbackValue": "negative"},
|
|
"from": {"id": "user-1", "name": "Test", "aadObjectId": "aad-1"},
|
|
}
|
|
result = process_feedback(activity)
|
|
assert result["is_positive"] is False
|
|
assert result["is_negative"] is True
|
|
|
|
def test_unknown_feedback(self):
|
|
activity = {
|
|
"name": "message/submitAction",
|
|
"value": {"feedbackValue": "other"},
|
|
"from": {"id": "user-1", "aadObjectId": "aad-1"},
|
|
}
|
|
result = process_feedback(activity)
|
|
assert result["is_positive"] is False
|
|
assert result["is_negative"] is False
|
|
|
|
def test_fallback_user_id(self):
|
|
activity = {
|
|
"name": "message/submitAction",
|
|
"value": {"feedbackValue": "positive"},
|
|
"from": {"id": "user-1"},
|
|
}
|
|
result = process_feedback(activity)
|
|
assert result["user_id"] == "user-1"
|
|
|
|
def test_reply_to_id(self):
|
|
activity = {
|
|
"name": "message/submitAction",
|
|
"value": {"feedbackValue": "positive"},
|
|
"from": {"id": "user-1"},
|
|
"replyToId": "msg-1",
|
|
}
|
|
result = process_feedback(activity)
|
|
assert result["reply_to_id"] == "msg-1"
|
|
|
|
|
|
class TestSSOTokenStore:
|
|
@pytest.fixture
|
|
def store(self, tmp_path):
|
|
return SSOTokenStore(storage_dir=str(tmp_path))
|
|
|
|
def test_store_and_get(self, store):
|
|
store.store("user-1", {"token": "abc123", "expires": "2024-01-01"})
|
|
result = store.get("user-1")
|
|
assert result is not None
|
|
assert result["token"] == "abc123"
|
|
|
|
def test_get_nonexistent(self, store):
|
|
result = store.get("nonexistent")
|
|
assert result is None
|
|
|
|
def test_remove(self, store):
|
|
store.store("user-1", {"token": "abc"})
|
|
store.remove("user-1")
|
|
assert store.get("user-1") is None
|
|
|
|
def test_clear(self, store):
|
|
store.store("user-1", {"token": "abc"})
|
|
store.store("user-2", {"token": "def"})
|
|
store.clear()
|
|
assert store.get("user-1") is None
|
|
assert store.get("user-2") is None
|
|
|
|
def test_persistence(self, tmp_path):
|
|
store1 = SSOTokenStore(storage_dir=str(tmp_path))
|
|
store1.store("user-1", {"token": "abc"})
|
|
|
|
store2 = SSOTokenStore(storage_dir=str(tmp_path))
|
|
result = store2.get("user-1")
|
|
assert result is not None
|
|
|
|
|
|
class TestConversationStore:
|
|
@pytest.fixture
|
|
def store(self, tmp_path):
|
|
return ConversationStore(storage_dir=str(tmp_path))
|
|
|
|
def test_store_and_get(self, store):
|
|
store.store("chat-1", {"conversation": {"id": "conv-1"}})
|
|
ref = store.get("chat-1")
|
|
assert ref is not None
|
|
assert ref["conversation"]["id"] == "conv-1"
|
|
|
|
def test_get_nonexistent(self, store):
|
|
assert store.get("nonexistent") is None
|
|
|
|
def test_remove(self, store):
|
|
store.store("chat-1", {"data": "test"})
|
|
store.remove("chat-1")
|
|
assert store.get("chat-1") is None
|
|
|
|
def test_list_keys(self, store):
|
|
store.store("chat-1", {"data": "a"})
|
|
store.store("chat-2", {"data": "b"})
|
|
keys = store.list_keys()
|
|
assert "chat-1" in keys
|
|
assert "chat-2" in keys
|
|
|
|
def test_count(self, store):
|
|
store.store("chat-1", {"data": "a"})
|
|
assert store.count == 1
|
|
|
|
def test_persistence(self, tmp_path):
|
|
store1 = ConversationStore(storage_dir=str(tmp_path))
|
|
store1.store("chat-1", {"data": "test"})
|
|
|
|
store2 = ConversationStore(storage_dir=str(tmp_path))
|
|
assert store2.get("chat-1") is not None
|
|
|
|
|
|
class TestMemoryConversationStore:
|
|
@pytest.fixture
|
|
def store(self):
|
|
return MemoryConversationStore()
|
|
|
|
def test_store_and_get(self, store):
|
|
store.store("chat-1", {"data": "test"})
|
|
assert store.get("chat-1") is not None
|
|
assert store.get("chat-1")["data"] == "test"
|
|
|
|
def test_get_nonexistent(self, store):
|
|
assert store.get("nonexistent") is None
|
|
|
|
def test_remove(self, store):
|
|
store.store("chat-1", {"data": "test"})
|
|
store.remove("chat-1")
|
|
assert store.get("chat-1") is None
|
|
|
|
def test_count(self, store):
|
|
store.store("a", {"d": 1})
|
|
store.store("b", {"d": 2})
|
|
assert store.count == 2
|
|
|
|
|
|
class TestExtractConversationRef:
|
|
def test_basic_extraction(self):
|
|
activity = {
|
|
"id": "activity-1",
|
|
"from": {"id": "user-1", "name": "Test"},
|
|
"recipient": {"id": "bot-1", "name": "Bot"},
|
|
"conversation": {"id": "conv-1"},
|
|
"channelId": "msteams",
|
|
"serviceUrl": "https://example.com",
|
|
"channelData": {"tenant": {"id": "tenant-1"}},
|
|
}
|
|
ref = extract_conversation_ref(activity)
|
|
assert ref["activityId"] == "activity-1"
|
|
assert ref["user"]["id"] == "user-1"
|
|
assert ref["bot"]["id"] == "bot-1"
|
|
assert ref["graphChatId"] == "conv-1"
|
|
assert ref["tenantId"] == "tenant-1"
|
|
assert ref["serviceUrl"] == "https://example.com"
|
|
|
|
def test_missing_fields_default(self):
|
|
activity = {"id": "a1", "from": {}, "conversation": {}}
|
|
ref = extract_conversation_ref(activity)
|
|
assert ref["activityId"] == "a1"
|
|
assert ref["graphChatId"] == ""
|
|
|
|
|
|
class TestSentMessageCache:
|
|
@pytest.fixture
|
|
def cache(self):
|
|
return SentMessageCache()
|
|
|
|
def test_record_and_was_sent(self, cache):
|
|
cache.record("msg-1", "chat-1")
|
|
assert cache.was_sent("msg-1") is True
|
|
|
|
def test_was_sent_nonexistent(self, cache):
|
|
assert cache.was_sent("nonexistent") is False
|
|
|
|
def test_get_chat_id(self, cache):
|
|
cache.record("msg-1", "chat-1")
|
|
assert cache.get_chat_id("msg-1") == "chat-1"
|
|
|
|
def test_get_chat_id_nonexistent(self, cache):
|
|
assert cache.get_chat_id("nonexistent") is None
|
|
|
|
def test_count(self, cache):
|
|
cache.record("msg-1", "chat-1")
|
|
cache.record("msg-2", "chat-2")
|
|
assert cache.count == 2
|
|
|
|
def test_clear(self, cache):
|
|
cache.record("msg-1", "chat-1")
|
|
cache.clear()
|
|
assert cache.count == 0
|
|
|
|
def test_lru_eviction(self):
|
|
cache = SentMessageCache(max_entries=3)
|
|
for i in range(5):
|
|
cache.record(f"msg-{i}", f"chat-{i}")
|
|
assert cache.count <= 3 |