333 lines
12 KiB
Python
333 lines
12 KiB
Python
|
|
"""MSTeams runtime / presentation / welcome / file_upload 单元测试。"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import time
|
||
|
|
from unittest import mock
|
||
|
|
from unittest.mock import patch, AsyncMock, MagicMock
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from yuxi.channels.adapters.msteams.runtime import (
|
||
|
|
MSTeamsRuntime,
|
||
|
|
get_msteams_runtime,
|
||
|
|
set_msteams_runtime,
|
||
|
|
reset_msteams_runtime,
|
||
|
|
)
|
||
|
|
from yuxi.channels.adapters.msteams.presentation import (
|
||
|
|
build_presentation_card,
|
||
|
|
add_actions_to_card,
|
||
|
|
)
|
||
|
|
from yuxi.channels.adapters.msteams.welcome import (
|
||
|
|
build_personal_welcome_card,
|
||
|
|
build_group_welcome_message,
|
||
|
|
build_welcome_response,
|
||
|
|
)
|
||
|
|
from yuxi.channels.adapters.msteams.file_upload import (
|
||
|
|
needs_file_consent,
|
||
|
|
choose_upload_strategy,
|
||
|
|
build_file_consent_card,
|
||
|
|
build_teams_file_info_card,
|
||
|
|
create_shareable_link,
|
||
|
|
PendingUploadStore,
|
||
|
|
FILE_CONSENT_MAX_SIZE,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestMSTeamsRuntime:
|
||
|
|
def test_default_values(self):
|
||
|
|
rt = MSTeamsRuntime()
|
||
|
|
assert rt.app_id == ""
|
||
|
|
assert rt.connected is False
|
||
|
|
assert rt.streaming_mode == "block"
|
||
|
|
|
||
|
|
def test_update(self):
|
||
|
|
rt = MSTeamsRuntime()
|
||
|
|
rt.update(app_id="test-123", streaming_mode="off")
|
||
|
|
assert rt.app_id == "test-123"
|
||
|
|
assert rt.streaming_mode == "off"
|
||
|
|
|
||
|
|
def test_update_ignores_unknown(self):
|
||
|
|
rt = MSTeamsRuntime()
|
||
|
|
rt.update(nonexistent_field="value")
|
||
|
|
assert not hasattr(rt, "nonexistent_field")
|
||
|
|
|
||
|
|
def test_set_connected(self):
|
||
|
|
rt = MSTeamsRuntime()
|
||
|
|
rt.set_connected(app_id="app-1", tenant_id="tenant-1")
|
||
|
|
assert rt.connected is True
|
||
|
|
assert rt.app_id == "app-1"
|
||
|
|
assert rt.tenant_id == "tenant-1"
|
||
|
|
assert rt.connected_at > 0
|
||
|
|
|
||
|
|
def test_set_disconnected(self):
|
||
|
|
rt = MSTeamsRuntime()
|
||
|
|
rt.set_connected(app_id="app-1")
|
||
|
|
rt.set_disconnected()
|
||
|
|
assert rt.connected is False
|
||
|
|
assert rt.connected_at == 0
|
||
|
|
|
||
|
|
def test_increment_decrement_uploads(self):
|
||
|
|
rt = MSTeamsRuntime()
|
||
|
|
rt.increment_pending_uploads(3)
|
||
|
|
assert rt.pending_uploads_count == 3
|
||
|
|
rt.decrement_pending_uploads(2)
|
||
|
|
assert rt.pending_uploads_count == 1
|
||
|
|
rt.decrement_pending_uploads(5)
|
||
|
|
assert rt.pending_uploads_count == 0
|
||
|
|
|
||
|
|
def test_increment_decrement_streams(self):
|
||
|
|
rt = MSTeamsRuntime()
|
||
|
|
rt.increment_active_streams(2)
|
||
|
|
assert rt.active_streams_count == 2
|
||
|
|
rt.decrement_active_streams(1)
|
||
|
|
assert rt.active_streams_count == 1
|
||
|
|
rt.decrement_active_streams(5)
|
||
|
|
assert rt.active_streams_count == 0
|
||
|
|
|
||
|
|
def test_get_snapshot(self):
|
||
|
|
rt = MSTeamsRuntime()
|
||
|
|
rt.update(app_id="app-id-1234567890")
|
||
|
|
snapshot = rt.get_snapshot()
|
||
|
|
assert "app_id" in snapshot
|
||
|
|
assert "connected" in snapshot
|
||
|
|
assert "streaming_mode" in snapshot
|
||
|
|
assert snapshot["app_id"] == "app-id-12345..."
|
||
|
|
|
||
|
|
def test_thread_safety(self):
|
||
|
|
rt = MSTeamsRuntime()
|
||
|
|
import threading
|
||
|
|
|
||
|
|
def _update():
|
||
|
|
for i in range(100):
|
||
|
|
rt.update(app_id=f"app-{i}")
|
||
|
|
|
||
|
|
threads = [threading.Thread(target=_update) for _ in range(5)]
|
||
|
|
for t in threads:
|
||
|
|
t.start()
|
||
|
|
for t in threads:
|
||
|
|
t.join()
|
||
|
|
assert rt.app_id.startswith("app-")
|
||
|
|
|
||
|
|
|
||
|
|
class TestRuntimeSingleton:
|
||
|
|
def teardown_method(self):
|
||
|
|
reset_msteams_runtime()
|
||
|
|
|
||
|
|
def test_get_default_returns_singleton(self):
|
||
|
|
reset_msteams_runtime()
|
||
|
|
r1 = get_msteams_runtime()
|
||
|
|
r2 = get_msteams_runtime()
|
||
|
|
assert r1 is r2
|
||
|
|
|
||
|
|
def test_set_replaces_singleton(self):
|
||
|
|
reset_msteams_runtime()
|
||
|
|
custom = MSTeamsRuntime(app_id="custom")
|
||
|
|
set_msteams_runtime(custom)
|
||
|
|
rt = get_msteams_runtime()
|
||
|
|
assert rt.app_id == "custom"
|
||
|
|
assert rt is custom
|
||
|
|
|
||
|
|
def test_reset_creates_new_instance(self):
|
||
|
|
reset_msteams_runtime()
|
||
|
|
rt = get_msteams_runtime()
|
||
|
|
rt.update(app_id="old")
|
||
|
|
reset_msteams_runtime()
|
||
|
|
new_rt = get_msteams_runtime()
|
||
|
|
assert new_rt.app_id == ""
|
||
|
|
assert new_rt is not rt
|
||
|
|
|
||
|
|
|
||
|
|
class TestPresentationCard:
|
||
|
|
def test_empty_card(self):
|
||
|
|
card = build_presentation_card()
|
||
|
|
assert card["type"] == "AdaptiveCard"
|
||
|
|
assert card["version"] == "1.5"
|
||
|
|
|
||
|
|
def test_with_title(self):
|
||
|
|
card = build_presentation_card(title="My Card")
|
||
|
|
assert card["body"][0]["text"] == "My Card"
|
||
|
|
assert card["body"][0]["size"] == "Large"
|
||
|
|
|
||
|
|
def test_text_block(self):
|
||
|
|
card = build_presentation_card(blocks=[{"type": "text", "text": "hello"}])
|
||
|
|
body_item = card["body"][0]
|
||
|
|
assert body_item["type"] == "TextBlock"
|
||
|
|
assert body_item["text"] == "hello"
|
||
|
|
|
||
|
|
def test_context_block(self):
|
||
|
|
card = build_presentation_card(blocks=[{"type": "context", "text": "info"}])
|
||
|
|
body_item = card["body"][0]
|
||
|
|
assert body_item["isSubtle"] is True
|
||
|
|
assert body_item["size"] == "small"
|
||
|
|
|
||
|
|
def test_section_block(self):
|
||
|
|
card = build_presentation_card(blocks=[{"type": "section", "title": "Sec", "text": "body text"}])
|
||
|
|
assert len(card["body"]) >= 2
|
||
|
|
|
||
|
|
def test_facts_block(self):
|
||
|
|
card = build_presentation_card(blocks=[{"type": "facts", "facts": {"key1": "val1"}}])
|
||
|
|
body_item = card["body"][0]
|
||
|
|
assert body_item["type"] == "FactSet"
|
||
|
|
assert body_item["facts"][0]["title"] == "key1"
|
||
|
|
|
||
|
|
def test_image_block(self):
|
||
|
|
card = build_presentation_card(blocks=[{"type": "image", "url": "https://example.com/img.png", "alt": "desc"}])
|
||
|
|
body_item = card["body"][0]
|
||
|
|
assert body_item["type"] == "Image"
|
||
|
|
assert body_item["url"] == "https://example.com/img.png"
|
||
|
|
|
||
|
|
def test_buttons_block(self):
|
||
|
|
card = build_presentation_card(blocks=[{
|
||
|
|
"type": "buttons",
|
||
|
|
"buttons": [
|
||
|
|
{"title": "Open", "url": "https://example.com"},
|
||
|
|
{"title": "Submit", "data": {"action": "ok"}},
|
||
|
|
],
|
||
|
|
}])
|
||
|
|
body_item = card["body"][0]
|
||
|
|
assert body_item["type"] == "ActionSet"
|
||
|
|
assert len(body_item["actions"]) == 2
|
||
|
|
assert body_item["actions"][0]["type"] == "Action.OpenUrl"
|
||
|
|
assert body_item["actions"][1]["type"] == "Action.Submit"
|
||
|
|
|
||
|
|
def test_unknown_block_type(self):
|
||
|
|
card = build_presentation_card(blocks=[{"type": "unknown", "text": "hi"}])
|
||
|
|
assert card["body"] == []
|
||
|
|
|
||
|
|
def test_add_actions_to_card(self):
|
||
|
|
card = {"type": "AdaptiveCard", "body": []}
|
||
|
|
actions = [{"title": "Click", "url": "https://example.com", "style": "positive"}]
|
||
|
|
result = add_actions_to_card(card, actions)
|
||
|
|
assert len(result["actions"]) == 1
|
||
|
|
assert result["actions"][0]["type"] == "Action.OpenUrl"
|
||
|
|
|
||
|
|
|
||
|
|
class TestWelcome:
|
||
|
|
def test_personal_welcome_card(self):
|
||
|
|
card = build_personal_welcome_card()
|
||
|
|
assert card["type"] == "AdaptiveCard"
|
||
|
|
assert len(card["body"]) >= 4
|
||
|
|
assert card["body"][0]["text"].startswith("欢迎")
|
||
|
|
|
||
|
|
def test_personal_welcome_custom_name(self):
|
||
|
|
card = build_personal_welcome_card(bot_name="MyBot")
|
||
|
|
assert "MyBot" in card["body"][0]["text"]
|
||
|
|
|
||
|
|
def test_personal_welcome_custom_starters(self):
|
||
|
|
starters = ["What can you do?", "Help me"]
|
||
|
|
card = build_personal_welcome_card(prompt_starters=starters)
|
||
|
|
assert "What can you do?" in str(card["body"])
|
||
|
|
|
||
|
|
def test_group_welcome_message(self):
|
||
|
|
msg = build_group_welcome_message()
|
||
|
|
assert "/help" in msg
|
||
|
|
|
||
|
|
def test_group_welcome_custom_name(self):
|
||
|
|
msg = build_group_welcome_message(bot_name="MyBot")
|
||
|
|
assert "MyBot" in msg
|
||
|
|
|
||
|
|
def test_build_welcome_response_personal(self):
|
||
|
|
resp = build_welcome_response("personal", "Bot")
|
||
|
|
assert resp["type"] == "card"
|
||
|
|
assert "adaptive_card" in resp
|
||
|
|
|
||
|
|
def test_build_welcome_response_group(self):
|
||
|
|
resp = build_welcome_response("groupChat", "Bot")
|
||
|
|
assert resp["type"] == "text"
|
||
|
|
assert "content" in resp
|
||
|
|
|
||
|
|
|
||
|
|
class TestFileUpload:
|
||
|
|
def test_needs_file_consent_direct_under_limit(self):
|
||
|
|
assert needs_file_consent(FILE_CONSENT_MAX_SIZE - 1, "direct") is False
|
||
|
|
|
||
|
|
def test_needs_file_consent_direct_over_limit(self):
|
||
|
|
assert needs_file_consent(FILE_CONSENT_MAX_SIZE + 1, "direct") is True
|
||
|
|
|
||
|
|
def test_needs_file_consent_group_under_limit(self):
|
||
|
|
assert needs_file_consent(FILE_CONSENT_MAX_SIZE * 4, "group") is False
|
||
|
|
|
||
|
|
def test_needs_file_consent_group_over_limit(self):
|
||
|
|
assert needs_file_consent(FILE_CONSENT_MAX_SIZE * 6, "group") is True
|
||
|
|
|
||
|
|
def test_choose_upload_strategy_direct_small(self):
|
||
|
|
result = choose_upload_strategy(1024, "direct")
|
||
|
|
assert result == "direct"
|
||
|
|
|
||
|
|
def test_choose_upload_strategy_sharepoint(self):
|
||
|
|
result = choose_upload_strategy(
|
||
|
|
FILE_CONSENT_MAX_SIZE + 1, "group", sharepoint_site_id="site-1",
|
||
|
|
)
|
||
|
|
assert result == "sharepoint"
|
||
|
|
|
||
|
|
def test_choose_upload_strategy_onedrive(self):
|
||
|
|
result = choose_upload_strategy(
|
||
|
|
FILE_CONSENT_MAX_SIZE + 1, "group", sharepoint_site_id="",
|
||
|
|
)
|
||
|
|
assert result == "onedrive"
|
||
|
|
|
||
|
|
def test_build_file_consent_card(self):
|
||
|
|
card = build_file_consent_card("report.pdf", 2097152, "desc")
|
||
|
|
assert card["type"] == "AdaptiveCard"
|
||
|
|
assert len(card["actions"]) == 2
|
||
|
|
assert card["actions"][0]["title"] == "接受"
|
||
|
|
assert card["actions"][1]["title"] == "拒绝"
|
||
|
|
assert "report.pdf" in card["body"][1]["text"]
|
||
|
|
assert "2.0 MB" in card["body"][1]["text"]
|
||
|
|
|
||
|
|
def test_build_file_consent_card_with_context(self):
|
||
|
|
card = build_file_consent_card(
|
||
|
|
"file.txt", 1024,
|
||
|
|
accept_context={"file_id": "123"},
|
||
|
|
decline_context={"file_id": "123"},
|
||
|
|
)
|
||
|
|
assert card["actions"][0]["data"]["file_id"] == "123"
|
||
|
|
|
||
|
|
def test_create_shareable_link(self):
|
||
|
|
result = create_shareable_link("token", "item-1", "edit")
|
||
|
|
assert result["item_id"] == "item-1"
|
||
|
|
assert result["link_type"] == "edit"
|
||
|
|
|
||
|
|
def test_build_teams_file_info_card(self):
|
||
|
|
card = build_teams_file_info_card("doc.pdf", 1048576, content_url="https://example.com")
|
||
|
|
assert card["contentType"] == "application/vnd.microsoft.teams.file.info"
|
||
|
|
assert card["content"]["name"] == "doc.pdf"
|
||
|
|
assert "1.0 MB" in card["metadata"]["size_str"]
|
||
|
|
|
||
|
|
def test_build_teams_file_info_card_kb(self):
|
||
|
|
card = build_teams_file_info_card("doc.txt", 512)
|
||
|
|
assert "KB" in card["metadata"]["size_str"]
|
||
|
|
|
||
|
|
|
||
|
|
class TestPendingUploadStore:
|
||
|
|
@pytest.fixture
|
||
|
|
def store(self, tmp_path):
|
||
|
|
return PendingUploadStore(storage_dir=str(tmp_path))
|
||
|
|
|
||
|
|
def test_save_and_load(self, store):
|
||
|
|
store.save_pending("upload-1", "file.txt", "base64data", "conv-1")
|
||
|
|
entry = store.load_pending("upload-1")
|
||
|
|
assert entry is not None
|
||
|
|
assert entry["filename"] == "file.txt"
|
||
|
|
assert entry["conversation_id"] == "conv-1"
|
||
|
|
|
||
|
|
def test_load_nonexistent(self, store):
|
||
|
|
assert store.load_pending("nonexistent") is None
|
||
|
|
|
||
|
|
def test_remove(self, store):
|
||
|
|
store.save_pending("upload-1", "file.txt", "data", "conv-1")
|
||
|
|
store.remove_pending("upload-1")
|
||
|
|
assert store.load_pending("upload-1") is None
|
||
|
|
|
||
|
|
def test_list_pending_ids(self, store):
|
||
|
|
store.save_pending("u1", "f1", "d1", "c1")
|
||
|
|
store.save_pending("u2", "f2", "d2", "c2")
|
||
|
|
ids = store.list_pending_ids()
|
||
|
|
assert "u1" in ids
|
||
|
|
assert "u2" in ids
|
||
|
|
|
||
|
|
def test_cleanup_expired(self, store):
|
||
|
|
store.save_pending("u1", "f1", "d1", "c1")
|
||
|
|
cleaned = store.cleanup_expired(max_age_seconds=0)
|
||
|
|
assert cleaned >= 0
|