ForcePilot/backend/test/unit/channels/test_nextcloudtalk_adapter.py
Kris 3264900bc9 test: 新增多渠道单元测试用例并配置测试环境变量
新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块
同时在测试配置中添加了测试用的OpenAI API密钥环境变量
2026-05-12 00:56:47 +08:00

1732 lines
63 KiB
Python

from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from yuxi.channels.adapters.nextcloudtalk.normalizer import normalize_inbound
from yuxi.channels.exceptions import ChannelNotConnectedError
from yuxi.channels.models import (
ChannelType,
ChatType,
EventType,
MessageType,
)
class TestNormalizeInbound:
def test_normal_text_message(self):
raw = {
"id": 1234,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "Hello World",
"messageType": "comment",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.identity.channel_id == "nextcloud-talk"
assert msg.identity.channel_type == ChannelType.NEXTCLOUDTALK
assert msg.identity.channel_user_id == "user-1"
assert msg.identity.channel_chat_id == "conv-abc"
assert msg.identity.channel_message_id == "1234"
assert msg.content == "Hello World"
assert msg.message_type == MessageType.TEXT
assert msg.chat_type == ChatType.DIRECT
def test_normal_command_message(self):
raw = {
"id": 1235,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "/help",
"messageType": "command",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.message_type == MessageType.COMMAND
assert msg.content == "/help"
def test_message_with_reply(self):
raw = {
"id": 1236,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "reply text",
"messageType": "comment",
"parent": {"id": 1234},
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.reply_to_message_id == "1234"
def test_message_with_attachment(self):
raw = {
"id": 1237,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "check this file",
"messageType": "comment",
"messageParameters": {
"file1": {
"type": "file",
"link": "https://nc.example.com/f/123",
"name": "document.pdf",
"mimetype": "application/pdf",
"size": 1024,
},
},
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert len(msg.attachments) == 1
assert msg.attachments[0].type == "file"
assert msg.attachments[0].filename == "document.pdf"
assert msg.attachments[0].url == "https://nc.example.com/f/123"
assert msg.attachments[0].size_bytes == 1024
def test_system_message_user_joined(self):
raw = {
"id": 1238,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-2",
"actorDisplayName": "New User",
"timestamp": 1700000000,
"message": "",
"messageType": "system",
"systemMessage": "user_added",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.event_type == EventType.MEMBER_JOINED
assert msg.content == "user_added"
def test_system_message_user_removed(self):
raw = {
"id": 1239,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-3",
"actorDisplayName": "Left User",
"timestamp": 1700000000,
"message": "",
"messageType": "system",
"systemMessage": "user_removed",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.event_type == EventType.MEMBER_LEFT
def test_system_message_message_edited(self):
raw = {
"id": 1242,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "",
"messageType": "system",
"systemMessage": "message_edited",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.event_type == EventType.MESSAGE_UPDATED
def test_system_message_recording_started(self):
raw = {
"id": 1243,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "",
"messageType": "system",
"systemMessage": "recording_started",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.event_type == EventType.SYSTEM_EVENT
assert msg.content == "recording_started"
def test_system_message_breakout_rooms_started(self):
raw = {
"id": 1244,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "",
"messageType": "system",
"systemMessage": "breakout_rooms_started",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.event_type == EventType.SYSTEM_EVENT
def test_system_message_poll_voted(self):
raw = {
"id": 1245,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "",
"messageType": "system",
"systemMessage": "poll_voted",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.event_type == EventType.SYSTEM_EVENT
def test_system_message_reaction_revoked(self):
raw = {
"id": 1246,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "",
"messageType": "system",
"systemMessage": "reaction_revoked",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.event_type == EventType.SYSTEM_EVENT
def test_unknown_system_message_defaults_to_system_event(self):
raw = {
"id": 1247,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "",
"messageType": "system",
"systemMessage": "unknown_event_type",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.event_type == EventType.SYSTEM_EVENT
def test_group_chat_message(self):
raw = {
"id": 1240,
"token": "group-conv-1",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "Hello Group",
"messageType": "comment",
}
msg = normalize_inbound(raw, "nextcloud-talk", room_type=2)
assert msg.chat_type == ChatType.GROUP
def test_metadata_fields(self):
raw = {
"id": 1241,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "test",
"messageType": "comment",
"referenceId": "ref-123",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.metadata["nc_message_type"] == "comment"
assert msg.metadata["nc_actor_type"] == "users"
assert msg.metadata["nc_actor_display_name"] == "Test User"
assert msg.metadata["reference_id"] == "ref-123"
class TestFormatOutbound:
def test_format_text_message(self):
from yuxi.channels.adapters.nextcloudtalk.formatter import format_outbound
from yuxi.channels.models import ChannelIdentity, ChannelResponse
response = ChannelResponse(
identity=ChannelIdentity(
channel_id="nextcloud-talk",
channel_type=ChannelType.NEXTCLOUDTALK,
channel_user_id="bot",
channel_chat_id="conv-abc",
channel_message_id="msg-1",
),
content="Hello from bot",
message_type=MessageType.TEXT,
)
payloads = format_outbound(response, "ForcePilot Bot")
assert isinstance(payloads, list)
payload = payloads[0]
assert payload["token"] == "conv-abc"
assert payload["message"] == "Hello from bot"
assert payload["actorDisplayName"] == "ForcePilot Bot"
assert "referenceId" in payload
assert payload["referenceId"].startswith("fp-")
assert len(payload["referenceId"]) > 20
def test_format_with_reply(self):
from yuxi.channels.adapters.nextcloudtalk.formatter import format_outbound
from yuxi.channels.models import ChannelIdentity, ChannelResponse
response = ChannelResponse(
identity=ChannelIdentity(
channel_id="nextcloud-talk",
channel_type=ChannelType.NEXTCLOUDTALK,
channel_user_id="bot",
channel_chat_id="conv-abc",
),
content="Reply text",
reply_to_message_id="1234",
)
payloads = format_outbound(response, "ForcePilot Bot")
payload = payloads[0]
assert payload["replyTo"] == "1234"
def test_format_with_reference_id(self):
from yuxi.channels.adapters.nextcloudtalk.formatter import format_outbound
from yuxi.channels.models import ChannelIdentity, ChannelResponse
response = ChannelResponse(
identity=ChannelIdentity(
channel_id="nextcloud-talk",
channel_type=ChannelType.NEXTCLOUDTALK,
channel_user_id="bot",
channel_chat_id="conv-abc",
),
content="test",
metadata={"reference_id": "custom-ref-1"},
)
payloads = format_outbound(response, "ForcePilot Bot")
payload = payloads[0]
assert payload["referenceId"] == "custom-ref-1"
def test_media_message_raises(self):
from yuxi.channels.adapters.nextcloudtalk.formatter import format_outbound
from yuxi.channels.models import ChannelIdentity, ChannelResponse
response = ChannelResponse(
identity=ChannelIdentity(
channel_id="nextcloud-talk",
channel_type=ChannelType.NEXTCLOUDTALK,
channel_user_id="bot",
channel_chat_id="conv-abc",
),
content="image caption",
message_type=MessageType.IMAGE,
)
with pytest.raises(ValueError, match="Media messages must use send_media"):
format_outbound(response, "ForcePilot Bot")
class TestProbe:
def test_resolve_api_version_v18(self):
from yuxi.channels.adapters.nextcloudtalk.probe import resolve_api_version
assert resolve_api_version({"version": "18.0.0"}) == "v4"
def test_resolve_api_version_v20(self):
from yuxi.channels.adapters.nextcloudtalk.probe import resolve_api_version
assert resolve_api_version({"version": "20.1.0"}) == "v4"
def test_resolve_api_version_v14(self):
from yuxi.channels.adapters.nextcloudtalk.probe import resolve_api_version
assert resolve_api_version({"version": "14.0.0"}) == "v3"
def test_resolve_api_version_v12(self):
from yuxi.channels.adapters.nextcloudtalk.probe import resolve_api_version
assert resolve_api_version({"version": "12.0.0"}) == "v3"
def test_resolve_api_version_legacy(self):
from yuxi.channels.adapters.nextcloudtalk.probe import resolve_api_version
assert resolve_api_version({"version": "10.0.0"}) == "v1"
def test_resolve_api_version_unknown(self):
from yuxi.channels.adapters.nextcloudtalk.probe import resolve_api_version
assert resolve_api_version({}) == "v1"
def test_resolve_api_base(self):
from yuxi.channels.adapters.nextcloudtalk.probe import resolve_api_base
assert resolve_api_base("v4") == "/ocs/v2.php/apps/spreed/api/v4"
assert resolve_api_base("v3") == "/ocs/v2.php/apps/spreed/api/v3"
assert resolve_api_base("v1") == "/ocs/v2.php/apps/spreed/api/v1"
class TestSession:
def test_resolve_dm_route(self):
from yuxi.channels.adapters.nextcloudtalk.session import resolve_session_route
result = resolve_session_route("dm-token", {"type": 1}, "default")
assert "dm:dm-token" in result["route"]
def test_resolve_group_route(self):
from yuxi.channels.adapters.nextcloudtalk.session import resolve_session_route
result = resolve_session_route("group-token", {"type": 2}, "default")
assert "group:group-token" in result["route"]
def test_resolve_chat_type(self):
from yuxi.channels.adapters.nextcloudtalk.session import resolve_chat_type
assert resolve_chat_type(1) == ChatType.DIRECT
assert resolve_chat_type(2) == ChatType.GROUP
assert resolve_chat_type(3) == ChatType.GROUP
class TestSecurity:
def test_dm_policy_open(self):
from yuxi.channels.adapters.nextcloudtalk.security import check_dm_policy
assert check_dm_policy("any-user", {"dm_policy": "open"}) is True
def test_dm_policy_disabled(self):
from yuxi.channels.adapters.nextcloudtalk.security import check_dm_policy
assert check_dm_policy("any-user", {"dm_policy": "disabled"}) is False
def test_dm_policy_allowlist_match(self):
from yuxi.channels.adapters.nextcloudtalk.security import check_dm_policy
assert check_dm_policy("user-1", {"dm_policy": "allowlist", "allow_from": ["nc:user-1"]}) is True
def test_dm_policy_allowlist_no_match(self):
from yuxi.channels.adapters.nextcloudtalk.security import check_dm_policy
assert check_dm_policy("user-2", {"dm_policy": "allowlist", "allow_from": ["nc:user-1"]}) is False
def test_group_policy_open(self):
from yuxi.channels.adapters.nextcloudtalk.security import check_group_policy
assert check_group_policy("token-1", "user-1", {"group_policy": "open"}) is True
def test_group_policy_disabled(self):
from yuxi.channels.adapters.nextcloudtalk.security import check_group_policy
assert check_group_policy("token-1", "user-1", {"group_policy": "disabled"}) is False
def test_group_policy_allowlist_match(self):
from yuxi.channels.adapters.nextcloudtalk.security import check_group_policy
config = {
"group_policy": "allowlist",
"groups": {"token-1": {"allow_from": ["nc:user-1"]}},
}
assert check_group_policy("token-1", "user-1", config) is True
def test_group_policy_allowlist_no_match(self):
from yuxi.channels.adapters.nextcloudtalk.security import check_group_policy
config = {
"group_policy": "allowlist",
"groups": {"token-1": {"allow_from": ["nc:user-1"]}},
}
assert check_group_policy("token-1", "user-2", config) is False
def test_group_policy_global_allowlist_match(self):
from yuxi.channels.adapters.nextcloudtalk.security import check_group_policy
config = {
"group_policy": "allowlist",
"groups": {"token-1": {"allow_from": ["nc:user-1"]}},
"group_allow_from": ["nc:user-3"],
}
assert check_group_policy("token-1", "user-3", config) is True
assert check_group_policy("token-1", "user-2", config) is False
def test_pairing(self):
from yuxi.channels.adapters.nextcloudtalk.security import check_dm_policy, pair_user, unpair_user
pair_user("paired-user")
assert check_dm_policy("paired-user", {"dm_policy": "pairing"}) is True
unpair_user("paired-user")
assert check_dm_policy("paired-user", {"dm_policy": "pairing"}) is False
class TestSendRetry:
@pytest.mark.asyncio
async def test_retry_config_defaults(self):
from yuxi.channels.adapters.nextcloudtalk.send import _retry_config
rc = _retry_config({})
assert rc["max_attempts"] == 3
assert rc["min_delay_ms"] == 400
assert rc["max_delay_ms"] == 30000
assert rc["jitter"] == 0.1
@pytest.mark.asyncio
async def test_retry_config_custom(self):
from yuxi.channels.adapters.nextcloudtalk.send import _retry_config
config = {"retry": {"attempts": 5, "min_delay_ms": 100, "max_delay_ms": 60000, "jitter": 0.2}}
rc = _retry_config(config)
assert rc["max_attempts"] == 5
assert rc["min_delay_ms"] == 100
assert rc["max_delay_ms"] == 60000
assert rc["jitter"] == 0.2
@pytest.mark.asyncio
async def test_with_retry_success_first_attempt(self):
from yuxi.channels.adapters.nextcloudtalk.send import _with_retry
from yuxi.channels.models import DeliveryResult
call_count = 0
async def _do():
nonlocal call_count
call_count += 1
return DeliveryResult(success=True, message_id="msg-1")
result = await _with_retry(_do, {})
assert result.success is True
assert result.message_id == "msg-1"
assert call_count == 1
@pytest.mark.asyncio
async def test_with_retry_success_after_failure(self):
from yuxi.channels.adapters.nextcloudtalk.send import _with_retry
from yuxi.channels.models import DeliveryResult
call_count = 0
async def _do():
nonlocal call_count
call_count += 1
if call_count < 2:
raise ConnectionError("transient")
return DeliveryResult(success=True, message_id="msg-1")
result = await _with_retry(_do, {"retry": {"attempts": 3, "min_delay_ms": 10, "max_delay_ms": 100}})
assert result.success is True
assert call_count == 2
@pytest.mark.asyncio
async def test_with_retry_all_attempts_fail(self):
from yuxi.channels.adapters.nextcloudtalk.send import _with_retry
from yuxi.channels.exceptions import DeliveryFailedError
async def _do():
raise ConnectionError("always fails")
with pytest.raises(DeliveryFailedError):
await _with_retry(_do, {"retry": {"attempts": 2, "min_delay_ms": 10, "max_delay_ms": 100}})
@pytest.mark.asyncio
async def test_with_retry_http_401_raises(self):
from yuxi.channels.adapters.nextcloudtalk.send import _with_retry
from yuxi.channels.exceptions import ChannelAuthenticationError
import aiohttp
async def _do():
resp = MagicMock()
resp.status = 401
raise aiohttp.ClientResponseError(
request_info=MagicMock(),
history=(),
status=401,
message="Unauthorized",
)
with pytest.raises(ChannelAuthenticationError):
await _with_retry(_do, {})
@pytest.mark.asyncio
async def test_with_retry_http_404_returns_failure(self):
from yuxi.channels.adapters.nextcloudtalk.send import _with_retry
import aiohttp
async def _do():
resp = MagicMock()
resp.status = 404
raise aiohttp.ClientResponseError(
request_info=MagicMock(),
history=(),
status=404,
message="Not Found",
)
result = await _with_retry(_do, {})
assert result.success is False
assert "HTTP 404" in result.error
@pytest.mark.asyncio
async def test_with_retry_http_429_retries_then_raises(self):
from yuxi.channels.adapters.nextcloudtalk.send import _with_retry
from yuxi.channels.exceptions import ChannelRateLimitError
import aiohttp
async def _do():
resp = MagicMock()
resp.status = 429
resp.headers = {"Retry-After": "1"}
raise aiohttp.ClientResponseError(
request_info=MagicMock(),
history=(),
status=429,
message="Rate Limited",
headers={"Retry-After": "1"},
)
with pytest.raises(ChannelRateLimitError):
await _with_retry(_do, {"retry": {"attempts": 1, "min_delay_ms": 10, "max_delay_ms": 100}})
class TestClient:
def test_session_property_raises_channel_not_connected(self):
from yuxi.channels.adapters.nextcloudtalk.client import NextcloudTalkClient
client = NextcloudTalkClient("https://nc.example.com", "bot", "pass")
with pytest.raises(ChannelNotConnectedError):
_ = client.session
@pytest.mark.asyncio
async def test_session_property_after_start(self):
from yuxi.channels.adapters.nextcloudtalk.client import NextcloudTalkClient
client = NextcloudTalkClient("https://nc.example.com", "bot", "pass")
await client.start()
try:
session = client.session
assert session is not None
finally:
await client.stop()
@pytest.mark.asyncio
async def test_client_user_agent_header(self):
from yuxi.channels.adapters.nextcloudtalk.client import NextcloudTalkClient
client = NextcloudTalkClient("https://nc.example.com", "bot", "pass")
await client.start()
try:
headers = client.session.headers
assert "User-Agent" in headers
finally:
await client.stop()
def test_auth_headers(self):
from yuxi.channels.adapters.nextcloudtalk.client import NextcloudTalkClient
client = NextcloudTalkClient("https://nc.example.com", "bot", "pass")
headers = client.auth_headers()
assert "Authorization" in headers
assert headers["Authorization"].startswith("Basic ")
def test_api_url(self):
from yuxi.channels.adapters.nextcloudtalk.client import NextcloudTalkClient
client = NextcloudTalkClient("https://nc.example.com", "bot", "pass")
assert client.api_url("/ocs/v2.php/cloud/capabilities") == "https://nc.example.com/ocs/v2.php/cloud/capabilities"
class TestSecurityLazyLoading:
def test_lazy_loading_no_immediate_file_read(self, tmp_path, monkeypatch):
import importlib
import yuxi.channels.adapters.nextcloudtalk.security as sec_mod
monkeypatch.setattr(sec_mod, "_PERSIST_PATH", str(tmp_path / ".paired_users.json"))
importlib.reload(sec_mod)
paired_file = tmp_path / ".paired_users.json"
assert not paired_file.exists() or paired_file.stat().st_size == 0
class TestPollingMonitor:
def test_update_conversations(self):
from yuxi.channels.adapters.nextcloudtalk.monitor_polling import PollingMonitor
from yuxi.channels.adapters.nextcloudtalk.client import NextcloudTalkClient
client = NextcloudTalkClient("https://nc.example.com", "bot", "pass")
monitor = PollingMonitor(client, "/ocs/v2.php/apps/spreed/api/v4", "nc", 1.0, 30)
monitor.update_conversations({"new-token": {"type": 1, "name": "test"}})
assert "new-token" in monitor._conversations
assert monitor._conversations["new-token"]["type"] == 1
class TestDownloadMediaValidation:
@pytest.mark.asyncio
async def test_download_media_rejects_external_url(self):
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
adapter._client = MagicMock()
adapter._client.server_url = "https://nc.example.com"
with pytest.raises(ValueError, match="same Nextcloud instance"):
await adapter.download_media("https://evil.com/f/123")
@pytest.mark.asyncio
async def test_download_media_rejects_path_traversal(self):
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
adapter._client = MagicMock()
adapter._client.server_url = "https://nc.example.com"
with pytest.raises(ValueError, match="path traversal"):
await adapter.download_media("https://nc.example.com/../etc/passwd")
class TestChannelType:
def test_nextcloudtalk_in_enum(self):
assert ChannelType.NEXTCLOUDTALK == "nextcloud-talk"
assert ChannelType("nextcloud-talk") == ChannelType.NEXTCLOUDTALK
class TestRoomInfo:
def test_map_room_type_direct(self):
from yuxi.channels.adapters.nextcloudtalk.room_info import _map_room_type
assert _map_room_type(1) == "direct"
assert _map_room_type(5) == "direct"
assert _map_room_type(6) == "direct"
def test_map_room_type_group(self):
from yuxi.channels.adapters.nextcloudtalk.room_info import _map_room_type
assert _map_room_type(2) == "group"
assert _map_room_type(3) == "group"
assert _map_room_type(4) == "group"
def test_map_room_type_none(self):
from yuxi.channels.adapters.nextcloudtalk.room_info import _map_room_type
assert _map_room_type(None) is None
assert _map_room_type(99) is None
def test_kind_result_direct(self):
from yuxi.channels.adapters.nextcloudtalk.room_info import _kind_result
result = _kind_result("direct")
assert result["kind"] == "direct"
assert result["chat_type"] == ChatType.DIRECT
def test_kind_result_group(self):
from yuxi.channels.adapters.nextcloudtalk.room_info import _kind_result
result = _kind_result("group")
assert result["kind"] == "group"
assert result["chat_type"] == ChatType.GROUP
def test_kind_result_fallback(self):
from yuxi.channels.adapters.nextcloudtalk.room_info import _kind_result
result = _kind_result(None)
assert result["kind"] is None
assert result["source"] == "cache_fallback"
def test_invalidate_room_cache(self):
from yuxi.channels.adapters.nextcloudtalk.room_info import (
_room_kind_cache, invalidate_room_cache,
)
_room_kind_cache["test-token"] = (0, "direct")
invalidate_room_cache("test-token")
assert "test-token" not in _room_kind_cache
_room_kind_cache["a"] = (0, "group")
_room_kind_cache["b"] = (0, "direct")
invalidate_room_cache()
assert len(_room_kind_cache) == 0
class TestReactionOperations:
@pytest.mark.asyncio
async def test_remove_reaction_returns_delivery_result(self):
from unittest.mock import AsyncMock, MagicMock
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
adapter._client = MagicMock()
adapter._client.delete = AsyncMock(return_value={
"ocs": {"meta": {"statuscode": 200}, "data": {}},
})
result = await adapter.remove_reaction("chat-1", "msg-1", "👍")
assert result.success is True
@pytest.mark.asyncio
async def test_get_reactions_returns_metadata(self):
from unittest.mock import AsyncMock, MagicMock
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
adapter._client = MagicMock()
adapter._client.get = AsyncMock(return_value={
"ocs": {"meta": {"statuscode": 200}, "data": {"👍": 3, "❤️": 1}},
})
result = await adapter.get_reactions("chat-1", "msg-1")
assert result.success is True
assert "reactions" in result.metadata
class TestCredentialSource:
def test_resolve_with_source_config(self):
from yuxi.channels.adapters.nextcloudtalk.credentials import resolve_credential_with_source
value, source = resolve_credential_with_source("direct_value", None, None)
assert value == "direct_value"
assert source == "config"
def test_resolve_with_source_env(self):
from yuxi.channels.adapters.nextcloudtalk.credentials import resolve_credential_with_source
value, source = resolve_credential_with_source(None, None, "PATH")
assert value
assert source == "env"
def test_resolve_with_source_none(self):
from yuxi.channels.adapters.nextcloudtalk.credentials import resolve_credential_with_source
value, source = resolve_credential_with_source(None, None, "NONEXISTENT_ENV_VAR_12345")
assert value == ""
assert source == "none"
class TestDMConfig:
def test_resolve_dm_config_exact_match(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_dm_config
config = {"dms": {"user-1": {"enabled": False, "systemPrompt": "custom"}}}
dm_cfg = resolve_dm_config("user-1", config)
assert dm_cfg["enabled"] is False
assert dm_cfg["systemPrompt"] == "custom"
def test_resolve_dm_config_wildcard(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_dm_config
config = {"dms": {"*": {"enabled": False}}}
dm_cfg = resolve_dm_config("unknown-user", config)
assert dm_cfg["enabled"] is False
def test_resolve_dm_config_merge(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_dm_config
config = {
"dms": {
"*": {"enabled": True, "systemPrompt": "base"},
"user-1": {"systemPrompt": "override"},
}
}
dm_cfg = resolve_dm_config("user-1", config)
assert dm_cfg["enabled"] is True
assert dm_cfg["systemPrompt"] == "override"
def test_resolve_dm_enabled(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_dm_enabled
config = {"dms": {"user-1": {"enabled": False}}}
assert resolve_dm_enabled("user-1", config) is False
assert resolve_dm_enabled("user-2", {"dms": {}}) is True
def test_resolve_dm_system_prompt(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_dm_system_prompt
config = {"dms": {"user-1": {"system_prompt": "hello"}}}
assert resolve_dm_system_prompt("user-1", config) == "hello"
assert resolve_dm_system_prompt("user-2", config) is None
def test_resolve_dm_skills(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_dm_skills
config = {"dms": {"user-1": {"skills": ["skill-a", "skill-b"]}}}
assert resolve_dm_skills("user-1", config) == ["skill-a", "skill-b"]
class TestHistoryLimit:
def test_resolve_history_limit_group_default(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_history_limit
assert resolve_history_limit("token-1", {}, "group") == 0
def test_resolve_history_limit_group_config(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_history_limit
assert resolve_history_limit("token-1", {"historyLimit": 50}, "group") == 50
def test_resolve_history_limit_group_room(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_history_limit
config = {"rooms": {"token-1": {"historyLimit": 25}}}
assert resolve_history_limit("token-1", config, "group") == 25
def test_resolve_history_limit_dm(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_history_limit
config = {"dms": {"user-1": {"historyLimit": 10}}}
assert resolve_history_limit("user-1", config, "direct") == 10
def test_resolve_history_limit_dm_global(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_history_limit
assert resolve_history_limit("user-1", {"dmHistoryLimit": 30}, "direct") == 30
class TestDoctorDiagnostics:
def test_check_credentials_pass(self):
from yuxi.channels.adapters.nextcloudtalk.doctor import _check_credentials
result = _check_credentials({"bot_user": "bot", "app_password": "secret"})
assert result["status"] == "pass"
def test_check_credentials_fail(self):
from yuxi.channels.adapters.nextcloudtalk.doctor import _check_credentials
result = _check_credentials({"bot_user": "", "app_password": ""})
assert result["status"] == "fail"
def test_check_server_url_pass(self):
from yuxi.channels.adapters.nextcloudtalk.doctor import _check_server_url
result = _check_server_url({"server_url": "https://nc.example.com"})
assert result["status"] == "pass"
def test_check_server_url_fail_missing(self):
from yuxi.channels.adapters.nextcloudtalk.doctor import _check_server_url
result = _check_server_url({"server_url": ""})
assert result["status"] == "fail"
def test_check_room_configs_empty(self):
from yuxi.channels.adapters.nextcloudtalk.doctor import _check_room_configs
result = _check_room_configs({})
assert result["detail"] == "No per-room configuration"
def test_check_dm_configs_empty(self):
from yuxi.channels.adapters.nextcloudtalk.doctor import _check_dm_configs
result = _check_dm_configs({})
assert result["detail"] == "No per-DM configuration"
class TestStatusSnapshot:
def test_build_snapshot_basic(self):
from yuxi.channels.adapters.nextcloudtalk.status import build_snapshot
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter({"server_url": "https://nc.example.com", "bot_user": "bot", "app_password": "pw"})
snapshot = build_snapshot(adapter)
assert snapshot.account_id == "nextcloud-talk"
assert snapshot.configured is True
assert snapshot.base_url == "https://nc.example.com"
assert snapshot.bot_token_source == "config"
def test_build_snapshot_not_configured(self):
from yuxi.channels.adapters.nextcloudtalk.status import build_snapshot
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter({})
snapshot = build_snapshot(adapter)
assert snapshot.configured is False
assert snapshot.status_state == "not-configured"
class TestApprovalAuth:
def test_check_approval_open(self):
from yuxi.channels.adapters.nextcloudtalk.approval_auth import check_approval_required
result = check_approval_required("user-1", {"dm_policy": "open"})
assert result["approval_required"] is False
def test_check_approval_disabled(self):
from yuxi.channels.adapters.nextcloudtalk.approval_auth import check_approval_required
result = check_approval_required("user-1", {"dm_policy": "disabled"})
assert result["approval_required"] is True
assert result["action"] == "block"
def test_check_approval_allowlist_match(self):
from yuxi.channels.adapters.nextcloudtalk.approval_auth import check_approval_required
result = check_approval_required("nc:user-1", {"dm_policy": "allowlist", "allow_from": ["nc:user-1"]})
assert result["approval_required"] is False
def test_check_approval_allowlist_no_match(self):
from yuxi.channels.adapters.nextcloudtalk.approval_auth import check_approval_required
result = check_approval_required("nc:user-2", {"dm_policy": "allowlist", "allow_from": ["nc:user-1"]})
assert result["approval_required"] is True
def test_check_approval_pairing_not_paired(self):
from yuxi.channels.adapters.nextcloudtalk.approval_auth import check_approval_required
result = check_approval_required("user-1", {"dm_policy": "pairing"}, paired_users=set())
assert result["approval_required"] is True
assert result["action"] == "pair"
def test_resolve_approver_id(self):
from yuxi.channels.adapters.nextcloudtalk.approval_auth import resolve_approver_id
assert resolve_approver_id("nc:user-1") == "nc:user-1"
assert resolve_approver_id("nextcloudtalk:user-1") == "nc:user-1"
class TestSecretContract:
def test_list_secret_targets(self):
from yuxi.channels.adapters.nextcloudtalk.secret_contract import list_secret_targets
targets = list_secret_targets()
assert len(targets) >= 4
assert any(t["key"] == "app_password" and t["secret"] for t in targets)
def test_verify_secret_contract_valid(self):
from yuxi.channels.adapters.nextcloudtalk.secret_contract import verify_secret_contract
result = verify_secret_contract({
"bot_user": "bot",
"app_password": "secret",
"server_url": "https://nc.example.com",
})
assert result["valid"] is True
def test_verify_secret_contract_missing_creds(self):
from yuxi.channels.adapters.nextcloudtalk.secret_contract import verify_secret_contract
result = verify_secret_contract({"bot_user": ""})
assert result["valid"] is False
assert any("bot_user" in issue or "app_password" in issue for issue in result["issues"])
def test_collect_runtime_secrets(self):
from yuxi.channels.adapters.nextcloudtalk.secret_contract import collect_runtime_secrets
secrets = collect_runtime_secrets({
"bot_user": "bot",
"app_password": "secret",
"api_user": "api",
"api_password": "api_secret",
})
assert secrets["bot_user"]["present"] is True
assert secrets["app_password"]["present"] is True
assert secrets["app_password"]["value"] == "***"
class TestConfigSchema:
def test_validate_config_minimal(self):
from yuxi.channels.adapters.nextcloudtalk.config_schema import validate_config
config = validate_config({
"serverUrl": "https://nc.example.com",
"botUser": "bot",
"appPassword": "secret",
})
assert config.server_url == "https://nc.example.com"
assert config.bot_user == "bot"
assert config.dm_policy == "pairing"
def test_validate_config_invalid_url(self):
import pytest
from yuxi.channels.adapters.nextcloudtalk.config_schema import validate_config
with pytest.raises(ValueError, match="server_url"):
validate_config({"serverUrl": "ftp://invalid", "botUser": "bot", "appPassword": "secret"})
def test_validate_config_invalid_dm_policy(self):
import pytest
from yuxi.channels.adapters.nextcloudtalk.config_schema import validate_config
with pytest.raises(ValueError, match="dm_policy"):
validate_config({
"serverUrl": "https://nc.example.com",
"botUser": "bot",
"appPassword": "secret",
"dmPolicy": "unknown",
})
def test_validate_config_with_rooms(self):
from yuxi.channels.adapters.nextcloudtalk.config_schema import validate_config
config = validate_config({
"serverUrl": "https://nc.example.com",
"botUser": "bot",
"appPassword": "secret",
"rooms": {"room-1": {"enabled": False, "systemPrompt": "custom"}},
"dms": {"user-1": {"enabled": True}},
})
assert "room-1" in config.rooms
assert config.rooms["room-1"].enabled is False
def test_validate_config_aliases(self):
from yuxi.channels.adapters.nextcloudtalk.config_schema import validate_config
config = validate_config({
"serverUrl": "https://nc.example.com",
"botUser": "bot",
"appPassword": "secret",
"historyLimit": 50,
"dmHistoryLimit": 30,
"responsePrefix": "[Bot]",
})
assert config.history_limit == 50
assert config.dm_history_limit == 30
assert config.response_prefix == "[Bot]"
class TestNormalizerPinAndForward:
def test_pin_event_detection(self):
raw = {
"id": 1250,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "",
"messageType": "system",
"systemMessage": "message_pinned",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.event_type == EventType.SYSTEM_EVENT
assert msg.content == "message_pinned"
def test_unpin_event_detection(self):
raw = {
"id": 1251,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "",
"messageType": "system",
"systemMessage": "message_unpinned",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.event_type == EventType.SYSTEM_EVENT
def test_forwarded_message_metadata(self):
raw = {
"id": 1252,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "check this {mention-forwarded}",
"messageType": "comment",
"forwardActorDisplayName": "Original Sender",
"forwardActorId": "user-2",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.metadata["nc_forwarded"] is True
assert msg.metadata["nc_forward_actor_display_name"] == "Original Sender"
assert msg.metadata["nc_forward_actor_id"] == "user-2"
def test_non_forwarded_message(self):
raw = {
"id": 1253,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "normal message",
"messageType": "comment",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert "nc_forwarded" not in msg.metadata
class TestAliasSupport:
def test_adapter_aliases_in_meta(self):
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
assert "nc-talk" in NextcloudTalkAdapter.meta.aliases
assert "nc" in NextcloudTalkAdapter.meta.aliases
assert "nextcloud-talk" in NextcloudTalkAdapter.meta.aliases
def test_adapter_registered_with_deprecated_alias(self):
from yuxi.channels.registry import BUILTIN_ADAPTERS, _BUILTIN_ADAPTER_ALIASES
assert "nextcloud-talk" in BUILTIN_ADAPTERS
assert _BUILTIN_ADAPTER_ALIASES.get("nc-talk") == "nextcloud-talk"
assert _BUILTIN_ADAPTER_ALIASES.get("nc") == "nextcloud-talk"
assert _BUILTIN_ADAPTER_ALIASES.get("nextcloud-talk") == "nextcloud-talk"
class TestHMACSigning:
def test_compute_hmac_signature(self):
from yuxi.channels.adapters.nextcloudtalk.client import _compute_hmac_signature
sig = _compute_hmac_signature(b"test body", "my-secret")
assert isinstance(sig, str)
assert len(sig) > 0
def test_compute_hmac_deterministic(self):
from yuxi.channels.adapters.nextcloudtalk.client import _compute_hmac_signature
sig1 = _compute_hmac_signature(b"hello", "secret")
sig2 = _compute_hmac_signature(b"hello", "secret")
assert sig1 == sig2
def test_compute_hmac_different_body_different_sig(self):
from yuxi.channels.adapters.nextcloudtalk.client import _compute_hmac_signature
sig1 = _compute_hmac_signature(b"hello", "secret")
sig2 = _compute_hmac_signature(b"world", "secret")
assert sig1 != sig2
def test_compute_hmac_different_secret_different_sig(self):
from yuxi.channels.adapters.nextcloudtalk.client import _compute_hmac_signature
sig1 = _compute_hmac_signature(b"hello", "secret1")
sig2 = _compute_hmac_signature(b"hello", "secret2")
assert sig1 != sig2
def test_verify_hmac_signature_valid(self):
from yuxi.channels.adapters.nextcloudtalk.client import _compute_hmac_signature, verify_hmac_signature
sig = _compute_hmac_signature(b"test body", "my-secret")
assert verify_hmac_signature(b"test body", sig, "my-secret") is True
def test_verify_hmac_signature_invalid(self):
from yuxi.channels.adapters.nextcloudtalk.client import verify_hmac_signature
assert verify_hmac_signature(b"test body", "invalid-sig", "my-secret") is False
def test_verify_hmac_signature_empty_header(self):
from yuxi.channels.adapters.nextcloudtalk.client import verify_hmac_signature
assert verify_hmac_signature(b"test body", "", "my-secret") is False
def test_verify_hmac_signature_empty_secret(self):
from yuxi.channels.adapters.nextcloudtalk.client import verify_hmac_signature
assert verify_hmac_signature(b"test body", "some-sig", "") is False
def test_client_sign_body_with_secret(self):
from yuxi.channels.adapters.nextcloudtalk.client import NextcloudTalkClient
client = NextcloudTalkClient("https://nc.example.com", "bot", "pass", bot_secret="secret")
headers = client._sign_body(b"hello")
assert "X-Nextcloud-Talk-SHA256" in headers
def test_client_sign_body_without_secret(self):
from yuxi.channels.adapters.nextcloudtalk.client import NextcloudTalkClient
client = NextcloudTalkClient("https://nc.example.com", "bot", "pass")
headers = client._sign_body(b"hello")
assert headers == {}
class TestWebhookSignature:
@pytest.mark.asyncio
async def test_verify_webhook_signature_valid(self):
from yuxi.channels.adapters.nextcloudtalk.client import _compute_hmac_signature
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter({"app_password": "my-secret"})
body = b'{"test": "data"}'
sig = _compute_hmac_signature(body, "my-secret")
result = await adapter.verify_webhook_signature(
{"X-Nextcloud-Talk-SHA256": sig}, body
)
assert result is True
@pytest.mark.asyncio
async def test_verify_webhook_signature_invalid(self):
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter({"app_password": "my-secret"})
result = await adapter.verify_webhook_signature(
{"X-Nextcloud-Talk-SHA256": "bad-signature"}, b"body"
)
assert result is False
@pytest.mark.asyncio
async def test_verify_webhook_signature_no_header(self):
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter({"app_password": "my-secret"})
result = await adapter.verify_webhook_signature({}, b"body")
assert result is False
@pytest.mark.asyncio
async def test_verify_webhook_signature_no_secret(self):
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter({})
result = await adapter.verify_webhook_signature(
{"X-Nextcloud-Talk-SHA256": "some-sig"}, b"body"
)
assert result is False
@pytest.mark.asyncio
async def test_verify_webhook_signature_lowercase_header(self):
from yuxi.channels.adapters.nextcloudtalk.client import _compute_hmac_signature
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter({"app_password": "my-secret"})
body = b"hello"
sig = _compute_hmac_signature(body, "my-secret")
result = await adapter.verify_webhook_signature(
{"x-nextcloud-talk-sha256": sig}, body
)
assert result is True
class TestBackendSourceValidation:
def test_validate_backend_source_match(self):
from yuxi.channels.adapters.nextcloudtalk.security import validate_backend_source
assert validate_backend_source(
{"X-Nextcloud-Talk-Backend": "https://nc.example.com/ocs/v2.php"},
"https://nc.example.com",
) is True
def test_validate_backend_source_mismatch(self):
from yuxi.channels.adapters.nextcloudtalk.security import validate_backend_source
assert validate_backend_source(
{"X-Nextcloud-Talk-Backend": "https://evil.com"},
"https://nc.example.com",
) is False
def test_validate_backend_source_missing_header(self):
from yuxi.channels.adapters.nextcloudtalk.security import validate_backend_source
assert validate_backend_source({}, "https://nc.example.com") is False
def test_validate_backend_source_lowercase_header(self):
from yuxi.channels.adapters.nextcloudtalk.security import validate_backend_source
assert validate_backend_source(
{"x-nextcloud-talk-backend": "https://nc.example.com"},
"https://nc.example.com",
) is True
class TestAS2Normalization:
def test_as2_basic_create_activity(self):
raw = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"actor": {"id": "user-1", "name": "Test User"},
"object": {"id": "msg-100", "content": "AS2 message"},
"target": {"id": "conv-abc"},
"published": 1700000000,
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.identity.channel_user_id == "user-1"
assert msg.identity.channel_chat_id == "conv-abc"
assert msg.identity.channel_message_id == "msg-100"
assert msg.content == "AS2 message"
assert msg.metadata["nc_actor_display_name"] == "Test User"
assert msg.metadata["as2_type"] == "Create"
assert msg.event_type == EventType.MESSAGE_RECEIVED
def test_as2_delete_activity(self):
raw = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Delete",
"actor": {"id": "user-1", "name": "Test User"},
"object": {"id": "msg-101"},
"published": 1700000000,
"messageType": "system",
"systemMessage": "message_deleted",
"token": "conv-abc",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.metadata["as2_type"] == "Delete"
assert msg.event_type == EventType.MESSAGE_DELETED
def test_as2_with_string_actor(self):
raw = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"actor": "user-1",
"actorId": "user-1",
"actorDisplayName": "Test User",
"object": "msg-100",
"id": "msg-100",
"token": "conv-abc",
"timestamp": 1700000000,
"message": "simple message",
"messageType": "comment",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert msg.content == "simple message"
assert msg.metadata["as2_type"] == "Create"
def test_non_as2_message_no_as2_metadata(self):
raw = {
"id": 2000,
"token": "conv-abc",
"actorType": "users",
"actorId": "user-1",
"actorDisplayName": "Test User",
"timestamp": 1700000000,
"message": "regular NC message",
"messageType": "comment",
}
msg = normalize_inbound(raw, "nextcloud-talk")
assert "as2_type" not in msg.metadata
assert msg.content == "regular NC message"
class TestDedupGuardEmptyValues:
def test_claim_returns_true_when_token_empty(self):
from yuxi.channels.adapters.nextcloudtalk.dedup import NextcloudTalkDedupGuard
guard = NextcloudTalkDedupGuard()
assert guard.claim("", "msg-1") is True
def test_claim_returns_true_when_message_id_empty(self):
from yuxi.channels.adapters.nextcloudtalk.dedup import NextcloudTalkDedupGuard
guard = NextcloudTalkDedupGuard()
assert guard.claim("token-1", "") is True
def test_commit_noop_when_empty(self):
from yuxi.channels.adapters.nextcloudtalk.dedup import NextcloudTalkDedupGuard
guard = NextcloudTalkDedupGuard()
guard.commit("", "msg-1")
guard.commit("token-1", "")
assert guard.stats()["committed"] == 0
def test_release_noop_when_empty(self):
from yuxi.channels.adapters.nextcloudtalk.dedup import NextcloudTalkDedupGuard
guard = NextcloudTalkDedupGuard()
guard.release("", "msg-1")
guard.release("token-1", "")
assert guard.stats()["pending"] == 0
def test_is_invalid_returns_false_when_empty(self):
from yuxi.channels.adapters.nextcloudtalk.dedup import NextcloudTalkDedupGuard
guard = NextcloudTalkDedupGuard()
assert guard.is_invalid("", "msg-1") is False
assert guard.is_invalid("token-1", "") is False
def test_is_duplicate_returns_false_when_empty(self):
from yuxi.channels.adapters.nextcloudtalk.dedup import NextcloudTalkDedupGuard
guard = NextcloudTalkDedupGuard()
assert guard.is_duplicate("", "msg-1") is False
assert guard.is_duplicate("token-1", "") is False
def test_mark_invalid_noop_when_empty(self):
from yuxi.channels.adapters.nextcloudtalk.dedup import NextcloudTalkDedupGuard
guard = NextcloudTalkDedupGuard()
guard.mark_invalid("", "msg-1")
guard.mark_invalid("token-1", "")
assert guard.stats()["invalid"] == 0
class TestMatchSource:
def test_match_source_direct(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_room_config
config = {"rooms": {"room-1": {"enabled": True}, "*": {"enabled": False}}}
result = resolve_room_config("room-1", config)
assert result["_match_source"] == "direct"
assert result["enabled"] is True
def test_match_source_wildcard(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_room_config
config = {"rooms": {"*": {"enabled": False}}}
result = resolve_room_config("unknown-room", config)
assert result["_match_source"] == "wildcard"
assert result["enabled"] is False
def test_match_source_none(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_room_config
result = resolve_room_config("room-1", {})
assert result["_match_source"] == "none"
def test_match_source_direct_overrides_wildcard(self):
from yuxi.channels.adapters.nextcloudtalk.config import resolve_room_config
config = {
"rooms": {
"room-1": {"enabled": True, "systemPrompt": "custom"},
"*": {"enabled": False, "systemPrompt": "base"},
}
}
result = resolve_room_config("room-1", config)
assert result["_match_source"] == "direct"
assert result["enabled"] is True
assert result["systemPrompt"] == "custom"
class TestActivityRecording:
def test_initial_activity_stats(self):
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
stats = adapter.get_activity_stats()
assert stats["inbound_count"] == 0
assert stats["outbound_count"] == 0
assert stats["last_inbound_seconds_ago"] is None
assert stats["last_outbound_seconds_ago"] is None
class TestPollOperations:
@pytest.mark.asyncio
async def test_create_poll_success(self):
from unittest.mock import AsyncMock, MagicMock
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
adapter._client = MagicMock()
adapter._client.post = AsyncMock(return_value={
"ocs": {"meta": {"status": "ok"}, "data": {"id": "poll-1"}},
})
result = await adapter.create_poll("chat-1", "Favorite color?", ["Red", "Blue", "Green"])
assert result.success is True
assert result.message_id == "poll-1"
@pytest.mark.asyncio
async def test_create_poll_failure(self):
from unittest.mock import AsyncMock, MagicMock
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
adapter._client = MagicMock()
adapter._client.post = AsyncMock(return_value={
"ocs": {"meta": {"status": "error", "message": "Invalid options"}},
})
result = await adapter.create_poll("chat-1", "?", [])
assert result.success is False
class TestVoiceOperations:
@pytest.mark.asyncio
async def test_send_voice_delegates_to_send_media(self):
from unittest.mock import AsyncMock, MagicMock
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
adapter._client = MagicMock()
adapter._client.post_form = AsyncMock(return_value={
"ocs": {"meta": {"statuscode": 201}, "data": {"id": "voice-msg-1"}},
})
result = await adapter.send_voice("chat-1", b"fake-audio-data")
assert result.success is True
assert result.message_id == "voice-msg-1"
@pytest.mark.asyncio
async def test_transcribe_voice_success(self):
from unittest.mock import AsyncMock, MagicMock
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
adapter._client = MagicMock()
adapter._client.get = AsyncMock(return_value={
"ocs": {"meta": {"status": "ok"}, "data": {"transcription": "Hello world"}},
})
result = await adapter.transcribe_voice("chat-1", "msg-1")
assert result.success is True
assert result.metadata.get("transcription") == "Hello world"
@pytest.mark.asyncio
async def test_transcribe_voice_not_available(self):
from unittest.mock import AsyncMock, MagicMock
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
adapter._client = MagicMock()
adapter._client.get = AsyncMock(return_value={
"ocs": {"meta": {"status": "error"}},
})
result = await adapter.transcribe_voice("chat-1", "msg-1")
assert result.success is False
class TestImageAnalysis:
@pytest.mark.asyncio
async def test_analyze_image_success(self):
from unittest.mock import AsyncMock, MagicMock
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
adapter._client = MagicMock()
adapter._client.get = AsyncMock(return_value={
"ocs": {"meta": {"status": "ok"}, "data": {"analysis": "A cat sitting on a chair"}},
})
result = await adapter.analyze_image("chat-1", "msg-1")
assert result.success is True
assert result.metadata.get("analysis") == "A cat sitting on a chair"
@pytest.mark.asyncio
async def test_analyze_image_with_prompt(self):
from unittest.mock import AsyncMock, MagicMock
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
adapter._client = MagicMock()
adapter._client.post = AsyncMock(return_value={
"ocs": {"meta": {"status": "ok"}, "data": {"result": "The image contains a dog"}},
})
result = await adapter.analyze_image("chat-1", "msg-1", prompt="What animal is this?")
assert result.success is True
assert result.metadata.get("analysis") == "The image contains a dog"
class TestWebhookURLFallback:
def test_public_url_defaults_to_localhost(self):
from yuxi.channels.adapters.nextcloudtalk.webhook_server import NextcloudTalkWebhookServer
adapter = MagicMock()
adapter.channel_id = "nextcloud-talk"
adapter.config = {}
server = NextcloudTalkWebhookServer(adapter, host="0.0.0.0", port=8788)
assert server.public_url == "http://localhost:8788/nextcloud-talk-webhook"
def test_public_url_uses_configured_host(self):
from yuxi.channels.adapters.nextcloudtalk.webhook_server import NextcloudTalkWebhookServer
adapter = MagicMock()
adapter.channel_id = "nextcloud-talk"
adapter.config = {}
server = NextcloudTalkWebhookServer(adapter, host="192.168.1.100", port=9090, path="/webhook")
assert server.public_url == "http://192.168.1.100:9090/webhook"
def test_public_url_omits_standard_ports(self):
from yuxi.channels.adapters.nextcloudtalk.webhook_server import NextcloudTalkWebhookServer
adapter = MagicMock()
adapter.channel_id = "nextcloud-talk"
adapter.config = {}
server = NextcloudTalkWebhookServer(adapter, host="example.com", port=80)
assert server.public_url == "http://example.com/nextcloud-talk-webhook"
server443 = NextcloudTalkWebhookServer(adapter, host="example.com", port=443)
assert server443.public_url == "http://example.com/nextcloud-talk-webhook"
class TestApprovalOperations:
@pytest.mark.asyncio
async def test_request_approval_success(self):
from unittest.mock import AsyncMock, MagicMock
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
adapter._client = MagicMock()
adapter._client.post = AsyncMock(return_value={
"ocs": {"meta": {"status": "ok"}, "data": {"id": "approval-1"}},
})
result = await adapter.request_approval("chat-1", "exec_command", {"cmd": "ls"})
assert result.success is True
assert result.message_id == "approval-1"
@pytest.mark.asyncio
async def test_request_approval_failure(self):
from unittest.mock import AsyncMock, MagicMock
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
adapter._client = MagicMock()
adapter._client.post = AsyncMock(return_value={
"ocs": {"meta": {"status": "error", "message": "Denied"}},
})
result = await adapter.request_approval("chat-1", "exec_command", {})
assert result.success is False
class TestDebugAccounts:
def test_debug_env_var_defaults_false(self):
import os
old_val = os.environ.pop("OPENCLAW_DEBUG_NEXTCLOUD_TALK_ACCOUNTS", None)
try:
import importlib
import yuxi.channels.adapters.nextcloudtalk.adapter as mod
importlib.reload(mod)
assert mod._DEBUG_ACCOUNTS is False
finally:
if old_val is not None:
os.environ["OPENCLAW_DEBUG_NEXTCLOUD_TALK_ACCOUNTS"] = old_val
def test_debug_env_var_enabled(self, monkeypatch):
monkeypatch.setenv("OPENCLAW_DEBUG_NEXTCLOUD_TALK_ACCOUNTS", "1")
import importlib
import yuxi.channels.adapters.nextcloudtalk.adapter as mod
importlib.reload(mod)
assert mod._DEBUG_ACCOUNTS is True