The upstream project name is gone from code, assets, docs and build
output: brand strings, domains (replypier.site and its app/api/docs/help
subdomains), env prefixes, image and network names, the widget's public
JS API (window.ReplyPier, replypierId/BaseUrl/Config/Token), CSS tokens
(--rp-*), file and directory names, and the WordPress plugin package.
- widget loader is now webclient/replypier.min.js; both bundles rebuilt
from source (backend/assets/widget.{js,css}, frontend/public/webclient)
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and config.dev.js
rewritten rather than string-swapped; the demo config no longer ships
the previous vendor's Firebase/Razorpay/Google keys
- new migration replypier_ai_model_001 adds REPLYPIER to the managed
AIModelType and rewrites pre-rename rows
- local compose project, database, role and volume renamed; LOCAL_DEPLOY
documents carrying the old local data over
254 lines
8.8 KiB
Python
254 lines
8.8 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.
|
|
|
|
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) == []
|