Files
replypier/backend/tests/services/test_message_delivery.py
T
WangShen007 20fef2b9f3 chore: settle product naming across app, widget and deployments
One word everywhere: ReplyPier in UI copy, package and image names, the
replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the
--rp-* design tokens.

- public widget API is window.ReplyPier with replypierId / replypierBaseUrl /
  replypierConfig / replypierToken, loader at webclient/replypier.min.js;
  both bundles rebuilt from source
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime
  config rewritten; the demo config ships no third-party keys
- migration adds the ReplyPier managed model value to the AI model enum
- local compose project, database, role, volume and Docker network carry the
  same name
2026-10-26 00:57:22 +08:00

477 lines
22 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 uuid import uuid4
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.channels.app_hooks import EventKind, register_event_listener
from app.channels.constants import MessageSource
from app.channels.base import SendResult, WindowStatus
from app.channels.interactive import from_options
from app.channels.interactive_limits import INSTAGRAM_LIMITS, WHATSAPP_LIMITS
from app.channels.sent_choices import SENT_CHOICES_ATTRIBUTE, decode_sent_choices
from app.repositories.channels import ChannelAccountRepository, ChannelConversationRepository
from app.repositories.channels.accounts import needs_reconnect
from app.repositories.chat import ChatRepository
from app.repositories.session_to_agent import SessionToAgentRepository
from app.services import message_delivery
from app.services.business_message import LAST_BUSINESS_MESSAGE, remember_business_message
from app.services.message_delivery import deliver_to_customer
@pytest.fixture
def mock_sio(monkeypatch):
sio = MagicMock()
sio.emit = AsyncMock()
monkeypatch.setattr(message_delivery, "sio", sio)
return sio
def make_session(db, test_agent, test_customer, test_organization, channel):
return SessionToAgentRepository(db).create_session(
session_id=uuid4(),
agent_id=test_agent.id,
customer_id=test_customer.id,
organization_id=test_organization.id,
channel=channel,
)
def make_channel_session(db, test_agent, test_customer, test_organization):
session = make_session(db, test_agent, test_customer, test_organization, 'telegram')
account = ChannelAccountRepository(db).create_account(
organization_id=test_organization.id,
channel_type='telegram',
external_account_id='bot123',
credentials={'bot_token': '123:abc'},
display_name='@testbot',
)
ChannelConversationRepository(db).create(
channel_account_id=account.id,
channel_type='telegram',
external_conversation_id='222',
external_user_id='111',
session_id=session.session_id,
organization_id=test_organization.id,
agent_id=test_agent.id,
customer_id=test_customer.id,
)
return session, account
@pytest.mark.asyncio
async def test_web_session_uses_socket_emit_only(db, test_agent, test_customer, test_organization, mock_sio):
session = make_session(db, test_agent, test_customer, test_organization, 'web')
result = await deliver_to_customer(db, session, {'message': 'hi'}, source=MessageSource.HUMAN)
assert result.ok is True
mock_sio.emit.assert_awaited_once_with(
'chat_response', {'message': 'hi'}, room=str(session.session_id), namespace='/widget')
@pytest.mark.asyncio
async def test_channel_session_routes_to_adapter(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
adapter = MagicMock()
adapter.check_delivery_window.return_value = WindowStatus.OK
adapter.format_outbound.side_effect = lambda t: t
adapter.send_text = AsyncMock(return_value=SendResult(ok=True))
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
result = await deliver_to_customer(db, session, {'message': 'hello'}, source=MessageSource.HUMAN)
assert result.ok is True
adapter.send_text.assert_awaited_once()
assert adapter.send_text.await_args.args[2] == 'hello'
mock_sio.emit.assert_awaited_once() # dashboard viewers still get the event
@pytest.mark.asyncio
async def test_channel_session_window_expired(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
adapter = MagicMock()
adapter.check_delivery_window.return_value = WindowStatus.TEMPLATE_REQUIRED
adapter.send_text = AsyncMock()
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
result = await deliver_to_customer(db, session, {'message': 'hello'}, source=MessageSource.HUMAN)
assert result.ok is False
assert result.reason == 'window_expired'
assert result.can_template is True
adapter.send_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_channel_session_without_conversation_fails(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session = make_session(db, test_agent, test_customer, test_organization, 'telegram')
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: MagicMock())
result = await deliver_to_customer(db, session, {'message': 'hello'}, source=MessageSource.HUMAN)
assert result.ok is False
assert result.reason == 'no_channel_conversation'
@pytest.mark.asyncio
async def test_send_failure_reported(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
adapter = MagicMock()
adapter.check_delivery_window.return_value = WindowStatus.OK
adapter.format_outbound.side_effect = lambda t: t
adapter.send_text = AsyncMock(return_value=SendResult(ok=False, error='chat not found'))
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
result = await deliver_to_customer(db, session, {'message': 'hello'}, source=MessageSource.HUMAN)
assert result.ok is False
assert result.reason == 'chat not found'
@pytest.mark.asyncio
async def test_a_refused_token_marks_the_account_for_reconnection(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session, account = make_channel_session(db, test_agent, test_customer, test_organization)
adapter = _channel_adapter(False)
adapter.send_text = AsyncMock(return_value=SendResult(
ok=False, error='Authentication Error', auth_failed=True))
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
result = await deliver_to_customer(db, session, {'message': 'hello'}, source=MessageSource.AI)
assert result.ok is False
db.refresh(account)
assert needs_reconnect(account) is True
# Once a send goes through again, the account is healthy
adapter.send_text = AsyncMock(return_value=SendResult(ok=True, external_message_id='wamid.OK'))
result = await deliver_to_customer(db, session, {'message': 'hello'}, source=MessageSource.AI)
assert result.ok is True
db.refresh(account)
assert needs_reconnect(account) is False
@pytest.mark.asyncio
async def test_an_interactive_auth_failure_still_marks_the_account(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
"""The interactive attempt and the text fallback both fail; the result
that decides is the fallback's."""
session, account = make_channel_session(db, test_agent, test_customer, test_organization)
refused = SendResult(ok=False, error='Authentication Error', auth_failed=True)
adapter = _channel_adapter(True)
adapter.send_interactive = AsyncMock(return_value=refused)
adapter.send_text = AsyncMock(return_value=refused)
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
await deliver_to_customer(db, session, {'message': 'Pick one'},
interactive=from_options('Pick one', ['A', 'B']),
source=MessageSource.AI)
db.refresh(account)
assert needs_reconnect(account) is True
def _channel_adapter(supports_interactive: bool):
adapter = MagicMock()
adapter.channel_type = 'whatsapp'
adapter.supports_interactive = supports_interactive
adapter.interactive_limits = WHATSAPP_LIMITS if supports_interactive else None
adapter.reports_delivery_status = True
adapter.max_text_length = 4096
adapter.check_delivery_window.return_value = WindowStatus.OK
adapter.format_outbound.side_effect = lambda t: t
adapter.send_text = AsyncMock(return_value=SendResult(ok=True, external_message_id='wamid.TXT'))
adapter.send_interactive = AsyncMock(return_value=SendResult(ok=True, external_message_id='wamid.INT'))
return adapter
@pytest.mark.asyncio
async def test_quick_replies_go_out_as_buttons(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
adapter = _channel_adapter(supports_interactive=True)
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
result = await deliver_to_customer(db, session, {'message': 'Confirm?'}, interactive=from_options('Confirm?', ['Yes', 'No']), source=MessageSource.HUMAN)
assert result.ok
sent = adapter.send_interactive.await_args.args[2]
assert [b.title for b in sent.buttons] == ['Yes', 'No']
adapter.send_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_quick_replies_fall_back_to_text_without_support(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
adapter = _channel_adapter(supports_interactive=False)
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
await deliver_to_customer(db, session, {'message': 'Confirm?'}, interactive=from_options('Confirm?', ['Yes', 'No']), source=MessageSource.HUMAN)
adapter.send_interactive.assert_not_awaited()
assert adapter.send_text.await_args.args[2] == 'Confirm?\n\n1. Yes\n2. No'
@pytest.mark.asyncio
async def test_rejected_interactive_send_falls_back_to_text(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
adapter = _channel_adapter(supports_interactive=True)
adapter.send_interactive = AsyncMock(return_value=SendResult(ok=False, error='bad param'))
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
result = await deliver_to_customer(db, session, {'message': 'Confirm?'}, interactive=from_options('Confirm?', ['Yes']), source=MessageSource.HUMAN)
assert result.ok
assert adapter.send_text.await_args.args[2] == 'Confirm?\n\n1. Yes'
@pytest.mark.asyncio
async def test_sent_message_id_lands_on_the_given_row(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: _channel_adapter(False))
chat_repo = ChatRepository(db)
common = {'session_id': str(session.session_id), 'organization_id': str(test_organization.id),
'message_type': 'bot'}
target = chat_repo.create_message({**common, 'message': 'first'})
later = chat_repo.create_message({**common, 'message': 'second'})
await deliver_to_customer(db, session, {'message': 'first'}, message_id=target.id, source=MessageSource.HUMAN)
db.refresh(target)
db.refresh(later)
assert target.external_message_id == 'wamid.TXT'
assert later.external_message_id is None
def _bot_row(db, session, test_organization, text='Confirm?'):
return ChatRepository(db).create_message({
'session_id': str(session.session_id), 'organization_id': str(test_organization.id),
'message_type': 'bot', 'message': text})
@pytest.mark.asyncio
async def test_what_was_sent_is_recorded_on_the_row(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: _channel_adapter(True))
row = _bot_row(db, session, test_organization)
await deliver_to_customer(db, session, {'message': 'Confirm?'}, message_id=row.id,
interactive=from_options('Confirm?', ['Yes', 'No']), source=MessageSource.HUMAN)
db.refresh(row)
record = decode_sent_choices(row.attributes[SENT_CHOICES_ATTRIBUTE])
assert record['as_text'] is False
assert [c['text'] for c in record['choices']] == ['Yes', 'No']
assert row.external_message_id == 'wamid.INT'
@pytest.mark.asyncio
async def test_a_text_fallback_is_recorded_as_such(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: _channel_adapter(False))
row = _bot_row(db, session, test_organization)
await deliver_to_customer(db, session, {'message': 'Confirm?'}, message_id=row.id,
interactive=from_options('Confirm?', ['Yes']), source=MessageSource.HUMAN)
db.refresh(row)
assert decode_sent_choices(row.attributes[SENT_CHOICES_ATTRIBUTE])['as_text'] is True
@pytest.mark.asyncio
async def test_a_long_reply_goes_as_text_then_the_choices(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
adapter = _channel_adapter(True)
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
row = _bot_row(db, session, test_organization)
long_reply = 'x' * 1100
await deliver_to_customer(db, session, {'message': long_reply}, message_id=row.id,
interactive=from_options(long_reply, ['Yes', 'No']), source=MessageSource.HUMAN)
assert adapter.send_text.await_args.args[2] == long_reply
choices = adapter.send_interactive.await_args.args[2]
assert choices.body == 'Choose an option:' and [b.title for b in choices.buttons] == ['Yes', 'No']
db.refresh(row)
assert row.external_message_id == 'wamid.INT' # a tap replies to the choices
@pytest.mark.asyncio
async def test_both_messages_of_a_split_reply_are_announced(db, test_agent, test_customer, test_organization,
mock_sio, monkeypatch):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: _channel_adapter(True))
seen = []
async def listen(event):
if event.kind == EventKind.OUTBOUND:
seen.append((event.outbound.external_message_id, event.outbound.interactive is not None))
register_event_listener(listen)
long_reply = 'x' * 1100
await deliver_to_customer(db, session, {'message': long_reply}, message_id=_bot_row(db, session, test_organization).id,
interactive=from_options(long_reply, ['Yes', 'No']), source=MessageSource.AI)
assert seen == [('wamid.TXT', False), ('wamid.INT', True)]
@pytest.mark.asyncio
async def test_channels_without_receipts_record_no_message_id(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
adapter = _channel_adapter(False)
adapter.reports_delivery_status = False
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
row = _bot_row(db, session, test_organization)
await deliver_to_customer(db, session, {'message': 'hi'}, message_id=row.id, source=MessageSource.HUMAN)
db.refresh(row)
assert row.external_message_id is None
@pytest.mark.asyncio
async def test_choices_on_a_channel_without_receipts_record_the_offers_id(
db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
"""A tap is matched back to the message that offered it (Messenger, Telegram)."""
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
adapter = _channel_adapter(True)
adapter.reports_delivery_status = False
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
row = _bot_row(db, session, test_organization)
await deliver_to_customer(db, session, {'message': 'Coming?'}, message_id=row.id,
interactive=from_options('Coming?', ['Yes', 'No']), source=MessageSource.AI)
db.refresh(row)
assert row.external_message_id == 'wamid.INT'
assert decode_sent_choices(row.attributes[SENT_CHOICES_ATTRIBUTE])["numbered"] is False
@pytest.mark.asyncio
async def test_choices_written_into_the_text_split_a_long_reply_and_are_numbered(
db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
"""Instagram: the reply plus its numbered choices must fit, or the reply
goes on its own first — never cut, which would lose the question."""
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
adapter = _channel_adapter(True)
adapter.interactive_limits = INSTAGRAM_LIMITS
adapter.reports_delivery_status = False
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
row = _bot_row(db, session, test_organization)
reply = 'x' * 960 + ' Which one do you want?' # fits alone, not with the choices
await deliver_to_customer(db, session, {'message': reply}, message_id=row.id,
interactive=from_options(reply, ['First', 'Second', 'Third'], INSTAGRAM_LIMITS),
source=MessageSource.AI)
assert adapter.send_text.await_args.args[2] == reply
assert adapter.send_interactive.await_args.args[2].body == 'Choose an option:'
db.refresh(row)
assert decode_sent_choices(row.attributes[SENT_CHOICES_ATTRIBUTE])["numbered"] is True
@pytest.mark.asyncio
async def test_bookkeeping_failure_never_fails_a_delivered_message(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: _channel_adapter(False))
monkeypatch.setattr(message_delivery, "record_sent", AsyncMock(side_effect=RuntimeError("socket down")))
row = _bot_row(db, session, test_organization)
result = await deliver_to_customer(db, session, {'message': 'hi'}, message_id=row.id, source=MessageSource.HUMAN)
assert result.ok is True
@pytest.mark.asyncio
async def test_long_reply_counts_as_delivered_when_only_its_choices_fail(
db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
adapter = _channel_adapter(True)
adapter.send_text = AsyncMock(side_effect=[SendResult(ok=True, external_message_id='wamid.BODY'),
SendResult(ok=False, error='rate limited')])
adapter.send_interactive = AsyncMock(return_value=SendResult(ok=False, error='bad param'))
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
row = _bot_row(db, session, test_organization)
long_reply = 'x' * 1100
result = await deliver_to_customer(db, session, {'message': long_reply}, message_id=row.id,
interactive=from_options(long_reply, ['Yes', 'No']), source=MessageSource.HUMAN)
assert result.ok is True
db.refresh(row)
assert row.external_message_id == 'wamid.BODY'
assert SENT_CHOICES_ATTRIBUTE not in (row.attributes or {})
@pytest.fixture
def outbound_events():
seen = []
async def listen(event):
seen.append(event)
register_event_listener(listen)
return seen
@pytest.mark.asyncio
async def test_a_channel_send_is_announced_with_who_wrote_it(
db, test_agent, test_customer, test_organization, mock_sio, monkeypatch, outbound_events):
session, account = make_channel_session(db, test_agent, test_customer, test_organization)
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: _channel_adapter(False))
row = _bot_row(db, session, test_organization, text='hi')
result = await deliver_to_customer(db, session, {'message': 'hi'}, message_id=row.id,
source=MessageSource.AI)
assert result.external_message_id == 'wamid.TXT'
(event,) = outbound_events
assert event.kind == EventKind.OUTBOUND
assert event.channel_account_id == account.id
assert (event.outbound.source, event.outbound.message_id, event.outbound.text) == \
(MessageSource.AI, row.id, 'hi')
@pytest.mark.asyncio
async def test_failed_and_web_sends_are_not_announced(
db, test_agent, test_customer, test_organization, mock_sio, monkeypatch, outbound_events):
web = make_session(db, test_agent, test_customer, test_organization, 'web')
await deliver_to_customer(db, web, {'message': 'hi'}, source=MessageSource.AI)
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
adapter = _channel_adapter(False)
adapter.send_text = AsyncMock(return_value=SendResult(ok=False, error='blocked'))
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
await deliver_to_customer(db, session, {'message': 'hi'}, source=MessageSource.AI)
assert outbound_events == []
@pytest.mark.asyncio
@pytest.mark.parametrize("source,kept", [
(MessageSource.HUMAN, False), # a person took over from there
(MessageSource.AI, True), # the AI's own turns don't make it stale
(MessageSource.SYSTEM, True), # e.g. a quota notice before the AI could answer
(MessageSource.APP, True),
])
async def test_only_a_person_replying_ends_the_business_message_context(
db, test_agent, test_customer, test_organization, mock_sio, monkeypatch, source, kept):
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: _channel_adapter(False))
conversation = ChannelConversationRepository(db).get_by_session(session.session_id)
remember_business_message(db, conversation, "Coming at 5?", "wamid.T")
await deliver_to_customer(db, session, {'message': 'ok'}, source=source)
db.refresh(conversation)
assert (LAST_BUSINESS_MESSAGE in (conversation.extra or {})) is kept