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