test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
1 parent
5da5f910da
commit
2554eb7bc7
97 files changed
+37281
No files matched your search
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 ChatterMate 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"
|
||||
Reference in new issue
Block a user