Files
replypier/backend/tests/services/test_channel_status.py
T
WangShen007 164f5f9615 test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
2026-10-08 22:35:00 +08:00

324 lines
12 KiB
Python

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from app.channels.app_hooks import EventKind, register_event_listener
from app.channels.base import DeliveryState, StatusEvent
from sqlalchemy.orm import Session
from app.models.chat_history import ChatHistory
from app.models.organization import Organization
from app.repositories.channels import ChannelAccountRepository
from app.repositories.chat import ChatRepository
from app.repositories.session_to_agent import SessionToAgentRepository
from app.services import agent_rooms, channel_status
from app.services.channel_status import process_status_event, record_sent, replaceable_by
class _FakePipeline:
def __init__(self, store):
self._store = store
self._ops = []
def rpush(self, key, value):
self._ops.append(lambda: self._store.setdefault(key, []).append(value))
def expire(self, key, seconds):
self._ops.append(lambda: None)
def lrange(self, key, start, end):
self._ops.append(lambda: list(self._store.get(key, [])))
def delete(self, key):
self._ops.append(lambda: self._store.pop(key, None))
def execute(self):
return [op() for op in self._ops]
class _FakeRedis:
def __init__(self):
self.store = {}
def pipeline(self):
return _FakePipeline(self.store)
@pytest.fixture
def redis(monkeypatch):
fake = _FakeRedis()
monkeypatch.setattr(channel_status, "get_redis", lambda: fake)
return fake
@pytest.fixture
def mock_sio(monkeypatch):
sio = MagicMock()
sio.emit = AsyncMock()
monkeypatch.setattr(agent_rooms, "sio", sio)
return sio
@pytest.fixture
def use_test_db(db, monkeypatch):
class _NonClosing:
def __init__(self, inner):
self._inner = inner
def __getattr__(self, name):
return getattr(self._inner, name)
def close(self):
pass
monkeypatch.setattr(channel_status, "SessionLocal", lambda: _NonClosing(db))
return db
@pytest.fixture
def account(db, test_organization):
return ChannelAccountRepository(db).create_account(
organization_id=test_organization.id,
channel_type="whatsapp",
external_account_id="PN123",
credentials={"access_token": "tok"},
display_name="+1 555",
)
def _message(db, organization_id, external_message_id=None, message_type="bot"):
message = ChatRepository(db).create_message({
"message": "hello",
"message_type": message_type,
"session_id": str(uuid4()),
"organization_id": str(organization_id),
"external_message_id": external_message_id,
})
return message
def _event(status: DeliveryState, message_id="wamid.1", error=None) -> StatusEvent:
return StatusEvent(external_account_id="PN123", external_message_id=message_id,
recipient_id="447", status=status, error=error,
timestamp=datetime(2026, 10, 11, tzinfo=timezone.utc))
@pytest.mark.parametrize("new,expected", [
(DeliveryState.SENT, set()),
(DeliveryState.DELIVERED, {"sent"}),
(DeliveryState.READ, {"sent", "delivered"}),
# failed replaces anything but read: a read message was delivered
(DeliveryState.FAILED, {"sent", "delivered"}),
])
def test_replaceable_by(new, expected):
assert replaceable_by(new) == expected
def test_a_stale_receipt_on_another_worker_cannot_overwrite_a_newer_one(db, test_organization):
"""The rank check is part of the UPDATE, not a read-then-write in Python."""
message = _message(db, test_organization.id, "wamid.1")
other_worker = Session(bind=db.get_bind())
try:
stale_view = other_worker.get(ChatHistory, message.id) # read while still unset
assert stale_view.channel_status is None
at = datetime(2026, 10, 11, tzinfo=timezone.utc)
assert ChatRepository(db).advance_channel_status(message.id, "read", at, replaceable_by(DeliveryState.READ))
assert not ChatRepository(other_worker).advance_channel_status(
message.id, "delivered", at, replaceable_by(DeliveryState.DELIVERED))
finally:
other_worker.close()
db.refresh(message)
assert message.channel_status == "read"
@pytest.mark.asyncio
async def test_receipts_update_the_message_and_never_regress(db, account, test_organization,
use_test_db, mock_sio, redis):
message = _message(db, test_organization.id, "wamid.1")
await process_status_event(account.id, _event(DeliveryState.READ))
await process_status_event(account.id, _event(DeliveryState.DELIVERED))
db.refresh(message)
assert message.channel_status == "read"
assert message.channel_status_at is not None
mock_sio.emit.assert_awaited_once()
event_name, payload = mock_sio.emit.await_args.args
assert event_name == "message_status"
assert payload["message_id"] == message.id and payload["channel_status"] == "read"
@pytest.mark.asyncio
async def test_failed_receipt_marks_the_message_undelivered(db, account, test_organization,
use_test_db, mock_sio, redis):
message = _message(db, test_organization.id, "wamid.1")
await process_status_event(account.id, _event(DeliveryState.FAILED, error="Re-engagement required"))
db.refresh(message)
assert message.channel_status == "failed"
assert message.attributes["delivery_status"] == "Re-engagement required"
@pytest.mark.asyncio
async def test_receipt_never_touches_another_organizations_message(db, account, use_test_db,
mock_sio, redis):
other_org = Organization(name="Other", domain="other.example")
db.add(other_org)
db.commit()
foreign = _message(db, other_org.id, "wamid.1")
await process_status_event(account.id, _event(DeliveryState.READ))
db.refresh(foreign)
assert foreign.channel_status is None
@pytest.mark.asyncio
async def test_early_receipt_waits_for_the_send_to_record_its_id(db, account, test_organization,
use_test_db, mock_sio, redis):
message = _message(db, test_organization.id) # id not stored yet
await process_status_event(account.id, _event(DeliveryState.SENT))
await process_status_event(account.id, _event(DeliveryState.DELIVERED))
db.refresh(message)
assert message.channel_status is None
await record_sent(db, message.id, account.id, "wamid.1")
db.refresh(message)
assert message.external_message_id == "wamid.1"
assert message.channel_status == "delivered"
assert redis.store == {}
@pytest.mark.asyncio
async def test_record_sent_without_redis_still_stores_the_id(db, account, test_organization,
monkeypatch, mock_sio):
monkeypatch.setattr(channel_status, "get_redis", lambda: None)
message = _message(db, test_organization.id)
await record_sent(db, message.id, account.id, "wamid.9")
db.refresh(message)
assert message.external_message_id == "wamid.9"
@pytest.mark.asyncio
async def test_receipt_parked_after_the_queue_was_drained_is_still_applied(
db, account, test_organization, use_test_db, mock_sio, redis, monkeypatch):
"""The send stores its id between the receipt's lookup and its park."""
message = _message(db, test_organization.id)
original_park = channel_status._park
def park_after_send(account_id, event):
ChatRepository(db).set_external_message_id(message.id, "wamid.1") # send lands now
original_park(account_id, event)
monkeypatch.setattr(channel_status, "_park", park_after_send)
await process_status_event(account.id, _event(DeliveryState.DELIVERED))
db.refresh(message)
assert message.channel_status == "delivered"
assert redis.store == {}
@pytest.mark.asyncio
async def test_receipt_reaches_the_handling_agents_inbox(db, account, test_organization, test_user,
test_agent, test_customer,
use_test_db, mock_sio, redis):
"""Every inbox open on the org hears it (AI-owned chats have no agent),
and so does the agent handling the chat."""
session = SessionToAgentRepository(db).create_session(
session_id=uuid4(), agent_id=test_agent.id, customer_id=test_customer.id,
organization_id=test_organization.id, channel="whatsapp")
session.user_id = test_user.id
db.commit()
message = ChatRepository(db).create_message({
"message": "Your session is at 5pm", "message_type": "agent",
"session_id": str(session.session_id), "organization_id": str(test_organization.id),
"external_message_id": "wamid.1",
})
await process_status_event(account.id, _event(DeliveryState.DELIVERED))
rooms = [call.kwargs["room"] for call in mock_sio.emit.await_args_list]
assert rooms == [f"org_inbox_{test_organization.id}", f"user_{test_user.id}"]
@pytest.mark.asyncio
async def test_parked_receipts_hold_no_customer_details(db, account, test_organization,
use_test_db, mock_sio, redis):
event = _event(DeliveryState.DELIVERED, message_id="wamid.LATE")
event.recipient_id = "447700900123"
event.pricing_category = "utility"
await process_status_event(account.id, event)
(parked,) = redis.store.values()
assert "447700900123" not in parked[0] and "utility" not in parked[0]
@pytest.mark.asyncio
async def test_redis_failure_is_not_fatal(db, account, test_organization, use_test_db, mock_sio, monkeypatch):
class _Broken:
def pipeline(self):
raise ConnectionError("redis down")
monkeypatch.setattr(channel_status, "get_redis", lambda: _Broken())
message = _message(db, test_organization.id)
await process_status_event(account.id, _event(DeliveryState.SENT, message_id="wamid.NOPE"))
await record_sent(db, message.id, account.id, "wamid.NOPE")
db.refresh(message)
assert message.external_message_id == "wamid.NOPE"
@pytest.mark.asyncio
async def test_failed_after_read_changes_nothing(db, account, test_organization,
use_test_db, mock_sio, redis):
message = _message(db, test_organization.id, "wamid.1")
await process_status_event(account.id, _event(DeliveryState.READ))
await process_status_event(account.id, _event(DeliveryState.FAILED, error="late failure"))
db.refresh(message)
assert message.channel_status == "read"
assert "delivery_status" not in (message.attributes or {})
mock_sio.emit.assert_awaited_once() # for the read; the late failure is dropped
@pytest.mark.asyncio
async def test_every_receipt_is_announced_even_for_unknown_messages(
db, account, use_test_db, mock_sio, redis):
"""Usage counting needs receipts for messages ReplyPier never stored."""
seen = []
async def listen(event):
seen.append(event)
register_event_listener(listen)
await process_status_event(account.id, _event(DeliveryState.DELIVERED, message_id="wamid.ELSEWHERE"))
(event,) = seen
assert event.kind == EventKind.STATUS
assert event.status.external_message_id == "wamid.ELSEWHERE"
assert event.event_id == f"{account.id}:wamid.ELSEWHERE:delivered"