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
|