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
132 lines
4.5 KiB
Python
132 lines
4.5 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.
|
|
"""
|
|
|
|
import asyncio
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from app.channels import app_hooks
|
|
from app.channels.app_hooks import (
|
|
ChannelEvent, EventKind, MessageSource, OutboundRecord, emit_channel_event,
|
|
inbound_event_id, outbound_event, register_event_listener, register_inbound_handler,
|
|
)
|
|
|
|
|
|
def _event() -> ChannelEvent:
|
|
return ChannelEvent(kind=EventKind.INBOUND, event_id="a:1", organization_id=uuid4(),
|
|
channel_account_id=uuid4(), occurred_at=datetime.now(timezone.utc))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_extension_means_every_hook_is_a_no_op():
|
|
assert app_hooks.inbound_handler() is None
|
|
await emit_channel_event(_event()) # nothing listening, nothing raised
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_failing_listener_never_stops_the_others():
|
|
calls = []
|
|
|
|
async def broken(event):
|
|
raise RuntimeError("extension bug")
|
|
|
|
async def working(event):
|
|
calls.append(event.event_id)
|
|
|
|
register_event_listener(broken)
|
|
register_event_listener(working)
|
|
register_event_listener(working) # registering twice is a no-op
|
|
|
|
await emit_channel_event(_event())
|
|
|
|
assert calls == ["a:1"]
|
|
|
|
|
|
def test_load_extensions_without_one_installed_is_harmless(monkeypatch):
|
|
monkeypatch.setattr(app_hooks, "EXTENSION_HOOKS_MODULE", "app.no_such_extension_module")
|
|
app_hooks.load_extensions()
|
|
app_hooks.load_extensions() # idempotent
|
|
assert app_hooks.inbound_handler() is None
|
|
|
|
|
|
def test_load_extensions_imports_the_extension_once(monkeypatch):
|
|
imported = []
|
|
monkeypatch.setattr(app_hooks.importlib, "import_module", lambda name: imported.append(name))
|
|
app_hooks.load_extensions()
|
|
app_hooks.load_extensions()
|
|
assert imported == [app_hooks.EXTENSION_HOOKS_MODULE]
|
|
|
|
|
|
def test_reset_forgets_registrations():
|
|
register_inbound_handler(AsyncMock(return_value=True))
|
|
register_event_listener(AsyncMock())
|
|
app_hooks.reset()
|
|
assert app_hooks.inbound_handler() is None
|
|
|
|
|
|
def test_event_ids_are_stable_for_the_same_message():
|
|
account = uuid4()
|
|
assert inbound_event_id(account, "wamid.1") == inbound_event_id(account, "wamid.1")
|
|
record = OutboundRecord(message_id=7, external_message_id="wamid.OUT",
|
|
source=MessageSource.AI, text="hi")
|
|
first = outbound_event(organization_id=uuid4(), channel_account_id=account,
|
|
session_id="s", customer_id=None, record=record)
|
|
second = outbound_event(organization_id=uuid4(), channel_account_id=account,
|
|
session_id="s", customer_id=None, record=record)
|
|
assert first.event_id == second.event_id == f"{account}:out:wamid.OUT"
|
|
|
|
|
|
def test_outbound_without_a_platform_id_is_keyed_by_its_row():
|
|
account = uuid4()
|
|
record = OutboundRecord(message_id=7, external_message_id=None,
|
|
source=MessageSource.SYSTEM, text="hi")
|
|
event = outbound_event(organization_id=uuid4(), channel_account_id=account,
|
|
session_id=None, customer_id=None, record=record)
|
|
assert event.event_id == f"{account}:out:row7"
|
|
|
|
|
|
def test_a_broken_extension_is_reported_not_silently_skipped(monkeypatch, caplog):
|
|
def import_module(name):
|
|
raise ModuleNotFoundError("No module named 'some_dependency'", name="some_dependency")
|
|
monkeypatch.setattr(app_hooks.importlib, "import_module", import_module)
|
|
|
|
with caplog.at_level("ERROR"):
|
|
app_hooks.load_extensions()
|
|
|
|
assert "Channel extension failed to load" in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_slow_listener_is_cut_off(monkeypatch):
|
|
monkeypatch.setattr(app_hooks, "LISTENER_TIMEOUT_SECONDS", 0.01)
|
|
calls = []
|
|
|
|
async def slow(event):
|
|
await asyncio.sleep(5)
|
|
|
|
async def fast(event):
|
|
calls.append(event.event_id)
|
|
|
|
register_event_listener(slow)
|
|
register_event_listener(fast)
|
|
|
|
await emit_channel_event(_event())
|
|
|
|
assert calls == ["a:1"]
|