feat(avatar): add BOXIM chat takeover
This commit is contained in:
@@ -2,7 +2,14 @@ import uuid
|
||||
|
||||
import pytest
|
||||
from database import init_db, SessionLocal
|
||||
from models import Authorization, Avatar, User
|
||||
from models import (
|
||||
Authorization,
|
||||
Avatar,
|
||||
TakeoverCursor,
|
||||
TakeoverMessage,
|
||||
TakeoverReplyTask,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
@@ -84,10 +91,20 @@ def authorization_context():
|
||||
}
|
||||
finally:
|
||||
db.rollback()
|
||||
db.query(Authorization).filter(
|
||||
Authorization.avatar_id.in_([avatar.id, other_avatar.id])
|
||||
avatar_ids = [avatar.id, other_avatar.id]
|
||||
db.query(TakeoverReplyTask).filter(
|
||||
TakeoverReplyTask.avatar_id.in_(avatar_ids)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(Avatar).filter(Avatar.id.in_([avatar.id, other_avatar.id])).delete(
|
||||
db.query(TakeoverMessage).filter(
|
||||
TakeoverMessage.avatar_id.in_(avatar_ids)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(TakeoverCursor).filter(
|
||||
TakeoverCursor.avatar_id.in_(avatar_ids)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(Authorization).filter(
|
||||
Authorization.avatar_id.in_(avatar_ids)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(Avatar).filter(Avatar.id.in_(avatar_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(User).filter(User.id.in_([owner.id, other.id])).delete(
|
||||
|
||||
@@ -108,13 +108,13 @@ def test_avatar_permission_settings_default_and_persist(authorization_context):
|
||||
updated = client.put(
|
||||
endpoint,
|
||||
headers=context["owner_headers"],
|
||||
json={"permissions": ["interact", "publish", "friend", "friend"]},
|
||||
json={"permissions": ["interact", "takeover", "publish", "friend", "friend"]},
|
||||
).json()
|
||||
assert updated["code"] == 200
|
||||
assert updated["data"]["permissions"] == ["friend", "publish", "interact"]
|
||||
assert updated["data"]["permissions"] == ["friend", "publish", "interact", "takeover"]
|
||||
|
||||
reloaded = client.get(endpoint, headers=context["owner_headers"]).json()
|
||||
assert reloaded["data"]["permissions"] == ["friend", "publish", "interact"]
|
||||
assert reloaded["data"]["permissions"] == ["friend", "publish", "interact", "takeover"]
|
||||
|
||||
|
||||
def test_avatar_permission_settings_allow_all_disabled(authorization_context):
|
||||
@@ -137,7 +137,7 @@ def test_avatar_permission_settings_validate_owner_and_permissions(authorization
|
||||
invalid = client.put(
|
||||
endpoint,
|
||||
headers=context["owner_headers"],
|
||||
json={"permissions": ["takeover"]},
|
||||
json={"permissions": ["admin"]},
|
||||
).json()
|
||||
assert invalid["code"] == 400
|
||||
|
||||
|
||||
@@ -1,134 +1,118 @@
|
||||
"""Tests for the Box IM client (Netease Yunxin gateway wrapper)."""
|
||||
import pytest
|
||||
"""Contract tests for the self-hosted BOXIM client."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services.boxim_client import BoxIMClient, BoxIMError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
def config():
|
||||
return {
|
||||
"HUIHUI_IM_BASE_URL": "http://192.168.1.200:60040",
|
||||
"HUIHUI_PLATFORM_BASE_URL": "https://open.example/api",
|
||||
"BOXIM_API_BASE_URL": "https://im.example/api",
|
||||
"HUIHUI_APP_ID": "test_app",
|
||||
"HUIHUI_ACCESS_ID": "test_access",
|
||||
"HUIHUI_ACCESS_SECRET": "test_secret",
|
||||
}
|
||||
|
||||
|
||||
def _make_mock_response(json_data: dict):
|
||||
"""Create a properly configured mock for httpx.Response."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = json_data
|
||||
return mock_response
|
||||
def _response(payload: dict, status_code: int = 200):
|
||||
response = MagicMock()
|
||||
response.status_code = status_code
|
||||
response.json.return_value = payload
|
||||
return response
|
||||
|
||||
|
||||
def _patch_httpx_client(json_data: dict):
|
||||
"""Patch httpx.AsyncClient so that `async with httpx.AsyncClient() as c: await c.post(...)` returns json_data."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = _make_mock_response(json_data)
|
||||
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__.return_value = mock_client
|
||||
mock_cm.__aexit__.return_value = None
|
||||
|
||||
return patch("httpx.AsyncClient", return_value=mock_cm)
|
||||
def _client_patch(*, post_payload=None, request_payload=None, status_code=200):
|
||||
client = AsyncMock()
|
||||
if post_payload is not None:
|
||||
client.post.return_value = _response(post_payload, status_code)
|
||||
if request_payload is not None:
|
||||
client.request.return_value = _response(request_payload, status_code)
|
||||
context = AsyncMock()
|
||||
context.__aenter__.return_value = client
|
||||
context.__aexit__.return_value = None
|
||||
return patch("services.boxim_client.httpx.AsyncClient", return_value=context), client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_credentials(mock_config):
|
||||
"""get_credentials should return accid and token from the gateway response."""
|
||||
with _patch_httpx_client({"code": 200, "data": {"accid": "user123", "token": "tok_xyz"}}):
|
||||
from services.boxim_client import BoxIMClient
|
||||
async def test_exchange_access_token_uses_huihui_bearer_and_signed_form(config):
|
||||
mocked, client = _client_patch(
|
||||
post_payload={"code": 0, "data": {"accessToken": "box-token", "accessTokenExpiresIn": 3600}}
|
||||
)
|
||||
with mocked:
|
||||
result = await BoxIMClient(config).exchange_access_token("huihui-token")
|
||||
|
||||
client = BoxIMClient(mock_config)
|
||||
result = await client.get_credentials("user123")
|
||||
|
||||
assert result["accid"] == "user123"
|
||||
assert result["token"] == "tok_xyz"
|
||||
assert result["accessToken"] == "box-token"
|
||||
call = client.post.await_args
|
||||
assert call.args[0] == "https://open.example/api/im/box/netease"
|
||||
assert call.kwargs["headers"]["Authorization"] == "Bearer huihui-token"
|
||||
assert call.kwargs["data"]["appId"] == "test_app"
|
||||
assert len(call.kwargs["data"]["signature"]) == 32
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_p2p_message_success(mock_config):
|
||||
"""send_p2p_message should return True when the gateway responds with code 200."""
|
||||
with _patch_httpx_client({"code": 200}):
|
||||
from services.boxim_client import BoxIMClient
|
||||
async def test_get_self_and_incremental_private_messages_use_boxim_header(config):
|
||||
client_instance = BoxIMClient(config)
|
||||
mocked, client = _client_patch(
|
||||
request_payload={"code": 200, "data": {"id": 42, "nickName": "Owner"}}
|
||||
)
|
||||
with mocked:
|
||||
profile = await client_instance.get_self("box-token")
|
||||
assert profile["id"] == 42
|
||||
assert client.request.await_args.kwargs["headers"] == {"accessToken": "box-token"}
|
||||
|
||||
client = BoxIMClient(mock_config)
|
||||
result = await client.send_p2p_message("owner_acc", "target_acc", "Hello")
|
||||
|
||||
assert result is True
|
||||
mocked, client = _client_patch(
|
||||
request_payload={"code": 200, "data": [{"id": 101, "sendId": 7, "recvId": 42}]}
|
||||
)
|
||||
with mocked:
|
||||
messages = await client_instance.fetch_private_messages("box-token", "100")
|
||||
assert messages[0]["id"] == 101
|
||||
assert client.request.await_args.kwargs["params"] == {"minId": "100"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_p2p_message_failure(mock_config):
|
||||
"""send_p2p_message should return False when the gateway responds with a non-200 code."""
|
||||
with _patch_httpx_client({"code": 500, "message": "error"}):
|
||||
from services.boxim_client import BoxIMClient
|
||||
async def test_send_private_message_matches_boxim_payload(config):
|
||||
mocked, client = _client_patch(
|
||||
request_payload={"code": 200, "data": {"id": 88, "localId": 12345}}
|
||||
)
|
||||
with mocked:
|
||||
result = await BoxIMClient(config).send_private_message(
|
||||
"box-token", "77", "你好", local_id="12345"
|
||||
)
|
||||
|
||||
client = BoxIMClient(mock_config)
|
||||
result = await client.send_p2p_message("owner_acc", "target_acc", "Hello")
|
||||
|
||||
assert result is False
|
||||
assert result["id"] == 88
|
||||
call = client.request.await_args
|
||||
assert call.args[:2] == ("POST", "https://im.example/api/message/private/send")
|
||||
assert call.kwargs["json"] == {
|
||||
"localId": 12345,
|
||||
"recvId": 77,
|
||||
"content": "你好",
|
||||
"type": 0,
|
||||
"receipt": False,
|
||||
"atUserIds": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_credentials_returns_none_on_error(mock_config):
|
||||
"""get_credentials should return None when the gateway responds with an error code."""
|
||||
with _patch_httpx_client({"code": 500, "message": "user not found"}):
|
||||
from services.boxim_client import BoxIMClient
|
||||
|
||||
client = BoxIMClient(mock_config)
|
||||
result = await client.get_credentials("nonexistent")
|
||||
|
||||
assert result is None
|
||||
async def test_boxim_auth_error_is_explicit(config):
|
||||
mocked, _ = _client_patch(
|
||||
request_payload={"code": 400, "message": "未登录"}, status_code=200
|
||||
)
|
||||
with mocked, pytest.raises(BoxIMError) as exc_info:
|
||||
await BoxIMClient(config).get_self("expired")
|
||||
assert exc_info.value.auth_error is True
|
||||
|
||||
|
||||
def test_build_sign_params_contains_required_fields(mock_config):
|
||||
"""_build_sign_params should produce appId, accessId, nonce, timestamp, signature, signType, signVersion."""
|
||||
from services.boxim_client import BoxIMClient
|
||||
|
||||
client = BoxIMClient(mock_config)
|
||||
params = client._build_sign_params({"userId": "u1"})
|
||||
|
||||
assert "appId" in params
|
||||
assert "accessId" in params
|
||||
assert "nonce" in params
|
||||
assert "timestamp" in params
|
||||
assert "signature" in params
|
||||
def test_sign_params_include_production_required_fields(config):
|
||||
params = BoxIMClient(config)._build_sign_params()
|
||||
assert params["appId"] == "test_app"
|
||||
assert params["accessId"] == "test_access"
|
||||
assert params["signType"] == "MD5"
|
||||
assert params["signVersion"] == "1.0"
|
||||
assert len(params["nonce"]) == 12
|
||||
|
||||
|
||||
def test_build_sign_params_excludes_signature_and_accessSecret_from_signing_string(mock_config):
|
||||
"""signature and accessSecret must be excluded from the signing string to match news_service.py."""
|
||||
from services.boxim_client import BoxIMClient
|
||||
|
||||
client = BoxIMClient(mock_config)
|
||||
|
||||
# Pass params that already contain a stale "signature" value
|
||||
params_with_stale_sig = client._build_sign_params({
|
||||
"userId": "u1",
|
||||
"signature": "OLD_STALE_SIG",
|
||||
})
|
||||
|
||||
# The returned signature must be freshly computed (32-char MD5 uppercase),
|
||||
# NOT the stale value we passed in.
|
||||
assert params_with_stale_sig["signature"] != "OLD_STALE_SIG"
|
||||
assert len(params_with_stale_sig["signature"]) == 32
|
||||
|
||||
# Calling with the same extra params but no stale signature should also work.
|
||||
params_clean = client._build_sign_params({"userId": "u1"})
|
||||
assert len(params_clean["signature"]) == 32
|
||||
|
||||
|
||||
def test_build_sign_params_signature_is_deterministic(mock_config):
|
||||
"""Same inputs should produce valid MD5 signatures."""
|
||||
from services.boxim_client import BoxIMClient
|
||||
|
||||
client = BoxIMClient(mock_config)
|
||||
|
||||
params1 = client._build_sign_params({"userId": "u1"})
|
||||
params2 = client._build_sign_params({"userId": "u1"})
|
||||
|
||||
assert params1["signature"] is not None
|
||||
assert params2["signature"] is not None
|
||||
assert len(params1["signature"]) == 32 # MD5 hex length
|
||||
assert len(params["timestamp"]) == 14
|
||||
assert len(params["signature"]) == 32
|
||||
assert "accessSecret" not in params
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Tests for the authorization takeover configuration endpoint."""
|
||||
"""Tests for takeover configuration and BOXIM connection status."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from database import SessionLocal
|
||||
from main import app
|
||||
from models import Authorization
|
||||
from models import Authorization, TakeoverCursor, TakeoverReplyTask, User
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
@@ -123,3 +125,81 @@ def test_takeover_is_limited_to_active_user_authorizations(authorization_context
|
||||
).json()
|
||||
assert response["code"] == 400
|
||||
assert "单聊接管" in response["message"]
|
||||
|
||||
|
||||
def test_takeover_status_reports_disabled_and_requires_owner_login(authorization_context):
|
||||
context = authorization_context
|
||||
endpoint = f"/api/avatar/{context['avatar'].id}/takeover/status"
|
||||
|
||||
disabled = client.get(endpoint, headers=context["owner_headers"])
|
||||
assert disabled.status_code == 200
|
||||
assert disabled.json()["data"]["status"] == "disabled"
|
||||
|
||||
client.put(
|
||||
f"/api/avatar/{context['avatar'].id}/permission-settings",
|
||||
headers=context["owner_headers"],
|
||||
json={"permissions": ["chat", "takeover"]},
|
||||
)
|
||||
needs_login = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
|
||||
assert needs_login["enabled"] is True
|
||||
assert needs_login["status"] == "needs_login"
|
||||
assert "BOXIM" in needs_login["message"]
|
||||
|
||||
assert client.get(endpoint).status_code == 401
|
||||
assert client.get(endpoint, headers=context["other_headers"]).status_code == 403
|
||||
|
||||
|
||||
def test_takeover_status_reports_ready_pending_count_and_errors(authorization_context):
|
||||
context = authorization_context
|
||||
avatar_id = context["avatar"].id
|
||||
endpoint = f"/api/avatar/{avatar_id}/takeover/status"
|
||||
client.put(
|
||||
f"/api/avatar/{avatar_id}/permission-settings",
|
||||
headers=context["owner_headers"],
|
||||
json={"permissions": ["chat", "takeover"]},
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
owner = db.query(User).filter(User.id == context["owner"].id).one()
|
||||
owner.huihui_token = "production-login-token"
|
||||
cursor = TakeoverCursor(
|
||||
avatar_id=avatar_id,
|
||||
owner_id=owner.huihui_user_id,
|
||||
boxim_owner_id="100",
|
||||
last_message_id="10",
|
||||
initialized=True,
|
||||
last_polled_at=datetime.utcnow(),
|
||||
)
|
||||
task = TakeoverReplyTask(
|
||||
avatar_id=avatar_id,
|
||||
owner_id=owner.huihui_user_id,
|
||||
peer_id="200",
|
||||
trigger_message_id="11",
|
||||
source_message_ids=["11"],
|
||||
prompt="你好",
|
||||
status="pending",
|
||||
scheduled_at=datetime.utcnow(),
|
||||
boxim_local_id="123",
|
||||
)
|
||||
db.add_all([cursor, task])
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
ready = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
|
||||
assert ready["status"] == "ready"
|
||||
assert ready["pendingCount"] == 1
|
||||
assert ready["lastPolledAt"]
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar_id).one()
|
||||
cursor.last_error = "BOXIM 暂时不可用"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
failed = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
|
||||
assert failed["status"] == "error"
|
||||
assert failed["message"] == "BOXIM 暂时不可用"
|
||||
|
||||
@@ -1,239 +1,83 @@
|
||||
"""Tests for the scheduled takeover message polling."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
"""Tests for the BOXIM takeover scheduler lifecycle."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
def test_app_has_startup_event():
|
||||
"""Verify the app has a startup event configured."""
|
||||
def test_app_has_startup_and_shutdown_events():
|
||||
from main import app
|
||||
startup_handlers = [handler for handler in app.router.on_startup]
|
||||
assert len(startup_handlers) > 0
|
||||
|
||||
|
||||
@patch("services.takeover_service.TakeoverService")
|
||||
@patch("services.boxim_client.BoxIMClient")
|
||||
@patch("main.redis_lib.from_url")
|
||||
@patch("main.AsyncIOScheduler")
|
||||
def test_scheduler_initialized_with_redis(mock_scheduler_class, mock_redis_from_url, mock_boxim_cls, mock_takeover_cls):
|
||||
"""Verify scheduler is initialized when Redis is available."""
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.ping.return_value = None
|
||||
mock_redis_from_url.return_value = mock_redis
|
||||
|
||||
mock_boxim = MagicMock()
|
||||
mock_boxim_cls.return_value = mock_boxim
|
||||
|
||||
mock_takeover = MagicMock()
|
||||
mock_takeover.poll_and_process_messages = AsyncMock()
|
||||
mock_takeover_cls.return_value = mock_takeover
|
||||
|
||||
with patch("main.init_db"), patch("main.seed"), patch.dict("os.environ", {"REDIS_URL": "redis://localhost:6379"}):
|
||||
from main import on_startup
|
||||
on_startup()
|
||||
|
||||
mock_scheduler_class.return_value.add_job.assert_called_once()
|
||||
scheduled_callable = mock_scheduler_class.return_value.add_job.call_args.args[0]
|
||||
call_kwargs = mock_scheduler_class.return_value.add_job.call_args[1]
|
||||
assert scheduled_callable is mock_takeover.poll_and_process_messages
|
||||
assert call_kwargs["id"] == "takeover_message_poll"
|
||||
mock_scheduler_class.return_value.start.assert_called_once_with()
|
||||
assert app.router.on_startup
|
||||
assert app.router.on_shutdown
|
||||
|
||||
|
||||
@patch("services.takeover_service.TakeoverService")
|
||||
@patch("services.boxim_client.BoxIMClient")
|
||||
@patch("main.AsyncIOScheduler")
|
||||
def test_scheduler_starts_without_redis(mock_scheduler_class, mock_boxim_cls, mock_takeover_cls):
|
||||
"""App should start even when REDIS_URL is not set."""
|
||||
mock_boxim = MagicMock()
|
||||
mock_boxim_cls.return_value = mock_boxim
|
||||
|
||||
mock_takeover = MagicMock()
|
||||
mock_takeover.poll_and_process_messages = AsyncMock()
|
||||
mock_takeover_cls.return_value = mock_takeover
|
||||
|
||||
with patch("main.init_db"), patch("main.seed"), patch.dict("os.environ", {"REDIS_URL": ""}, clear=False):
|
||||
from main import on_startup
|
||||
on_startup()
|
||||
|
||||
mock_scheduler_class.return_value.add_job.assert_called_once()
|
||||
|
||||
|
||||
@patch("services.takeover_service.TakeoverService")
|
||||
@patch("services.boxim_client.BoxIMClient")
|
||||
@patch("main.redis_lib.from_url")
|
||||
@patch("main.AsyncIOScheduler")
|
||||
def test_scheduler_starts_when_redis_fails(mock_scheduler_class, mock_redis_from_url, mock_boxim_cls, mock_takeover_cls):
|
||||
"""App should start even when Redis ping fails."""
|
||||
mock_redis_from_url.side_effect = ConnectionError("Connection refused")
|
||||
|
||||
mock_boxim = MagicMock()
|
||||
mock_boxim_cls.return_value = mock_boxim
|
||||
|
||||
mock_takeover = MagicMock()
|
||||
mock_takeover.poll_and_process_messages = AsyncMock()
|
||||
mock_takeover_cls.return_value = mock_takeover
|
||||
|
||||
with patch("main.init_db"), patch("main.seed"), patch.dict("os.environ", {"REDIS_URL": "redis://badhost:6379"}):
|
||||
from main import on_startup
|
||||
on_startup()
|
||||
|
||||
mock_scheduler_class.return_value.add_job.assert_called_once()
|
||||
|
||||
|
||||
@patch("main.AsyncIOScheduler")
|
||||
def test_scheduler_fails_gracefully(mock_scheduler_class):
|
||||
"""If scheduler init raises, the app should still start (exception caught)."""
|
||||
mock_scheduler_class.side_effect = RuntimeError("Scheduler crash")
|
||||
|
||||
with patch("main.init_db"), patch("main.seed"):
|
||||
from main import on_startup
|
||||
on_startup()
|
||||
|
||||
# No exception should propagate
|
||||
|
||||
|
||||
def test_scheduler_shutdown_releases_resources():
|
||||
"""Shutdown should stop polling and close its dedicated database session."""
|
||||
def test_scheduler_uses_boxim_and_restart_safe_service(
|
||||
mock_scheduler_class,
|
||||
mock_boxim_class,
|
||||
mock_takeover_class,
|
||||
):
|
||||
import main
|
||||
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.running = True
|
||||
mock_db = MagicMock()
|
||||
main.takeover_scheduler = mock_scheduler
|
||||
main.takeover_db = mock_db
|
||||
scheduler = MagicMock()
|
||||
mock_scheduler_class.return_value = scheduler
|
||||
boxim = MagicMock()
|
||||
mock_boxim_class.return_value = boxim
|
||||
takeover = MagicMock()
|
||||
takeover.poll_and_process_messages = AsyncMock()
|
||||
mock_takeover_class.return_value = takeover
|
||||
|
||||
environment = {
|
||||
"HUIHUI_PLATFORM_BASE_URL": "https://open.example/api",
|
||||
"BOXIM_API_BASE_URL": "https://im.example/api",
|
||||
"HUIHUI_APP_ID": "app-id",
|
||||
"HUIHUI_ACCESS_ID": "access-id",
|
||||
"HUIHUI_ACCESS_SECRET": "secret",
|
||||
"BOXIM_POLL_INTERVAL_SECONDS": "1",
|
||||
}
|
||||
with patch("main.init_db"), patch("main.seed"), patch.dict(
|
||||
"os.environ", environment, clear=False
|
||||
):
|
||||
main.on_startup()
|
||||
|
||||
config = mock_boxim_class.call_args.args[0]
|
||||
assert config["HUIHUI_PLATFORM_BASE_URL"] == "https://open.example/api"
|
||||
assert config["BOXIM_API_BASE_URL"] == "https://im.example/api"
|
||||
mock_takeover_class.assert_called_once_with(main.SessionLocal, boxim)
|
||||
|
||||
scheduler.add_job.assert_called_once()
|
||||
scheduled_callable = scheduler.add_job.call_args.args[0]
|
||||
job_options = scheduler.add_job.call_args.kwargs
|
||||
assert scheduled_callable is takeover.poll_and_process_messages
|
||||
assert job_options["id"] == "takeover_message_poll"
|
||||
assert job_options["trigger"].interval.total_seconds() == 1
|
||||
assert job_options["max_instances"] == 1
|
||||
assert job_options["coalesce"] is True
|
||||
scheduler.start.assert_called_once_with()
|
||||
|
||||
main.takeover_scheduler = None
|
||||
|
||||
|
||||
@patch("main.AsyncIOScheduler")
|
||||
def test_scheduler_failure_does_not_stop_the_api(mock_scheduler_class):
|
||||
import main
|
||||
|
||||
mock_scheduler_class.side_effect = RuntimeError("scheduler crash")
|
||||
with patch("main.init_db"), patch("main.seed"):
|
||||
main.on_startup()
|
||||
|
||||
assert main.takeover_scheduler is None
|
||||
|
||||
|
||||
def test_shutdown_stops_only_the_scheduler():
|
||||
import main
|
||||
|
||||
scheduler = MagicMock()
|
||||
scheduler.running = True
|
||||
main.takeover_scheduler = scheduler
|
||||
|
||||
main.on_shutdown()
|
||||
|
||||
mock_scheduler.shutdown.assert_called_once_with(wait=False)
|
||||
mock_db.close.assert_called_once_with()
|
||||
scheduler.shutdown.assert_called_once_with(wait=False)
|
||||
assert main.takeover_scheduler is None
|
||||
assert main.takeover_db is None
|
||||
|
||||
|
||||
# --- poll_and_process_messages ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_boxim():
|
||||
return AsyncMock()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_and_process_messages_calls_fetch_and_process(mock_db, mock_boxim):
|
||||
"""poll_and_process_messages should fetch messages and process each."""
|
||||
from services.takeover_service import TakeoverService
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
service.fetch_unread_messages = AsyncMock(return_value=[
|
||||
{"owner_huihui_id": "owner_1", "from_accid": "user_1", "content": "hi"},
|
||||
{"owner_huihui_id": "owner_2", "from_accid": "user_2", "content": "hello"},
|
||||
])
|
||||
service.process_message = AsyncMock()
|
||||
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
service.fetch_unread_messages.assert_awaited_once()
|
||||
assert service.process_message.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_and_process_messages_handles_errors(mock_db, mock_boxim):
|
||||
"""poll_and_process_messages should not crash on fetch failure."""
|
||||
from services.takeover_service import TakeoverService
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
service.fetch_unread_messages = AsyncMock(side_effect=ConnectionError("Box IM down"))
|
||||
|
||||
await service.poll_and_process_messages()
|
||||
# No exception should propagate
|
||||
|
||||
|
||||
# --- process_message ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_auth():
|
||||
auth = MagicMock()
|
||||
auth.takeover_enabled = True
|
||||
auth.takeover_mode = "immediate"
|
||||
auth.takeover_delay_seconds = 30
|
||||
auth.avatar_id = "avatar_123"
|
||||
auth.target_id = "target_user_123"
|
||||
return auth
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_immediate_mode(mock_db, mock_boxim, mock_auth):
|
||||
"""When takeover_mode is 'immediate', execute_takeover should be called."""
|
||||
from services.takeover_service import TakeoverService
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
service.check_takeover_enabled = MagicMock(return_value=mock_auth)
|
||||
service.execute_takeover = AsyncMock(return_value=True)
|
||||
service.enqueue_delayed_message = MagicMock()
|
||||
|
||||
message = {"owner_huihui_id": "owner_1", "from_accid": "user_1", "content": "hi"}
|
||||
await service.process_message(message)
|
||||
|
||||
service.execute_takeover.assert_awaited_once_with(mock_auth, message)
|
||||
service.enqueue_delayed_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_delayed_mode(mock_db, mock_boxim, mock_auth):
|
||||
"""When takeover_mode is not 'immediate', message should be enqueued."""
|
||||
from services.takeover_service import TakeoverService
|
||||
|
||||
mock_auth.takeover_mode = "delayed"
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim, MagicMock())
|
||||
service.check_takeover_enabled = MagicMock(return_value=mock_auth)
|
||||
service.execute_takeover = AsyncMock()
|
||||
service.enqueue_delayed_message = MagicMock()
|
||||
|
||||
message = {"owner_huihui_id": "owner_1", "from_accid": "user_1", "content": "hi"}
|
||||
await service.process_message(message)
|
||||
|
||||
service.enqueue_delayed_message.assert_called_once_with(mock_auth, message)
|
||||
service.execute_takeover.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_delayed_mode_without_redis_falls_back_immediately(mock_db, mock_boxim, mock_auth):
|
||||
"""A missing Redis connection must not silently drop delayed replies."""
|
||||
from services.takeover_service import TakeoverService
|
||||
|
||||
mock_auth.takeover_mode = "delayed"
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
service.check_takeover_enabled = MagicMock(return_value=mock_auth)
|
||||
service.execute_takeover = AsyncMock(return_value=True)
|
||||
service.enqueue_delayed_message = MagicMock()
|
||||
|
||||
message = {"owner_huihui_id": "owner_1", "from_accid": "user_1", "content": "hi"}
|
||||
await service.process_message(message)
|
||||
|
||||
service.execute_takeover.assert_awaited_once_with(mock_auth, message)
|
||||
service.enqueue_delayed_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_no_takeover(mock_db, mock_boxim):
|
||||
"""When takeover is not enabled, nothing should happen."""
|
||||
from services.takeover_service import TakeoverService
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
service.check_takeover_enabled = MagicMock(return_value=None)
|
||||
service.execute_takeover = AsyncMock()
|
||||
service.enqueue_delayed_message = MagicMock()
|
||||
|
||||
message = {"owner_huihui_id": "owner_1", "from_accid": "user_1", "content": "hi"}
|
||||
await service.process_message(message)
|
||||
|
||||
service.execute_takeover.assert_not_awaited()
|
||||
service.enqueue_delayed_message.assert_not_called()
|
||||
|
||||
@@ -1,322 +1,196 @@
|
||||
"""Tests for the TakeoverService — message listening, decision, reply execution."""
|
||||
"""End-to-end service tests for BOXIM takeover timing and human priority."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from services.takeover_service import TakeoverService
|
||||
from models import Authorization, Avatar
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from database import Base
|
||||
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||
from services.takeover_service import TakeoverService, _plain_text_reply
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.value = datetime(2026, 8, 19, 10, 0, 0)
|
||||
|
||||
def now(self):
|
||||
return self.value
|
||||
|
||||
def advance(self, seconds: int):
|
||||
self.value += timedelta(seconds=seconds)
|
||||
|
||||
def millis(self):
|
||||
return int(self.value.replace(tzinfo=timezone.utc).timestamp() * 1000)
|
||||
|
||||
|
||||
class FakeBoxIM:
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
self.sent = []
|
||||
|
||||
async def exchange_access_token(self, huihui_token):
|
||||
assert huihui_token == "prod-huihui-token"
|
||||
return {"accessToken": "box-token", "accessTokenExpiresIn": 3600}
|
||||
|
||||
async def get_self(self, access_token):
|
||||
assert access_token == "box-token"
|
||||
return {"id": 100}
|
||||
|
||||
async def fetch_private_messages(self, access_token, min_id="0"):
|
||||
assert access_token == "box-token"
|
||||
return [item.copy() for item in self.messages if int(item["id"]) > int(min_id)]
|
||||
|
||||
async def send_private_message(self, access_token, peer_id, content, *, local_id=None):
|
||||
self.sent.append({"peerId": str(peer_id), "content": content, "localId": str(local_id)})
|
||||
return {"id": 900 + len(self.sent), "localId": int(local_id)}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = MagicMock()
|
||||
return db
|
||||
def service_context():
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
Base.metadata.create_all(engine)
|
||||
db = session_factory()
|
||||
user = User(
|
||||
id="owner-local",
|
||||
huihui_user_id="owner-huihui",
|
||||
huihui_token="prod-huihui-token",
|
||||
app_token="app-token",
|
||||
)
|
||||
avatar = Avatar(
|
||||
id="avatar-1",
|
||||
owner_id=user.huihui_user_id,
|
||||
name="分身",
|
||||
status="active",
|
||||
config={"authorizationPermissions": ["chat", "takeover"]},
|
||||
)
|
||||
db.add_all([user, avatar])
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_boxim():
|
||||
client = AsyncMock()
|
||||
client.get_credentials.return_value = {"accid": "owner_acc", "token": "tok"}
|
||||
client.send_p2p_message.return_value = True
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_auth():
|
||||
auth = MagicMock(spec=Authorization)
|
||||
auth.takeover_enabled = True
|
||||
auth.takeover_mode = "immediate"
|
||||
auth.takeover_delay_seconds = 30
|
||||
auth.avatar_id = "avatar_123"
|
||||
auth.target_id = "target_user_123"
|
||||
auth.target_type = "user"
|
||||
auth.status = "active"
|
||||
auth.permissions = ["chat", "takeover"]
|
||||
return auth
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_avatar():
|
||||
avatar = MagicMock(spec=Avatar)
|
||||
avatar.id = "avatar_123"
|
||||
avatar.owner_id = "owner_huihui_123"
|
||||
return avatar
|
||||
|
||||
|
||||
# --- check_takeover_enabled ---
|
||||
|
||||
|
||||
def test_check_takeover_enabled_returns_auth_when_enabled(mock_db, mock_auth, mock_boxim, mock_avatar):
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
|
||||
auth_filter = MagicMock()
|
||||
auth_filter.filter.return_value = auth_filter
|
||||
auth_filter.first.return_value = mock_auth
|
||||
|
||||
def query_side_effect(model):
|
||||
if model == Avatar:
|
||||
return avatar_query
|
||||
return auth_filter
|
||||
|
||||
mock_db.query.side_effect = query_side_effect
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
result = service.check_takeover_enabled("owner_huihui_123", "target_user_123")
|
||||
assert result == mock_auth
|
||||
|
||||
|
||||
def test_check_takeover_enabled_returns_none_when_no_avatar(mock_db, mock_boxim):
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = None
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
mock_db.query.return_value = avatar_query
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
result = service.check_takeover_enabled("owner_123", "target_123")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_check_takeover_enabled_returns_none_when_disabled(mock_db, mock_boxim, mock_avatar):
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
|
||||
disabled_auth = MagicMock(spec=Authorization)
|
||||
disabled_auth.takeover_enabled = False
|
||||
disabled_auth.permissions = []
|
||||
auth_filter = MagicMock()
|
||||
auth_filter.filter.return_value = auth_filter
|
||||
auth_filter.first.return_value = disabled_auth
|
||||
|
||||
def query_side_effect(model):
|
||||
if model == Avatar:
|
||||
return avatar_query
|
||||
return auth_filter
|
||||
|
||||
mock_db.query.side_effect = query_side_effect
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
result = service.check_takeover_enabled("owner_123", "target_123")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_check_takeover_enabled_filters_by_owner_and_target(mock_db, mock_boxim, mock_avatar, mock_auth):
|
||||
"""Verify that queries use the correct filter arguments."""
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
|
||||
auth_filter = MagicMock()
|
||||
auth_filter.filter.return_value = auth_filter
|
||||
auth_filter.first.return_value = mock_auth
|
||||
|
||||
call_order = []
|
||||
|
||||
def query_side_effect(model):
|
||||
if model == Avatar:
|
||||
call_order.append("Avatar")
|
||||
return avatar_query
|
||||
call_order.append("Authorization")
|
||||
return auth_filter
|
||||
|
||||
mock_db.query.side_effect = query_side_effect
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
service.check_takeover_enabled("owner_huihui_123", "target_user_123")
|
||||
|
||||
assert "Avatar" in call_order
|
||||
assert "Authorization" in call_order
|
||||
|
||||
|
||||
# --- generate_reply ---
|
||||
clock = Clock()
|
||||
boxim = FakeBoxIM()
|
||||
service = TakeoverService(session_factory, boxim, now=clock.now)
|
||||
return session_factory, service, boxim, clock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_reply_returns_answer(mock_boxim):
|
||||
mock_db = MagicMock()
|
||||
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"code": 200, "data": {"answer": "Hello back"}}
|
||||
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
|
||||
async def test_first_sync_primes_cursor_without_replying_to_history(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
boxim.messages = [
|
||||
{"id": 10, "localId": 1, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "旧消息"}
|
||||
]
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
result = await service.generate_reply("avatar_123", "Hello")
|
||||
assert result == "Hello back"
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "不应发送"}):
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
cursor = db.query(TakeoverCursor).one()
|
||||
assert cursor.initialized is True
|
||||
assert cursor.last_message_id == "10"
|
||||
assert db.query(TakeoverMessage).count() == 1
|
||||
assert db.query(TakeoverReplyTask).count() == 0
|
||||
assert boxim.sent == []
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_reply_handles_empty_answer(mock_boxim):
|
||||
"""generate_reply should return empty string when answer is missing."""
|
||||
mock_db = MagicMock()
|
||||
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"code": 200, "data": {}}
|
||||
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
|
||||
async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{"id": 11, "localId": 2, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "你好"}
|
||||
)
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
result = await service.generate_reply("avatar_123", "Hello")
|
||||
assert result == ""
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}):
|
||||
await service.poll_and_process_messages()
|
||||
assert boxim.sent == []
|
||||
|
||||
clock.advance(2)
|
||||
await service.poll_and_process_messages()
|
||||
assert boxim.sent == []
|
||||
|
||||
clock.advance(1)
|
||||
await service.poll_and_process_messages()
|
||||
assert boxim.sent == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}]
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
task = db.query(TakeoverReplyTask).one()
|
||||
assert task.status == "sent"
|
||||
assert task.sent_at == clock.now()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_reply_handles_error_code(mock_boxim):
|
||||
"""generate_reply should return empty string when API returns error code."""
|
||||
mock_db = MagicMock()
|
||||
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"code": 500, "message": "Internal error"}
|
||||
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
|
||||
async def test_owner_message_cancels_pending_reply(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{"id": 21, "localId": 3, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "在吗"}
|
||||
)
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "在的"}):
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
result = await service.generate_reply("avatar_123", "Hello")
|
||||
assert result == ""
|
||||
clock.advance(2)
|
||||
boxim.messages.append(
|
||||
{"id": 22, "localId": 4, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我来回复"}
|
||||
)
|
||||
await service.poll_and_process_messages()
|
||||
clock.advance(2)
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
|
||||
# --- execute_takeover ---
|
||||
db = session_factory()
|
||||
try:
|
||||
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.trigger_message_id == "21").one()
|
||||
assert task.status == "cancelled"
|
||||
assert task.cancel_reason == "owner_replied"
|
||||
assert boxim.sent == []
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_takeover_success(mock_db, mock_boxim, mock_auth, mock_avatar):
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
mock_db.query.return_value = avatar_query
|
||||
async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{"id": 31, "localId": 5, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第一句"}
|
||||
)
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "第一版"}):
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"code": 200, "data": {"answer": "Hello back"}}
|
||||
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
|
||||
clock.advance(1)
|
||||
boxim.messages.append(
|
||||
{"id": 32, "localId": 6, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第二句"}
|
||||
)
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "合并回复"}) as resolver:
|
||||
await service.poll_and_process_messages()
|
||||
assert resolver.call_args.args[2] == "第一句\n第二句"
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
message = {"from_accid": "user_acc", "content": "Hello"}
|
||||
clock.advance(3)
|
||||
await service.poll_and_process_messages()
|
||||
assert [item["content"] for item in boxim.sent] == ["合并回复"]
|
||||
|
||||
result = await service.execute_takeover(mock_auth, message)
|
||||
|
||||
assert result is True
|
||||
mock_boxim.get_credentials.assert_called_once_with("owner_huihui_123")
|
||||
mock_boxim.send_p2p_message.assert_called_once()
|
||||
db = session_factory()
|
||||
try:
|
||||
tasks = db.query(TakeoverReplyTask).order_by(TakeoverReplyTask.created_at).all()
|
||||
assert [task.status for task in tasks] == ["cancelled", "sent"]
|
||||
assert tasks[0].cancel_reason == "newer_incoming_message"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_takeover_fails_when_avatar_not_found(mock_db, mock_boxim, mock_auth):
|
||||
"""execute_takeover should return False when Avatar is not found."""
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = None
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
mock_db.query.return_value = avatar_query
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
message = {"from_accid": "user_acc", "content": "Hello"}
|
||||
|
||||
result = await service.execute_takeover(mock_auth, message)
|
||||
|
||||
assert result is False
|
||||
mock_boxim.get_credentials.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_takeover_fails_when_no_credentials(mock_db, mock_boxim, mock_auth, mock_avatar):
|
||||
"""execute_takeover should return False when boxim.get_credentials returns None."""
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
mock_db.query.return_value = avatar_query
|
||||
|
||||
mock_boxim.get_credentials.return_value = None
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
message = {"from_accid": "user_acc", "content": "Hello"}
|
||||
|
||||
result = await service.execute_takeover(mock_auth, message)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# --- enqueue_delayed_message ---
|
||||
|
||||
|
||||
def test_enqueue_delayed_message_with_redis(mock_db, mock_boxim, mock_auth, mock_avatar):
|
||||
mock_redis = MagicMock()
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
mock_db.query.return_value = avatar_query
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim, mock_redis)
|
||||
message = {"msg_id": "msg_1", "from_accid": "user_acc", "content": "Hello"}
|
||||
|
||||
service.enqueue_delayed_message(mock_auth, message)
|
||||
|
||||
mock_redis.setex.assert_called_once()
|
||||
call_args = mock_redis.setex.call_args
|
||||
value = call_args[0][1]
|
||||
import json
|
||||
payload = json.loads(call_args[0][2])
|
||||
assert payload["owner_huihui_id"] == "owner_huihui_123"
|
||||
|
||||
|
||||
def test_enqueue_delayed_message_without_redis_logs_warning(mock_db, mock_boxim, mock_auth, mock_avatar):
|
||||
"""When Redis is not configured, enqueue_delayed_message should log a warning and not crash."""
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
mock_db.query.return_value = avatar_query
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
message = {"msg_id": "msg_1", "from_accid": "user_acc", "content": "Hello"}
|
||||
|
||||
service.enqueue_delayed_message(mock_auth, message)
|
||||
|
||||
|
||||
# --- process_delayed_queue ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_delayed_queue_no_redis(mock_db, mock_boxim):
|
||||
"""process_delayed_queue should return immediately without Redis."""
|
||||
service = TakeoverService(mock_db, mock_boxim)
|
||||
await service.process_delayed_queue()
|
||||
mock_db.query.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_delayed_queue_processes_messages(mock_db, mock_boxim, mock_auth, mock_avatar):
|
||||
"""process_delayed_queue should read from Redis, resolve auth, and execute takeover."""
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.keys.return_value = ["takeover:delayed:target_user_123:msg_1"]
|
||||
mock_redis.get.return_value = '{"from_accid": "user_acc", "content": "Hello"}'
|
||||
|
||||
avatar_filter = MagicMock()
|
||||
avatar_filter.first.return_value = mock_avatar
|
||||
avatar_query = MagicMock()
|
||||
avatar_query.filter.return_value = avatar_filter
|
||||
|
||||
auth_filter = MagicMock()
|
||||
auth_filter.filter.return_value = auth_filter
|
||||
auth_filter.first.return_value = mock_auth
|
||||
|
||||
def query_side_effect(model):
|
||||
if model == Avatar:
|
||||
return avatar_query
|
||||
return auth_filter
|
||||
|
||||
mock_db.query.side_effect = query_side_effect
|
||||
|
||||
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"code": 200, "data": {"answer": "Hello back"}}
|
||||
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
|
||||
|
||||
service = TakeoverService(mock_db, mock_boxim, mock_redis)
|
||||
await service.process_delayed_queue()
|
||||
|
||||
mock_boxim.send_p2p_message.assert_called_once()
|
||||
mock_redis.delete.assert_called_once()
|
||||
def test_plain_text_reply_removes_markdown_and_empty_lines():
|
||||
assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医"
|
||||
|
||||
Reference in New Issue
Block a user