324 lines
12 KiB
Python
324 lines
12 KiB
Python
"""
|
|
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"
|