ForcePilot/backend/test/unit/channels/test_nextcloudtalk_adapter.py
Kris 69fe97a90d test: 批量修复并新增单元测试用例
1. 移除Telegram格式化测试中未使用的导入项
2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关
3. 更新钉钉适配器测试,替换弃用的流属性检查
4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑
5. 重构会话映射测试,完善数据库执行结果模拟
6. 格式化Slack块构建测试的长参数调用
7. 修复LINE适配器测试,更新能力断言和异步锁使用
8. 修正Slack会话解析测试,修复聊天类型判断错误
9. 更新能力测试,补充缺失的字段检查
10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑
11. 为飞书分析模块测试添加跳过标记
12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试
13. 修复Twitch适配器导入路径和测试断言
14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试
15. 修复Manager阶段测试的导入路径
16. 新增iMessage异常和命令处理的单元测试
17. 新增Nostr健康检查和相关模块的单元测试
18. 新增Signal守护进程和SSE重连相关测试
2026-05-13 16:43:01 +08:00

1958 lines
71 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_bot_signature(self):
from yuxi.channels.adapters.nextcloudtalk.client import _compute_bot_signature
sig = _compute_bot_signature(b"test body", "random-hex-32bytes-nonce", "my-secret")
assert isinstance(sig, str)
assert len(sig) > 0
def test_compute_bot_signature_deterministic(self):
from yuxi.channels.adapters.nextcloudtalk.client import _compute_bot_signature
sig1 = _compute_bot_signature(b"hello", "fixed-random", "secret")
sig2 = _compute_bot_signature(b"hello", "fixed-random", "secret")
assert sig1 == sig2
def test_compute_bot_signature_different_body_different_sig(self):
from yuxi.channels.adapters.nextcloudtalk.client import _compute_bot_signature
sig1 = _compute_bot_signature(b"hello", "fixed-random", "secret")
sig2 = _compute_bot_signature(b"world", "fixed-random", "secret")
assert sig1 != sig2
def test_compute_bot_signature_different_secret_different_sig(self):
from yuxi.channels.adapters.nextcloudtalk.client import _compute_bot_signature
sig1 = _compute_bot_signature(b"hello", "fixed-random", "secret1")
sig2 = _compute_bot_signature(b"hello", "fixed-random", "secret2")
assert sig1 != sig2
def test_compute_bot_signature_different_nonce_different_sig(self):
from yuxi.channels.adapters.nextcloudtalk.client import _compute_bot_signature
sig1 = _compute_bot_signature(b"hello", "random-aaa", "secret")
sig2 = _compute_bot_signature(b"hello", "random-bbb", "secret")
assert sig1 != sig2
def test_verify_hmac_signature_valid(self):
from yuxi.channels.adapters.nextcloudtalk.client import _compute_bot_signature, verify_hmac_signature
random_hex = "test-random-nonce"
sig = _compute_bot_signature(b"test body", random_hex, "my-secret")
assert verify_hmac_signature(b"test body", random_hex, 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", "random", "invalid-sig", "my-secret") is False
def test_verify_hmac_signature_empty_random(self):
from yuxi.channels.adapters.nextcloudtalk.client import verify_hmac_signature
assert verify_hmac_signature(b"test body", "", "some-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", "random", "", "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", "random", "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-Bot-Random" in headers
assert "X-Nextcloud-Talk-Bot-Signature" in headers
assert len(headers["X-Nextcloud-Talk-Bot-Random"]) == 64
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_bot_signature
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter({"app_password": "my-secret"})
body = b'{"test": "data"}'
random_hex = "test-nonce-for-webhook"
sig = _compute_bot_signature(body, random_hex, "my-secret")
result = await adapter.verify_webhook_signature(
{"X-Nextcloud-Talk-Bot-Signature": sig, "X-Nextcloud-Talk-Bot-Random": random_hex}, 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-Bot-Signature": "bad-signature", "X-Nextcloud-Talk-Bot-Random": "random"}, 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-Bot-Signature": "some-sig", "X-Nextcloud-Talk-Bot-Random": "random"}, 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_bot_signature
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter({"app_password": "my-secret"})
body = b"hello"
random_hex = "lowercase-test-nonce"
sig = _compute_bot_signature(body, random_hex, "my-secret")
result = await adapter.verify_webhook_signature(
{"x-nextcloud-talk-bot-signature": sig, "x-nextcloud-talk-bot-random": random_hex}, 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
class TestCapabilitiesEnhanced:
def test_capabilities_approval_vision(self):
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
caps = adapter.capabilities
assert caps.approval is True
assert caps.vision is True
def test_capabilities_pin(self):
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
caps = adapter.capabilities
assert caps.pin is True
assert caps.unpin is True
assert caps.list_pins is True
class TestPollCloseAndResults:
@pytest.mark.asyncio
async def test_close_poll_success(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": {"status": "ok"}},
})
result = await adapter.close_poll("chat-1", "poll-1")
assert result.success is True
assert result.message_id == "poll-1"
@pytest.mark.asyncio
async def test_close_poll_failure(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": {"status": "error", "message": "Not found"}},
})
result = await adapter.close_poll("chat-1", "poll-missing")
assert result.success is False
@pytest.mark.asyncio
async def test_get_poll_results_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": {
"question": "Favorite color?",
"options": [{"id": 0, "text": "Red", "count": 3}],
"votes": {"0": 3},
"numVoters": 3,
"status": "closed",
},
},
})
result = await adapter.get_poll_results("chat-1", "poll-1")
assert result.success is True
assert result.metadata["question"] == "Favorite color?"
assert result.metadata["numVoters"] == 3
assert result.metadata["status"] == "closed"
@pytest.mark.asyncio
async def test_get_poll_results_failure(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", "message": "Poll not found"}},
})
result = await adapter.get_poll_results("chat-1", "poll-missing")
assert result.success is False
class TestPinOperations:
@pytest.mark.asyncio
async def test_pin_message_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"}},
})
result = await adapter.pin_message("chat-1", "msg-1")
assert result.success is True
assert result.message_id == "msg-1"
@pytest.mark.asyncio
async def test_pin_message_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": "Message not found"}},
})
result = await adapter.pin_message("chat-1", "msg-missing")
assert result.success is False
@pytest.mark.asyncio
async def test_unpin_message_success(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": {"status": "ok"}},
})
result = await adapter.unpin_message("chat-1", "msg-1")
assert result.success is True
assert result.message_id == "msg-1"
@pytest.mark.asyncio
async def test_unpin_message_failure(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": {"status": "error", "message": "Not pinned"}},
})
result = await adapter.unpin_message("chat-1", "msg-1")
assert result.success is False
@pytest.mark.asyncio
async def test_list_pins_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": [
{"messageId": 1, "actorId": "user-1"},
{"messageId": 2, "actorId": "user-2"},
],
},
})
result = await adapter.list_pins("chat-1")
assert isinstance(result, list)
assert len(result) == 2
@pytest.mark.asyncio
async def test_list_pins_empty(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": [],
},
})
result = await adapter.list_pins("chat-1")
assert isinstance(result, list)
assert len(result) == 0
@pytest.mark.asyncio
async def test_list_pins_no_client(self):
from yuxi.channels.adapters.nextcloudtalk.adapter import NextcloudTalkAdapter
adapter = NextcloudTalkAdapter()
result = await adapter.list_pins("chat-1")
assert isinstance(result, list)
assert len(result) == 0