ForcePilot/backend/test/unit/channels/test_whatsapp_io_credentials.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

338 lines
12 KiB
Python

from __future__ import annotations
import asyncio
import json
import os
import tempfile
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from yuxi.channels.adapters.whatsapp.credential_queue import CredentialQueue
from yuxi.channels.adapters.whatsapp.login import login_with_qr
from yuxi.channels.adapters.whatsapp.logout_security import (
PathOwnership,
_classify_path,
_is_safe_path,
perform_logout_cleanup,
validate_credential_freshness,
)
from yuxi.channels.adapters.whatsapp.probe import probe_bridge, wait_for_connection
from yuxi.channels.models import HealthStatus
class TestPathOwnership:
def test_enum_values(self):
assert PathOwnership.OWNED == "owned"
assert PathOwnership.UNSAFE_OWNED == "unsafe_owned"
assert PathOwnership.EXTERNAL == "external"
class TestClassifyPath:
def test_owned(self, tmp_path):
auth_dir = tmp_path / "auth"
auth_dir.mkdir()
target = auth_dir / "creds.json"
target.write_text("{}")
assert _classify_path(auth_dir, target) == PathOwnership.OWNED
def test_external(self, tmp_path):
auth_dir = tmp_path / "auth"
auth_dir.mkdir()
external = tmp_path / "external" / "creds.json"
external.parent.mkdir()
external.write_text("{}")
assert _classify_path(auth_dir, external) == PathOwnership.EXTERNAL
def test_is_safe_path_owned(self, tmp_path):
auth_dir = tmp_path / "auth"
auth_dir.mkdir()
target = auth_dir / "file.txt"
target.write_text("test")
assert _is_safe_path(auth_dir, target) is True
def test_is_safe_path_external(self, tmp_path):
auth_dir = tmp_path / "auth"
auth_dir.mkdir()
external = tmp_path / "other" / "file.txt"
external.parent.mkdir()
external.write_text("test")
assert _is_safe_path(auth_dir, external) is False
def test_external_symlink(self, tmp_path):
auth_dir = tmp_path / "auth"
auth_dir.mkdir()
external = tmp_path / "external" / "creds.json"
external.parent.mkdir()
external.write_text("{}")
symlink = auth_dir / "creds.json"
try:
symlink.symlink_to(external)
result = _classify_path(auth_dir, symlink)
assert result in (PathOwnership.EXTERNAL, PathOwnership.UNSAFE_OWNED)
except OSError:
pytest.skip("symlink not supported on this platform")
class TestPerformLogoutCleanup:
def test_cleanup_owned_files(self, tmp_path):
auth_dir = tmp_path / "auth"
auth_dir.mkdir()
for filename in ["creds.json", "creds.json.bak", "app-state-sync-key.json"]:
(auth_dir / filename).write_text("{}")
result = perform_logout_cleanup(auth_dir)
assert result["owned"] > 0
def test_external_auth_dir(self, tmp_path):
auth_dir = tmp_path / "auth"
auth_dir.mkdir()
external = tmp_path / "other"
external.mkdir()
auth_dir_link = external / "link"
auth_dir_link.symlink_to(auth_dir)
result = perform_logout_cleanup(auth_dir_link)
assert result["unsafe_owned"] > 0 or result["external"] > 0
def test_missing_files_no_error(self, tmp_path):
auth_dir = tmp_path / "empty_auth"
auth_dir.mkdir()
result = perform_logout_cleanup(auth_dir)
assert result["owned"] == 0
def test_cleanup_returns_dict(self, tmp_path):
auth_dir = tmp_path / "auth"
auth_dir.mkdir()
(auth_dir / "creds.json").write_text("{}")
result = perform_logout_cleanup(auth_dir)
assert "owned" in result
assert "unsafe_owned" in result
assert "external" in result
class TestValidateCredentialFreshness:
def test_valid_fresh_credentials(self, tmp_path):
auth_dir = tmp_path / "fresh_auth"
auth_dir.mkdir()
(auth_dir / "creds.json").write_text("{}")
assert validate_credential_freshness(auth_dir, max_age_hours=168) is True
def test_expired_credentials(self, tmp_path):
auth_dir = tmp_path / "old_auth"
auth_dir.mkdir()
creds = auth_dir / "creds.json"
creds.write_text("{}")
old_time = time.time() - 200 * 3600
os.utime(str(creds), (old_time, old_time))
assert validate_credential_freshness(auth_dir, max_age_hours=168) is False
def test_missing_credentials(self, tmp_path):
auth_dir = tmp_path / "empty"
auth_dir.mkdir()
assert validate_credential_freshness(auth_dir) is False
def test_custom_max_age(self, tmp_path):
auth_dir = tmp_path / "custom_auth"
auth_dir.mkdir()
(auth_dir / "creds.json").write_text("{}")
assert validate_credential_freshness(auth_dir, max_age_hours=1) is True
def test_creds_file_error(self, tmp_path):
auth_dir = tmp_path / "error_auth"
auth_dir.mkdir()
creds = auth_dir / "creds.json"
creds.write_text("{invalid json}")
assert validate_credential_freshness(auth_dir) is True
class TestLoginWithQr:
@pytest.mark.asyncio
async def test_qr_generation_fails(self):
bridge = MagicMock()
bridge.get_qr = AsyncMock(side_effect=Exception("connection failed"))
result = await login_with_qr(bridge)
assert result["success"] is False
assert "QR" in result["error"]
@pytest.mark.asyncio
async def test_no_qr_in_response(self):
bridge = MagicMock()
bridge.get_qr = AsyncMock(return_value={})
result = await login_with_qr(bridge)
assert result["success"] is False
assert "QR" in result["error"]
@pytest.mark.asyncio
async def test_scan_timeout(self):
bridge = MagicMock()
bridge.get_qr = AsyncMock(return_value={"qr": "base64data"})
async def slow_scan():
await asyncio.sleep(3600)
return {"success": True}
bridge.wait_scan = slow_scan
result = await login_with_qr(bridge, timeout=0.01)
assert result["success"] is False
assert result["qr_base64"] == "base64data"
@pytest.mark.asyncio
async def test_scan_success(self):
bridge = MagicMock()
bridge.get_qr = AsyncMock(return_value={"qr": "base64data"})
bridge.wait_scan = AsyncMock(return_value={"success": True, "jid": "test@s.whatsapp.net"})
result = await login_with_qr(bridge)
assert result["success"] is True
assert result["jid"] == "test@s.whatsapp.net"
@pytest.mark.asyncio
async def test_scan_failed(self):
bridge = MagicMock()
bridge.get_qr = AsyncMock(return_value={"qr": "base64data"})
bridge.wait_scan = AsyncMock(return_value={"success": False, "error": "Scan rejected"})
result = await login_with_qr(bridge)
assert result["success"] is False
assert result["qr_base64"] == "base64data"
class TestProbe:
@pytest.mark.asyncio
async def test_probe_bridge(self):
bridge = MagicMock()
bridge.health_check = AsyncMock(return_value=HealthStatus(status="healthy"))
result = await probe_bridge(bridge)
assert result.status == "healthy"
@pytest.mark.asyncio
async def test_probe_bridge_unhealthy(self):
bridge = MagicMock()
bridge.health_check = AsyncMock(return_value=HealthStatus(status="unhealthy", last_error="test error"))
result = await probe_bridge(bridge)
assert result.status == "unhealthy"
@pytest.mark.asyncio
async def test_wait_for_connection_success(self):
bridge = MagicMock()
bridge.health_check = AsyncMock(
return_value=HealthStatus(status="healthy", metadata={"jid": "test@s.whatsapp.net"})
)
result = await wait_for_connection(bridge, timeout=5.0)
assert result["connected"] is True
assert result["jid"] == "test@s.whatsapp.net"
@pytest.mark.asyncio
async def test_wait_for_connection_timeout(self):
bridge = MagicMock()
bridge.health_check = AsyncMock(
return_value=HealthStatus(status="degraded", metadata={})
)
result = await wait_for_connection(bridge, timeout=0.01)
assert result["connected"] is False
assert "timeout" in result["error"].lower()
class TestCredentialQueue:
def test_write_auth_creates_file(self, tmp_path):
queue = CredentialQueue(tmp_path, debounce_ms=100)
async def _test():
count = await queue.write_auth({"test": "data"})
assert count == 1
assert (tmp_path / "creds.json").exists()
data = json.loads((tmp_path / "creds.json").read_text())
assert data["test"] == "data"
asyncio.run(_test())
def test_write_auth_backup(self, tmp_path):
queue = CredentialQueue(tmp_path, debounce_ms=100)
(tmp_path / "creds.json").write_text('{"old": "data"}')
async def _test():
await queue.write_auth({"new": "data"})
assert (tmp_path / "creds.json.bak").exists()
backup_data = json.loads((tmp_path / "creds.json.bak").read_text())
assert backup_data["old"] == "data"
asyncio.run(_test())
def test_write_count_increments(self, tmp_path):
queue = CredentialQueue(tmp_path, debounce_ms=100)
async def _test():
assert queue.write_count == 0
await queue.write_auth({"a": 1})
assert queue.write_count == 1
await queue.write_auth({"b": 2})
assert queue.write_count == 2
asyncio.run(_test())
def test_read_auth(self, tmp_path):
queue = CredentialQueue(tmp_path, debounce_ms=100)
(tmp_path / "creds.json").write_text('{"key": "value"}')
async def _test():
data = await queue.read_auth()
assert data is not None
assert data["key"] == "value"
asyncio.run(_test())
def test_read_auth_nonexistent(self, tmp_path):
queue = CredentialQueue(tmp_path, debounce_ms=100)
async def _test():
data = await queue.read_auth()
assert data is None
asyncio.run(_test())
def test_read_auth_corrupt_with_backup(self, tmp_path):
(tmp_path / "creds.json").write_text("not valid json")
(tmp_path / "creds.json.bak").write_text('{"restored": true}')
queue = CredentialQueue(tmp_path, debounce_ms=100)
async def _test():
data = await queue.read_auth()
assert data is not None
assert data["restored"] is True
asyncio.run(_test())
def test_clear_auth(self, tmp_path):
queue = CredentialQueue(tmp_path, debounce_ms=100)
(tmp_path / "creds.json").write_text('{"test": 1}')
async def _test():
result = await queue.clear_auth()
assert result is True
assert not (tmp_path / "creds.json").exists()
assert (tmp_path / "creds.json.bak").exists()
asyncio.run(_test())
def test_clear_auth_no_existing(self, tmp_path):
queue = CredentialQueue(tmp_path, debounce_ms=100)
async def _test():
result = await queue.clear_auth()
assert result is True
asyncio.run(_test())
def test_debounce_write(self, tmp_path):
queue = CredentialQueue(tmp_path, debounce_ms=50)
async def _test():
count1 = await queue.write_auth({"first": 1})
count2 = await queue.write_auth({"second": 2})
assert count1 == 1
assert count2 == 2
await asyncio.sleep(0.1)
data = json.loads((tmp_path / "creds.json").read_text())
assert data["second"] == 2
asyncio.run(_test())