feat(chat): Socket.IO 双端通道、消息持久化与幂等
访客与客服分别鉴权接入,消息带客户端 ID 去重,重发只产生一条业务记录。
This commit is contained in:
1 parent
5d0a48505a
commit
db48cac9e0
23 files changed
+7057
No files matched your search
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Chat notification preference gating: who gets a push for which event, and
|
||||
what happens for users who have never opened the settings page.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.notification import Notification, NotificationType
|
||||
from app.models.notification_settings import UserNotificationSettings
|
||||
from app.models.permission import Permission
|
||||
from app.models.role import Role
|
||||
from app.models.user import User
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.services.chat_notifications import notify_chat_assigned, notify_new_chat
|
||||
from app.services.notifications import ChatNotificationEvent, notify_chat_event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def second_user(db, test_organization, test_role) -> User:
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="second@example.com",
|
||||
full_name="Second User",
|
||||
hashed_password="hashed_password",
|
||||
is_active=True,
|
||||
organization_id=test_organization.id,
|
||||
role_id=test_role.id,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
async def _notify(db, user_ids, event):
|
||||
with patch("app.services.notifications.send_fcm_notification", new=AsyncMock()):
|
||||
await notify_chat_event(
|
||||
db=db,
|
||||
user_ids=user_ids,
|
||||
event=event,
|
||||
title="New Chat Transfer",
|
||||
message="A chat has been transferred to your group.",
|
||||
metadata={"session_id": "abc-123"},
|
||||
)
|
||||
|
||||
|
||||
def _notifications_for(db, user_id):
|
||||
return db.query(Notification).filter(Notification.user_id == user_id).all()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_users_without_settings_get_default_enabled_events(db, test_user):
|
||||
"""No settings row means the defaults apply — transfers still notify."""
|
||||
await _notify(db, [test_user.id], ChatNotificationEvent.CHAT_TRANSFER)
|
||||
|
||||
notifications = _notifications_for(db, test_user.id)
|
||||
assert len(notifications) == 1
|
||||
assert notifications[0].type == NotificationType.CHAT
|
||||
assert notifications[0].notification_metadata["event"] == "notify_chat_transfer"
|
||||
# session_id must survive — the push uses it to deep-link the conversation.
|
||||
assert notifications[0].notification_metadata["session_id"] == "abc-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_users_without_settings_do_not_get_default_disabled_events(db, test_user):
|
||||
"""New-chat is opt-in, so an untouched user gets nothing."""
|
||||
await _notify(db, [test_user.id], ChatNotificationEvent.NEW_CHAT)
|
||||
|
||||
assert _notifications_for(db, test_user.id) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_muted_user_is_skipped_others_are_not(db, test_user, second_user):
|
||||
db.add(UserNotificationSettings(user_id=test_user.id, notify_chat_transfer=False))
|
||||
db.commit()
|
||||
|
||||
await _notify(db, [test_user.id, second_user.id], ChatNotificationEvent.CHAT_TRANSFER)
|
||||
|
||||
assert _notifications_for(db, test_user.id) == []
|
||||
assert len(_notifications_for(db, second_user.id)) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_opted_in_user_gets_new_chat(db, test_user):
|
||||
db.add(UserNotificationSettings(user_id=test_user.id, notify_new_chat=True))
|
||||
db.commit()
|
||||
|
||||
await _notify(db, [test_user.id], ChatNotificationEvent.NEW_CHAT)
|
||||
|
||||
assert len(_notifications_for(db, test_user.id)) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_recipients_notified_once(db, test_user):
|
||||
await _notify(
|
||||
db,
|
||||
[test_user.id, test_user.id, str(test_user.id)],
|
||||
ChatNotificationEvent.CHAT_TRANSFER,
|
||||
)
|
||||
|
||||
assert len(_notifications_for(db, test_user.id)) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_recipients_is_a_no_op(db):
|
||||
await _notify(db, [], ChatNotificationEvent.CHAT_TRANSFER)
|
||||
|
||||
assert db.query(Notification).count() == 0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inbox_user(db, test_organization) -> User:
|
||||
"""A user whose role can see unclaimed chats, opted in to new-chat pushes."""
|
||||
permission = Permission(name="view_unassigned_chats")
|
||||
db.add(permission)
|
||||
db.commit()
|
||||
|
||||
role = Role(name="Inbox Agent", organization_id=test_organization.id)
|
||||
role.permissions = [permission]
|
||||
db.add(role)
|
||||
db.commit()
|
||||
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="inbox@example.com",
|
||||
full_name="Inbox Agent",
|
||||
hashed_password="hashed_password",
|
||||
is_active=True,
|
||||
organization_id=test_organization.id,
|
||||
role_id=role.id,
|
||||
)
|
||||
db.add(user)
|
||||
db.add(UserNotificationSettings(user_id=user.id, notify_new_chat=True))
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _new_session(db, test_organization, test_agent, test_customer):
|
||||
return SessionToAgentRepository(db).create_session(
|
||||
session_id=uuid4(),
|
||||
agent_id=test_agent.id,
|
||||
customer_id=test_customer.id,
|
||||
organization_id=test_organization.id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_chat_notifies_only_users_who_can_open_it(
|
||||
db, test_organization, test_agent, test_customer, test_user, inbox_user
|
||||
):
|
||||
"""test_user's role has no chat permissions, so it isn't a recipient."""
|
||||
session = _new_session(db, test_organization, test_agent, test_customer)
|
||||
|
||||
with patch("app.services.notifications.send_fcm_notification", new=AsyncMock()):
|
||||
await notify_new_chat(db, session)
|
||||
|
||||
assert len(_notifications_for(db, inbox_user.id)) == 1
|
||||
assert _notifications_for(db, test_user.id) == []
|
||||
|
||||
notification = _notifications_for(db, inbox_user.id)[0]
|
||||
assert notification.notification_metadata["session_id"] == str(session.session_id)
|
||||
assert "web widget" in notification.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_chat_respects_the_opt_in(
|
||||
db, test_organization, test_agent, test_customer, inbox_user
|
||||
):
|
||||
db.query(UserNotificationSettings)\
|
||||
.filter(UserNotificationSettings.user_id == inbox_user.id)\
|
||||
.update({"notify_new_chat": False})
|
||||
db.commit()
|
||||
|
||||
session = _new_session(db, test_organization, test_agent, test_customer)
|
||||
with patch("app.services.notifications.send_fcm_notification", new=AsyncMock()):
|
||||
await notify_new_chat(db, session)
|
||||
|
||||
assert _notifications_for(db, inbox_user.id) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_opening_the_widget_alone_is_not_a_new_chat(
|
||||
db, test_organization, test_agent, test_customer
|
||||
):
|
||||
"""The widget opens its session on connect — no customer message, no chat."""
|
||||
session = _new_session(db, test_organization, test_agent, test_customer)
|
||||
chat_repo = ChatRepository(db)
|
||||
|
||||
assert chat_repo.has_customer_messages(session.session_id) is False
|
||||
|
||||
chat_repo.create_message({
|
||||
"message": "Hello?",
|
||||
"message_type": "user",
|
||||
"session_id": str(session.session_id),
|
||||
"organization_id": str(test_organization.id),
|
||||
"agent_id": str(test_agent.id),
|
||||
"customer_id": str(test_customer.id),
|
||||
})
|
||||
|
||||
assert chat_repo.has_customer_messages(session.session_id) is True
|
||||
# A bot-only session still counts as unstarted.
|
||||
other = _new_session(db, test_organization, test_agent, test_customer)
|
||||
chat_repo.create_message({
|
||||
"message": "Hi there!",
|
||||
"message_type": "bot",
|
||||
"session_id": str(other.session_id),
|
||||
"organization_id": str(test_organization.id),
|
||||
"agent_id": str(test_agent.id),
|
||||
"customer_id": str(test_customer.id),
|
||||
})
|
||||
assert chat_repo.has_customer_messages(other.session_id) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assignment_notifies_the_target(
|
||||
db, test_organization, test_agent, test_customer, test_user, second_user
|
||||
):
|
||||
session = _new_session(db, test_organization, test_agent, test_customer)
|
||||
|
||||
with patch("app.services.notifications.send_fcm_notification", new=AsyncMock()):
|
||||
await notify_chat_assigned(db, session, second_user.id, assigned_by=test_user.id)
|
||||
|
||||
assert len(_notifications_for(db, second_user.id)) == 1
|
||||
assert _notifications_for(db, test_user.id) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_self_assignment_notifies_nobody(
|
||||
db, test_organization, test_agent, test_customer, test_user
|
||||
):
|
||||
"""Claiming a chat yourself shouldn't push a notification back at you."""
|
||||
session = _new_session(db, test_organization, test_agent, test_customer)
|
||||
|
||||
with patch("app.services.notifications.send_fcm_notification", new=AsyncMock()):
|
||||
await notify_chat_assigned(db, session, test_user.id, assigned_by=test_user.id)
|
||||
|
||||
assert _notifications_for(db, test_user.id) == []
|
||||
Reference in new issue
Block a user