ForcePilot/backend/test/unit/channels/test_msteams_storage.py

496 lines
17 KiB
Python
Raw Normal View History

"""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