feat(avatar): add BOXIM chat takeover

This commit is contained in:
stefanfeng
2026-08-19 17:56:57 +08:00
parent cfcfe7146e
commit 25fb8fbee5
15 changed files with 1340 additions and 880 deletions
+21 -4
View File
@@ -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必要时就医"