mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-16 18:46:04 +00:00
simplex-chat-python: split Client from Bot, add request/response API (#6976)
* simplex-chat-python: split Client from Bot, add request/response API
Client is now the base class for SimpleX participants that talk TO
services (monitors, probes, automated participants). Bot extends Client
with server features (address, auto-accept, welcome, commands).
New methods on Client (inherited by Bot):
connect_to(link) idempotent contact handshake
send_and_wait(id, text) send a message and await the reply
events() async iterator over chat events
@on_message(contact_id=N) filter by sender in decorators
BotProfile renamed to Profile (alias kept). New ContactAlreadyExistsError
subclass for cleaner error handling.
* simplex-chat-python: narrow event payload type per @on_event tag
@client.on_event("contactConnected") now types the handler's event
parameter as CEvt.ContactConnected instead of the unnarrowed
CEvt.ChatEvent union — mirroring how @on_message narrows by
content_type.
The 50 overloads are generated by the Haskell codegen into _events.py
(as a Protocol class), so new events stay in sync automatically.
Client.on_event is exposed as a property typed as that Protocol; the
runtime implementation is unchanged.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from simplex_chat import Bot, BotCommand, BotProfile, Middleware, SqliteDb
|
||||
from simplex_chat import Bot, BotCommand, BotProfile, Client, Middleware, Profile, SqliteDb
|
||||
from simplex_chat.api import ChatApi
|
||||
|
||||
|
||||
@@ -57,9 +57,9 @@ def test_command_keyword_tuple():
|
||||
|
||||
|
||||
def test_bot_profile_to_wire_default():
|
||||
"""use_bot_profile=True (default) sets peerType=bot and disables calls/voice."""
|
||||
"""Bot's profile wire-form sets peerType=bot and disables calls/voice."""
|
||||
bot = _bot()
|
||||
p = bot._bot_profile_to_wire()
|
||||
p = bot._profile_to_wire()
|
||||
assert p["displayName"] == "x"
|
||||
assert p.get("peerType") == "bot"
|
||||
prefs = p.get("preferences") or {}
|
||||
@@ -74,7 +74,7 @@ def test_bot_profile_to_wire_allow_files():
|
||||
db=SqliteDb(file_prefix="/tmp/test"),
|
||||
allow_files=True,
|
||||
)
|
||||
prefs = bot._bot_profile_to_wire().get("preferences") or {}
|
||||
prefs = bot._profile_to_wire().get("preferences") or {}
|
||||
assert prefs.get("files", {}).get("allow") == "yes"
|
||||
|
||||
|
||||
@@ -84,32 +84,26 @@ def test_bot_profile_to_wire_with_commands():
|
||||
db=SqliteDb(file_prefix="/tmp/test"),
|
||||
commands=[BotCommand(keyword="ping", label="Ping bot"), BotCommand("help", "Show help")],
|
||||
)
|
||||
cmds = bot._bot_profile_to_wire().get("preferences", {}).get("commands") or []
|
||||
cmds = bot._profile_to_wire().get("preferences", {}).get("commands") or []
|
||||
assert len(cmds) == 2
|
||||
assert cmds[0] == {"type": "command", "keyword": "ping", "label": "Ping bot"}
|
||||
assert cmds[1] == {"type": "command", "keyword": "help", "label": "Show help"}
|
||||
|
||||
|
||||
def test_bot_profile_to_wire_no_bot_profile():
|
||||
bot = Bot(
|
||||
profile=BotProfile(display_name="x"),
|
||||
db=SqliteDb(file_prefix="/tmp/test"),
|
||||
use_bot_profile=False,
|
||||
)
|
||||
p = bot._bot_profile_to_wire()
|
||||
def test_client_profile_to_wire_has_no_bot_extras():
|
||||
"""Client's wire profile has no peerType=bot, no command list, no calls/voice prefs.
|
||||
That's the whole point of having Client as a separate class."""
|
||||
c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"))
|
||||
p = c._profile_to_wire()
|
||||
assert p["displayName"] == "x"
|
||||
assert "peerType" not in p
|
||||
assert "preferences" not in p
|
||||
|
||||
|
||||
def test_commands_without_bot_profile_raises():
|
||||
bot = Bot(
|
||||
profile=BotProfile(display_name="x"),
|
||||
db=SqliteDb(file_prefix="/tmp/test"),
|
||||
use_bot_profile=False,
|
||||
commands=[BotCommand("ping", "Ping bot")],
|
||||
)
|
||||
with pytest.raises(ValueError, match="use_bot_profile=False"):
|
||||
bot._bot_profile_to_wire()
|
||||
def test_bot_profile_alias_is_profile():
|
||||
"""`BotProfile` is kept as an alias for backwards compatibility."""
|
||||
assert BotProfile is Profile
|
||||
assert BotProfile(display_name="x") == Profile(display_name="x")
|
||||
|
||||
|
||||
def test_dispatch_message_first_match_wins():
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
"""Tests for Client class + connect_to / send_and_wait / events plumbing.
|
||||
|
||||
Stubs out ChatApi so we exercise the dispatch and waiter logic without
|
||||
spinning up the native libsimplex controller.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from simplex_chat import (
|
||||
Bot,
|
||||
BotProfile,
|
||||
Client,
|
||||
ContactAlreadyExistsError,
|
||||
Profile,
|
||||
SqliteDb,
|
||||
)
|
||||
|
||||
|
||||
class FakeApi:
|
||||
"""Drop-in replacement for ChatApi for tests that don't need the FFI.
|
||||
|
||||
Records api_send_text_message calls; supports scripting api_connect_plan
|
||||
and api_connect_active_user behaviour.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[tuple[Any, str]] = []
|
||||
self.connect_plan_result: Any = ("error", None) # default: no known contact
|
||||
self.connect_should_raise: Exception | None = None
|
||||
self.active_user: dict[str, Any] = {"userId": 1, "profile": {"displayName": "x"}}
|
||||
|
||||
async def api_send_text_message(self, chat, text, in_reply_to=None):
|
||||
self.sent.append((chat, text))
|
||||
return []
|
||||
|
||||
async def api_connect_plan(self, _user_id, _link):
|
||||
kind = self.connect_plan_result[0]
|
||||
if kind == "known_contact_address":
|
||||
return (
|
||||
{
|
||||
"type": "contactAddress",
|
||||
"contactAddressPlan": {"type": "known", "contact": self.connect_plan_result[1]},
|
||||
},
|
||||
{},
|
||||
)
|
||||
if kind == "known_invitation":
|
||||
return (
|
||||
{
|
||||
"type": "invitationLink",
|
||||
"invitationLinkPlan": {"type": "known", "contact": self.connect_plan_result[1]},
|
||||
},
|
||||
{},
|
||||
)
|
||||
if kind == "ok":
|
||||
return (
|
||||
{
|
||||
"type": "contactAddress",
|
||||
"contactAddressPlan": {"type": "ok"},
|
||||
},
|
||||
{},
|
||||
)
|
||||
# default "error"
|
||||
return ({"type": "error", "chatError": {}}, {})
|
||||
|
||||
async def api_connect_active_user(self, _link):
|
||||
if self.connect_should_raise is not None:
|
||||
raise self.connect_should_raise
|
||||
return "contact"
|
||||
|
||||
async def api_get_active_user(self):
|
||||
return self.active_user
|
||||
|
||||
|
||||
def _bot_with_fake_api() -> tuple[Bot, FakeApi]:
|
||||
bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"))
|
||||
api = FakeApi()
|
||||
bot._api = api # type: ignore[assignment]
|
||||
bot._serving = True # pretend receive loop is up
|
||||
return bot, api
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_client_has_no_address_or_bot_profile_attributes():
|
||||
"""Client should not carry bot-side state (address creation, auto-accept,
|
||||
welcome, commands). That's the whole point of separating Client from Bot."""
|
||||
c = Client(profile=Profile(display_name="monitor"), db=SqliteDb(file_prefix="/tmp/test"))
|
||||
for attr in ("_create_address", "_update_address", "_auto_accept", "_welcome", "_commands"):
|
||||
assert not hasattr(c, attr), f"Client unexpectedly has Bot-only attribute {attr}"
|
||||
# And the wire profile has no bot peerType
|
||||
p = c._profile_to_wire()
|
||||
assert "peerType" not in p
|
||||
assert "preferences" not in p
|
||||
|
||||
|
||||
def test_bot_is_a_client_subclass():
|
||||
"""Bot should extend Client, so anywhere a Client is accepted, a Bot fits too."""
|
||||
assert issubclass(Bot, Client)
|
||||
|
||||
|
||||
def test_client_exposes_messaging_methods():
|
||||
c = Client(profile=Profile(display_name="m"), db=SqliteDb(file_prefix="/tmp/test"))
|
||||
assert hasattr(c, "connect_to")
|
||||
assert hasattr(c, "send_and_wait")
|
||||
assert hasattr(c, "events")
|
||||
assert hasattr(c, "on_message") # decorators available on Client too
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send_and_wait
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_send_and_wait_requires_serving():
|
||||
"""Without the receive loop running, send_and_wait must raise — otherwise
|
||||
callers would silently hang waiting for a reply that's never dispatched."""
|
||||
bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"))
|
||||
bot._api = FakeApi() # type: ignore[assignment]
|
||||
# _serving is False by default
|
||||
with pytest.raises(RuntimeError, match="receive loop"):
|
||||
asyncio.run(bot.send_and_wait(1, "hi"))
|
||||
|
||||
|
||||
def test_send_and_wait_resolves_on_matching_reply():
|
||||
"""A reply from the awaited contact should resolve the Future and skip
|
||||
regular message dispatch."""
|
||||
bot, api = _bot_with_fake_api()
|
||||
fallback_calls: list[str] = []
|
||||
|
||||
@bot.on_message(content_type="text")
|
||||
async def fallback(_msg):
|
||||
fallback_calls.append("fallback")
|
||||
|
||||
async def go() -> str:
|
||||
send_task = asyncio.create_task(bot.send_and_wait(42, "ping", timeout=2.0))
|
||||
# Yield so the task gets to register its waiter.
|
||||
await asyncio.sleep(0)
|
||||
evt = {"type": "newChatItems", "chatItems": [
|
||||
{
|
||||
"chatInfo": {"type": "direct", "contact": {"contactId": 42}},
|
||||
"chatItem": {
|
||||
"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "pong"}},
|
||||
},
|
||||
}
|
||||
]}
|
||||
await bot._dispatch_event(evt) # type: ignore[arg-type]
|
||||
reply = await send_task
|
||||
return reply.text or ""
|
||||
|
||||
result = asyncio.run(go())
|
||||
assert result == "pong"
|
||||
assert api.sent == [(["direct", 42], "ping")]
|
||||
assert fallback_calls == [], "fallback handler should NOT fire when a waiter consumed the reply"
|
||||
|
||||
|
||||
def test_send_and_wait_ignores_other_contacts():
|
||||
"""Replies from a different contact must not resolve the waiter — that
|
||||
would mis-correlate responses and is the bug send_and_wait exists to
|
||||
prevent users from writing themselves."""
|
||||
bot, _api = _bot_with_fake_api()
|
||||
|
||||
async def go():
|
||||
send_task = asyncio.create_task(bot.send_and_wait(42, "ping", timeout=0.5))
|
||||
await asyncio.sleep(0)
|
||||
evt = {"type": "newChatItems", "chatItems": [
|
||||
{
|
||||
"chatInfo": {"type": "direct", "contact": {"contactId": 99}},
|
||||
"chatItem": {
|
||||
"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "not for you"}},
|
||||
},
|
||||
}
|
||||
]}
|
||||
await bot._dispatch_event(evt) # type: ignore[arg-type]
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await send_task
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_send_and_wait_fifo_within_contact():
|
||||
"""Two concurrent waiters on the same contact should resolve in send order."""
|
||||
bot, _api = _bot_with_fake_api()
|
||||
|
||||
async def go() -> tuple[str, str]:
|
||||
first = asyncio.create_task(bot.send_and_wait(42, "first", timeout=2.0))
|
||||
await asyncio.sleep(0)
|
||||
second = asyncio.create_task(bot.send_and_wait(42, "second", timeout=2.0))
|
||||
await asyncio.sleep(0)
|
||||
for text in ("reply1", "reply2"):
|
||||
evt = {"type": "newChatItems", "chatItems": [
|
||||
{
|
||||
"chatInfo": {"type": "direct", "contact": {"contactId": 42}},
|
||||
"chatItem": {
|
||||
"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": text}},
|
||||
},
|
||||
}
|
||||
]}
|
||||
await bot._dispatch_event(evt) # type: ignore[arg-type]
|
||||
return (await first).text or "", (await second).text or ""
|
||||
|
||||
a, b = asyncio.run(go())
|
||||
assert (a, b) == ("reply1", "reply2")
|
||||
|
||||
|
||||
def test_send_and_wait_cleans_up_state_on_timeout():
|
||||
"""Timed-out waiters must be removed so they don't accidentally consume
|
||||
later replies."""
|
||||
bot, _api = _bot_with_fake_api()
|
||||
|
||||
async def go():
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await bot.send_and_wait(42, "ping", timeout=0.05)
|
||||
assert 42 not in bot._reply_waiters, f"leaked waiters: {bot._reply_waiters}"
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_dispatch_skips_cancelled_waiters_and_falls_through_to_handlers():
|
||||
"""Race fix: if a waiter is cancelled (wait_for timed out) but still in
|
||||
the FIFO when a reply arrives, the dispatcher must skip it and either
|
||||
resolve a live waiter OR fall through to user message handlers — not
|
||||
silently drop the message."""
|
||||
bot, _api = _bot_with_fake_api()
|
||||
fallback_calls: list[str] = []
|
||||
|
||||
@bot.on_message(content_type="text")
|
||||
async def fallback(msg):
|
||||
fallback_calls.append(msg.text or "")
|
||||
|
||||
async def go():
|
||||
# Manually inject a cancelled waiter (simulating wait_for timeout
|
||||
# cleanup losing the race with the inbound message).
|
||||
loop = asyncio.get_running_loop()
|
||||
stale: asyncio.Future = loop.create_future()
|
||||
stale.cancel()
|
||||
bot._reply_waiters[42] = [stale]
|
||||
|
||||
evt = {"type": "newChatItems", "chatItems": [
|
||||
{
|
||||
"chatInfo": {"type": "direct", "contact": {"contactId": 42}},
|
||||
"chatItem": {
|
||||
"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "racing reply"}},
|
||||
},
|
||||
}
|
||||
]}
|
||||
await bot._dispatch_event(evt) # type: ignore[arg-type]
|
||||
|
||||
asyncio.run(go())
|
||||
assert fallback_calls == ["racing reply"], (
|
||||
"dispatcher dropped the message instead of falling through to user handlers; "
|
||||
f"got {fallback_calls}"
|
||||
)
|
||||
assert 42 not in bot._reply_waiters, "cancelled waiter wasn't cleaned up"
|
||||
|
||||
|
||||
def test_send_and_wait_parallel_different_contacts():
|
||||
"""Concurrent send_and_wait to different contacts must not block each other.
|
||||
|
||||
The library docstring promises this; this test pins the behaviour so a
|
||||
future refactor (e.g., adding a single lock) can't quietly break it."""
|
||||
bot, _api = _bot_with_fake_api()
|
||||
|
||||
async def go() -> tuple[str, str]:
|
||||
t_a = asyncio.create_task(bot.send_and_wait(10, "a", timeout=2.0))
|
||||
await asyncio.sleep(0)
|
||||
t_b = asyncio.create_task(bot.send_and_wait(20, "b", timeout=2.0))
|
||||
await asyncio.sleep(0)
|
||||
# Deliver reply for B first — order shouldn't matter.
|
||||
await bot._dispatch_event({"type": "newChatItems", "chatItems": [ # type: ignore[arg-type]
|
||||
{
|
||||
"chatInfo": {"type": "direct", "contact": {"contactId": 20}},
|
||||
"chatItem": {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "B"}}},
|
||||
}
|
||||
]})
|
||||
await bot._dispatch_event({"type": "newChatItems", "chatItems": [ # type: ignore[arg-type]
|
||||
{
|
||||
"chatInfo": {"type": "direct", "contact": {"contactId": 10}},
|
||||
"chatItem": {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "A"}}},
|
||||
}
|
||||
]})
|
||||
return (await t_a).text or "", (await t_b).text or ""
|
||||
|
||||
a, b = asyncio.run(go())
|
||||
assert (a, b) == ("A", "B")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# connect_to
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_connect_to_returns_known_contact_without_handshake():
|
||||
"""If the link is already known, connect_to skips api_connect entirely."""
|
||||
bot, api = _bot_with_fake_api()
|
||||
existing = {"contactId": 7, "profile": {"displayName": "SimpleX Directory"}}
|
||||
api.connect_plan_result = ("known_contact_address", existing)
|
||||
|
||||
contact = asyncio.run(bot.connect_to("link", timeout=2.0))
|
||||
assert contact["contactId"] == 7
|
||||
# No connect issued: send buffer untouched.
|
||||
assert api.sent == []
|
||||
|
||||
|
||||
def test_connect_to_waits_for_contactConnected():
|
||||
"""For unknown links, connect_to issues the handshake and waits for the
|
||||
contactConnected event before returning."""
|
||||
bot, api = _bot_with_fake_api()
|
||||
api.connect_plan_result = ("ok", None)
|
||||
new_contact = {"contactId": 11, "profile": {"displayName": "Friend"}}
|
||||
|
||||
async def go():
|
||||
connect_task = asyncio.create_task(bot.connect_to("link", timeout=2.0))
|
||||
await asyncio.sleep(0)
|
||||
await bot._dispatch_event({"type": "contactConnected", "contact": new_contact}) # type: ignore[arg-type]
|
||||
return await connect_task
|
||||
|
||||
contact = asyncio.run(go())
|
||||
assert contact["contactId"] == 11
|
||||
|
||||
|
||||
def test_connect_to_tolerates_contact_already_exists():
|
||||
"""ContactAlreadyExistsError must NOT abort connect_to — a previous
|
||||
incomplete attempt may have left the connection mid-handshake; the
|
||||
contactConnected event will still arrive."""
|
||||
bot, api = _bot_with_fake_api()
|
||||
api.connect_plan_result = ("ok", None)
|
||||
api.connect_should_raise = ContactAlreadyExistsError(
|
||||
"exists", {"type": "contactAlreadyExists"} # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
async def go():
|
||||
connect_task = asyncio.create_task(bot.connect_to("link", timeout=2.0))
|
||||
await asyncio.sleep(0)
|
||||
await bot._dispatch_event({"type": "contactConnected", "contact": {"contactId": 5, "profile": {"displayName": "Friend"}}}) # type: ignore[arg-type]
|
||||
return await connect_task
|
||||
|
||||
contact = asyncio.run(go())
|
||||
assert contact["contactId"] == 5
|
||||
|
||||
|
||||
def test_connect_to_requires_serving():
|
||||
bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"))
|
||||
bot._api = FakeApi() # type: ignore[assignment]
|
||||
with pytest.raises(RuntimeError, match="receive loop"):
|
||||
asyncio.run(bot.connect_to("link"))
|
||||
|
||||
|
||||
def test_connect_to_timeout_cleans_up_waiter():
|
||||
bot, api = _bot_with_fake_api()
|
||||
api.connect_plan_result = ("ok", None)
|
||||
|
||||
async def go():
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await bot.connect_to("link", timeout=0.05)
|
||||
assert bot._connect_waiters == [], "leaked connect waiter"
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_connect_to_rejects_non_positive_timeout():
|
||||
"""timeout<=0 must fail upfront — otherwise wait_for raises after the
|
||||
handshake side-effect has already gone over the wire."""
|
||||
bot, _api = _bot_with_fake_api()
|
||||
|
||||
async def go():
|
||||
for bad in (0, -1, -0.001):
|
||||
with pytest.raises(ValueError, match="timeout must be positive"):
|
||||
await bot.connect_to("link", timeout=bad)
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_send_and_wait_rejects_non_positive_timeout():
|
||||
"""Same as connect_to: timeout<=0 would surprise the caller with a sent
|
||||
message and no Future to await."""
|
||||
bot, api = _bot_with_fake_api()
|
||||
|
||||
async def go():
|
||||
for bad in (0, -1, -0.5):
|
||||
with pytest.raises(ValueError, match="timeout must be positive"):
|
||||
await bot.send_and_wait(42, "ping", timeout=bad)
|
||||
# And nothing was sent.
|
||||
assert api.sent == []
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_stop_before_serve_forever_is_preserved(monkeypatch):
|
||||
"""If stop() is called between __aenter__ and serve_forever (e.g. a
|
||||
signal handler fires during the window where run() wires SIGINT), the
|
||||
pre-set _stop_event must NOT be cleared by serve_forever — otherwise
|
||||
the signal is silently lost and the loop runs indefinitely."""
|
||||
import simplex_chat.client as client_mod
|
||||
|
||||
class _FakeApi:
|
||||
@classmethod
|
||||
async def init(cls, *_a, **_kw):
|
||||
return cls()
|
||||
|
||||
@property
|
||||
def started(self):
|
||||
return False
|
||||
|
||||
async def start_chat(self):
|
||||
pass
|
||||
|
||||
async def stop_chat(self):
|
||||
pass
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
async def api_get_active_user(self):
|
||||
return {"userId": 1, "profile": {"displayName": "x"}}
|
||||
|
||||
async def recv_chat_event(self, wait_us=0):
|
||||
# Should NOT be reached — the loop should exit on the pre-set
|
||||
# stop event before it ever polls for an event.
|
||||
raise AssertionError("receive loop should have exited immediately")
|
||||
|
||||
# _ensure_active_user / _maybe_sync_profile pokes
|
||||
async def send_chat_cmd(self, _cmd):
|
||||
return {"type": "cmdOk"}
|
||||
|
||||
monkeypatch.setattr(client_mod, "ChatApi", _FakeApi)
|
||||
|
||||
c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"))
|
||||
|
||||
async def go():
|
||||
async with c:
|
||||
c.stop() # signal fires before serve_forever
|
||||
await c.serve_forever() # must not block
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_aexit_nulls_api_even_if_close_raises(monkeypatch):
|
||||
"""If `close()` raises inside __aexit__, the Client must still appear
|
||||
closed — `client.api` should refuse to hand back the half-shutdown
|
||||
controller, and re-entering the context manager should re-init cleanly."""
|
||||
import simplex_chat.client as client_mod
|
||||
|
||||
init_count = [0]
|
||||
|
||||
class _BoomCloseApi:
|
||||
@classmethod
|
||||
async def init(cls, *_a, **_kw):
|
||||
init_count[0] += 1
|
||||
return cls()
|
||||
|
||||
@property
|
||||
def started(self):
|
||||
return False
|
||||
|
||||
async def start_chat(self):
|
||||
pass
|
||||
|
||||
async def stop_chat(self):
|
||||
pass
|
||||
|
||||
async def close(self):
|
||||
raise RuntimeError("close failed")
|
||||
|
||||
async def api_get_active_user(self):
|
||||
return {"userId": 1, "profile": {"displayName": "x"}}
|
||||
|
||||
async def send_chat_cmd(self, _cmd):
|
||||
return {"type": "cmdOk"}
|
||||
|
||||
monkeypatch.setattr(client_mod, "ChatApi", _BoomCloseApi)
|
||||
|
||||
c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"))
|
||||
|
||||
async def go():
|
||||
with pytest.raises(RuntimeError, match="close failed"):
|
||||
async with c:
|
||||
pass
|
||||
# _api must be None despite close() raising
|
||||
assert c._api is None, "Client._api leaked after __aexit__ close() raised"
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
_ = c.api
|
||||
# Re-enter must work
|
||||
try:
|
||||
async with c:
|
||||
pass
|
||||
except RuntimeError:
|
||||
pass # close raises again, fine
|
||||
assert init_count[0] == 2, "re-entry didn't re-init the controller"
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_aenter_rolls_back_partial_init_on_post_start_failure(monkeypatch):
|
||||
"""If anything in __aenter__ raises after ChatApi.init succeeded — including
|
||||
_post_start — the controller must be closed. Otherwise the with-block isn't
|
||||
entered, __aexit__ never runs, and the FFI handle leaks."""
|
||||
import simplex_chat.client as client_mod
|
||||
|
||||
closed: list[str] = []
|
||||
started: list[bool] = [False]
|
||||
|
||||
class FakeChatApi:
|
||||
@classmethod
|
||||
async def init(cls, *_args, **_kwargs):
|
||||
return cls()
|
||||
|
||||
@property
|
||||
def started(self) -> bool:
|
||||
return started[0]
|
||||
|
||||
async def start_chat(self):
|
||||
started[0] = True
|
||||
|
||||
async def stop_chat(self):
|
||||
started[0] = False
|
||||
closed.append("stop")
|
||||
|
||||
async def close(self):
|
||||
closed.append("close")
|
||||
|
||||
# Stub the bits _ensure_active_user / _maybe_sync_profile reach for.
|
||||
async def api_get_active_user(self):
|
||||
return {"userId": 1, "profile": {"displayName": "x"}}
|
||||
|
||||
async def send_chat_cmd(self, _cmd):
|
||||
return {"type": "cmdOk"}
|
||||
|
||||
monkeypatch.setattr(client_mod, "ChatApi", FakeChatApi)
|
||||
|
||||
class Boom(RuntimeError):
|
||||
pass
|
||||
|
||||
class BoomClient(Client):
|
||||
async def _post_start(self, user):
|
||||
raise Boom("kaboom")
|
||||
|
||||
c = BoomClient(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"))
|
||||
|
||||
async def go():
|
||||
with pytest.raises(Boom):
|
||||
async with c:
|
||||
pytest.fail("should not enter the with-block")
|
||||
|
||||
asyncio.run(go())
|
||||
assert closed == ["stop", "close"], f"controller not cleaned up: {closed}"
|
||||
assert c._api is None, "Client._api should be reset to None after rollback"
|
||||
|
||||
|
||||
def test_lookup_known_contact_propagates_non_command_errors():
|
||||
"""_lookup_known_contact must NOT mask transport / FFI errors as 'unknown
|
||||
link' — only ChatCommandError (malformed link, etc.) should fall through
|
||||
to the handshake path. Bare Exception catch would hide real bugs."""
|
||||
bot, api = _bot_with_fake_api()
|
||||
|
||||
class BoomError(RuntimeError):
|
||||
pass
|
||||
|
||||
async def boom(_user_id, _link):
|
||||
raise BoomError("FFI wedged")
|
||||
|
||||
api.api_connect_plan = boom # type: ignore[assignment]
|
||||
|
||||
async def go():
|
||||
with pytest.raises(BoomError):
|
||||
await bot._lookup_known_contact(1, "link")
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exception subclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_contact_already_exists_is_chat_command_error_subclass():
|
||||
"""Callers should be able to catch the base class to handle all command
|
||||
errors uniformly, and the specific subclass for targeted handling."""
|
||||
from simplex_chat import ChatCommandError, ContactAlreadyExistsError
|
||||
|
||||
assert issubclass(ContactAlreadyExistsError, ChatCommandError)
|
||||
|
||||
e = ContactAlreadyExistsError("x", {"type": "contactAlreadyExists"}) # type: ignore[arg-type]
|
||||
assert isinstance(e, ChatCommandError)
|
||||
assert e.response_type == "contactAlreadyExists"
|
||||
|
||||
|
||||
def test_chat_command_error_response_type_property():
|
||||
from simplex_chat import ChatCommandError
|
||||
|
||||
e = ChatCommandError("x", {"type": "someError"}) # type: ignore[arg-type]
|
||||
assert e.response_type == "someError"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# events() mutual exclusion with serve_forever
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_events_raises_if_already_serving():
|
||||
bot, _api = _bot_with_fake_api()
|
||||
# _serving=True is set by _bot_with_fake_api
|
||||
|
||||
async def go():
|
||||
with pytest.raises(RuntimeError, match="mutually exclusive"):
|
||||
async for _ in bot.events():
|
||||
pass
|
||||
|
||||
asyncio.run(go())
|
||||
@@ -3,7 +3,7 @@ import re
|
||||
from simplex_chat.filters import compile_message_filter
|
||||
|
||||
|
||||
def _msg(content_type="text", text=None, chat_type="direct", group_id=None):
|
||||
def _msg(content_type="text", text=None, chat_type="direct", group_id=None, contact_id=None):
|
||||
"""Build a minimal mock Message-like object for filter testing."""
|
||||
|
||||
class M:
|
||||
@@ -11,12 +11,12 @@ def _msg(content_type="text", text=None, chat_type="direct", group_id=None):
|
||||
|
||||
m = M()
|
||||
m.content = {"type": content_type, "text": text} if text is not None else {"type": content_type}
|
||||
m.chat_item = {
|
||||
"chatInfo": {
|
||||
"type": chat_type,
|
||||
**({"groupInfo": {"groupId": group_id}} if chat_type == "group" else {}),
|
||||
}
|
||||
}
|
||||
chat_info: dict = {"type": chat_type}
|
||||
if chat_type == "group":
|
||||
chat_info["groupInfo"] = {"groupId": group_id}
|
||||
elif chat_type == "direct" and contact_id is not None:
|
||||
chat_info["contact"] = {"contactId": contact_id}
|
||||
m.chat_item = {"chatInfo": chat_info}
|
||||
return m
|
||||
|
||||
|
||||
@@ -81,3 +81,23 @@ def test_group_id_tuple_or():
|
||||
f = compile_message_filter({"group_id": (1, 2, 3)})
|
||||
assert f(_msg(chat_type="group", group_id=2))
|
||||
assert not f(_msg(chat_type="group", group_id=99))
|
||||
|
||||
|
||||
def test_contact_id_filter():
|
||||
f = compile_message_filter({"contact_id": 7})
|
||||
assert f(_msg(chat_type="direct", contact_id=7))
|
||||
assert not f(_msg(chat_type="direct", contact_id=99))
|
||||
assert not f(_msg(chat_type="group", group_id=7))
|
||||
|
||||
|
||||
def test_contact_id_tuple_or():
|
||||
f = compile_message_filter({"contact_id": (1, 2, 3)})
|
||||
assert f(_msg(chat_type="direct", contact_id=2))
|
||||
assert not f(_msg(chat_type="direct", contact_id=99))
|
||||
|
||||
|
||||
def test_contact_id_combined_with_content_type():
|
||||
f = compile_message_filter({"content_type": "text", "contact_id": 5})
|
||||
assert f(_msg(content_type="text", chat_type="direct", contact_id=5))
|
||||
assert not f(_msg(content_type="image", chat_type="direct", contact_id=5))
|
||||
assert not f(_msg(content_type="text", chat_type="direct", contact_id=99))
|
||||
|
||||
Reference in New Issue
Block a user