mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-02 20:00:04 +00:00
simplex-chat-python: add python library (#6954)
* docs: simplex-chat-python design and implementation plan * bots: Python wire types codegen * simplex-chat-python: package scaffold * simplex-chat-python: native libsimplex loader * simplex-chat-python: async FFI wrappers * simplex-chat-python: ChatApi with 49 api methods * simplex-chat-python: Bot class with decorators and dispatch * simplex-chat-python: install CLI, example bot, README * simplex-chat-python: audit fixes * bots: regenerate API docs and types Catches up the markdown, TypeScript and Python codegen outputs with two upstream schema changes: - APIConnectPlan.connectionLink became optional (from sh/python-lib audit fixes); cmdString and EBNF syntax now reflect optional parameter. - APIAddGroupRelays command and CRGroupRelaysAdded/CRGroupRelaysAddFailed responses added in #6917 (relay management). The TS and markdown outputs were regenerated when #6917 landed but the Python types module only got the new entries with this regeneration. * core: refresh SQLite query plans after relay_inactive_at migration The M20260507_relay_inactive_at migration (#6917 / #6952) shifted the query plans that 'Save query plans' verifies. Regenerated via the test that owns those snapshots; no behavioral change. * bots: keep APIConnectPlan connectionLink as required parameter The prior audit-fixes commit changed the syntax expression to `Optional ...` because the Haskell field is `connectionLink :: Maybe AConnectionLink`. That misrepresents the API contract: the `Maybe` is purely an internal signal for link-parsing failure (the handler returns `CEInvalidConnReq` on `Nothing`), not API-level optionality. Callers MUST always pass a connection link. Revert the syntax expression to `Param "connectionLink"` and add a comment so the intent is preserved next time someone audits. Regenerates COMMANDS.md, commands.ts and _commands.py to match.
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
import pytest
|
||||
|
||||
from simplex_chat import Bot, BotCommand, BotProfile, Middleware, SqliteDb
|
||||
from simplex_chat.api import ChatApi
|
||||
|
||||
|
||||
def _bot() -> Bot:
|
||||
return Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"))
|
||||
|
||||
|
||||
def test_decorator_registers_message_handler():
|
||||
bot = _bot()
|
||||
|
||||
@bot.on_message(content_type="text")
|
||||
async def h(msg):
|
||||
pass
|
||||
|
||||
assert len(bot._message_handlers) == 1
|
||||
|
||||
|
||||
def test_decorator_registers_command_handler():
|
||||
bot = _bot()
|
||||
|
||||
@bot.on_command("ping")
|
||||
async def h(msg, cmd):
|
||||
pass
|
||||
|
||||
assert len(bot._command_handlers) == 1
|
||||
assert bot._command_handlers[0][0] == ("ping",)
|
||||
|
||||
|
||||
def test_decorator_registers_event_handler():
|
||||
bot = _bot()
|
||||
|
||||
@bot.on_event("newChatItems")
|
||||
async def h(evt):
|
||||
pass
|
||||
|
||||
assert "newChatItems" in bot._event_handlers
|
||||
assert len(bot._event_handlers["newChatItems"]) == 1
|
||||
|
||||
|
||||
def test_api_property_raises_before_init():
|
||||
bot = _bot()
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
_ = bot.api
|
||||
|
||||
|
||||
def test_command_keyword_tuple():
|
||||
bot = _bot()
|
||||
|
||||
@bot.on_command(("p", "ping"))
|
||||
async def h(msg, cmd):
|
||||
pass
|
||||
|
||||
assert bot._command_handlers[0][0] == ("p", "ping")
|
||||
|
||||
|
||||
def test_bot_profile_to_wire_default():
|
||||
"""use_bot_profile=True (default) sets peerType=bot and disables calls/voice."""
|
||||
bot = _bot()
|
||||
p = bot._bot_profile_to_wire()
|
||||
assert p["displayName"] == "x"
|
||||
assert p.get("peerType") == "bot"
|
||||
prefs = p.get("preferences") or {}
|
||||
assert prefs.get("calls", {}).get("allow") == "no"
|
||||
assert prefs.get("voice", {}).get("allow") == "no"
|
||||
assert prefs.get("files", {}).get("allow") == "no" # allow_files defaults to False
|
||||
|
||||
|
||||
def test_bot_profile_to_wire_allow_files():
|
||||
bot = Bot(
|
||||
profile=BotProfile(display_name="x"),
|
||||
db=SqliteDb(file_prefix="/tmp/test"),
|
||||
allow_files=True,
|
||||
)
|
||||
prefs = bot._bot_profile_to_wire().get("preferences") or {}
|
||||
assert prefs.get("files", {}).get("allow") == "yes"
|
||||
|
||||
|
||||
def test_bot_profile_to_wire_with_commands():
|
||||
bot = Bot(
|
||||
profile=BotProfile(display_name="x"),
|
||||
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 []
|
||||
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()
|
||||
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_dispatch_message_first_match_wins():
|
||||
"""Two matching message handlers — only the first registered fires."""
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
bot = _bot()
|
||||
calls: list[str] = []
|
||||
|
||||
@bot.on_message(content_type="text", text=re.compile(r"^\d+$"))
|
||||
async def number(_msg):
|
||||
calls.append("number")
|
||||
|
||||
@bot.on_message(content_type="text")
|
||||
async def fallback(_msg):
|
||||
calls.append("fallback")
|
||||
|
||||
class M:
|
||||
pass
|
||||
|
||||
m = M()
|
||||
m.content = {"type": "text", "text": "42"}
|
||||
m.chat_item = {
|
||||
"chatItem": {
|
||||
"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "42"}}
|
||||
},
|
||||
"chatInfo": {"type": "direct"},
|
||||
}
|
||||
m.text = "42"
|
||||
|
||||
asyncio.run(bot._dispatch_message(m)) # type: ignore[arg-type]
|
||||
assert calls == ["number"], f"expected only 'number' for '42', got {calls}"
|
||||
|
||||
|
||||
def test_dispatch_message_falls_to_second_when_first_doesnt_match():
|
||||
"""If the first handler's filter doesn't match, the second one fires."""
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
bot = _bot()
|
||||
calls: list[str] = []
|
||||
|
||||
@bot.on_message(content_type="text", text=re.compile(r"^\d+$"))
|
||||
async def number(_msg):
|
||||
calls.append("number")
|
||||
|
||||
@bot.on_message(content_type="text")
|
||||
async def fallback(_msg):
|
||||
calls.append("fallback")
|
||||
|
||||
class M:
|
||||
pass
|
||||
|
||||
m = M()
|
||||
m.content = {"type": "text", "text": "hello"}
|
||||
m.chat_item = {
|
||||
"chatItem": {
|
||||
"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "hello"}}
|
||||
},
|
||||
"chatInfo": {"type": "direct"},
|
||||
}
|
||||
m.text = "hello"
|
||||
|
||||
asyncio.run(bot._dispatch_message(m)) # type: ignore[arg-type]
|
||||
assert calls == ["fallback"], f"expected 'fallback' for 'hello', got {calls}"
|
||||
|
||||
|
||||
def test_register_log_handlers_idempotent():
|
||||
"""Calling _register_log_handlers twice doesn't duplicate handlers."""
|
||||
bot = Bot(
|
||||
profile=BotProfile(display_name="x"),
|
||||
db=SqliteDb(file_prefix="/tmp/test"),
|
||||
log_contacts=True,
|
||||
log_network=True,
|
||||
)
|
||||
bot._register_log_handlers()
|
||||
counts1 = {tag: len(hs) for tag, hs in bot._event_handlers.items()}
|
||||
bot._register_log_handlers()
|
||||
counts2 = {tag: len(hs) for tag, hs in bot._event_handlers.items()}
|
||||
assert counts1 == counts2, f"handler count changed across calls: {counts1} -> {counts2}"
|
||||
|
||||
|
||||
def test_default_error_handlers_always_registered():
|
||||
"""messageError/chatError/chatErrors get default loggers regardless of opts."""
|
||||
bot = Bot(
|
||||
profile=BotProfile(display_name="x"),
|
||||
db=SqliteDb(file_prefix="/tmp/test"),
|
||||
log_contacts=False,
|
||||
log_network=False,
|
||||
)
|
||||
bot._register_log_handlers()
|
||||
assert "messageError" in bot._event_handlers
|
||||
assert "chatError" in bot._event_handlers
|
||||
assert "chatErrors" in bot._event_handlers
|
||||
|
||||
|
||||
def test_dispatch_command_suppresses_matching_message_handlers():
|
||||
"""A `/help` message routed to a command handler must NOT also fire the
|
||||
generic on_message text handler."""
|
||||
import asyncio
|
||||
|
||||
bot = _bot()
|
||||
calls: list[str] = []
|
||||
|
||||
@bot.on_message(content_type="text")
|
||||
async def fallback(_msg):
|
||||
calls.append("message")
|
||||
|
||||
@bot.on_command("help")
|
||||
async def help_cmd(_msg, _cmd):
|
||||
calls.append("command")
|
||||
|
||||
# Build a minimal Message-shaped object (handlers only inspect chat_item / text).
|
||||
class M:
|
||||
pass
|
||||
|
||||
m = M()
|
||||
m.content = {"type": "text", "text": "/help"}
|
||||
m.chat_item = {
|
||||
"chatItem": {
|
||||
"content": {
|
||||
"type": "rcvMsgContent",
|
||||
"msgContent": {"type": "text", "text": "/help"},
|
||||
}
|
||||
},
|
||||
"chatInfo": {"type": "direct"},
|
||||
}
|
||||
m.text = "/help"
|
||||
|
||||
asyncio.run(bot._dispatch_message(m)) # type: ignore[arg-type]
|
||||
assert calls == ["command"], f"expected only 'command' to fire for /help, got {calls}"
|
||||
|
||||
|
||||
def test_dispatch_unknown_command_falls_through_to_message_handlers():
|
||||
"""A `/unknown` slash-command with no handler should still fire on_message."""
|
||||
import asyncio
|
||||
|
||||
bot = _bot()
|
||||
calls: list[str] = []
|
||||
|
||||
@bot.on_message(content_type="text")
|
||||
async def fallback(_msg):
|
||||
calls.append("message")
|
||||
|
||||
@bot.on_command("help")
|
||||
async def help_cmd(_msg, _cmd):
|
||||
calls.append("command")
|
||||
|
||||
class M:
|
||||
pass
|
||||
|
||||
m = M()
|
||||
m.content = {"type": "text", "text": "/unknown"}
|
||||
m.chat_item = {
|
||||
"chatItem": {
|
||||
"content": {
|
||||
"type": "rcvMsgContent",
|
||||
"msgContent": {"type": "text", "text": "/unknown"},
|
||||
}
|
||||
},
|
||||
"chatInfo": {"type": "direct"},
|
||||
}
|
||||
m.text = "/unknown"
|
||||
|
||||
asyncio.run(bot._dispatch_message(m)) # type: ignore[arg-type]
|
||||
assert calls == ["message"], f"expected message fallback to fire for /unknown, got {calls}"
|
||||
|
||||
|
||||
def test_chat_api_status_properties():
|
||||
"""`initialized` and `started` reflect lifecycle state without invoking the FFI."""
|
||||
api = ChatApi(ctrl=12345)
|
||||
assert api.initialized is True
|
||||
assert api.started is False
|
||||
assert api.ctrl == 12345
|
||||
# Simulate close: ctrl wiped, both properties false.
|
||||
api._ctrl = None
|
||||
api._started = False
|
||||
assert api.initialized is False
|
||||
assert api.started is False
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
_ = api.ctrl
|
||||
|
||||
|
||||
def test_log_contacts_registers_handlers():
|
||||
bot = Bot(
|
||||
profile=BotProfile(display_name="x"),
|
||||
db=SqliteDb(file_prefix="/tmp/test"),
|
||||
log_contacts=True,
|
||||
log_network=False,
|
||||
)
|
||||
bot._register_log_handlers()
|
||||
assert "contactConnected" in bot._event_handlers
|
||||
assert "contactDeletedByContact" in bot._event_handlers
|
||||
assert "hostConnected" not in bot._event_handlers
|
||||
|
||||
|
||||
def test_log_network_registers_handlers():
|
||||
bot = Bot(
|
||||
profile=BotProfile(display_name="x"),
|
||||
db=SqliteDb(file_prefix="/tmp/test"),
|
||||
log_contacts=False,
|
||||
log_network=True,
|
||||
)
|
||||
bot._register_log_handlers()
|
||||
assert "hostConnected" in bot._event_handlers
|
||||
assert "hostDisconnected" in bot._event_handlers
|
||||
assert "subscriptionStatus" in bot._event_handlers
|
||||
assert "contactConnected" not in bot._event_handlers
|
||||
|
||||
|
||||
def test_middleware_registration_and_invocation_order():
|
||||
"""Middleware registered first wraps middleware registered later (outer first)."""
|
||||
bot = _bot()
|
||||
calls: list[str] = []
|
||||
|
||||
class Outer(Middleware):
|
||||
async def __call__(self, handler, message, data):
|
||||
calls.append("outer-before")
|
||||
await handler(message, data)
|
||||
calls.append("outer-after")
|
||||
|
||||
class Inner(Middleware):
|
||||
async def __call__(self, handler, message, data):
|
||||
calls.append("inner-before")
|
||||
await handler(message, data)
|
||||
calls.append("inner-after")
|
||||
|
||||
bot.use(Outer())
|
||||
bot.use(Inner())
|
||||
assert len(bot._middleware) == 2
|
||||
|
||||
async def handler(msg):
|
||||
calls.append("handler")
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(bot._invoke_with_middleware(handler, message=object())) # type: ignore[arg-type]
|
||||
assert calls == [
|
||||
"outer-before",
|
||||
"inner-before",
|
||||
"handler",
|
||||
"inner-after",
|
||||
"outer-after",
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Sanity checks on auto-generated wire types — catches generator regressions."""
|
||||
|
||||
import typing
|
||||
|
||||
from simplex_chat.types import CC, CEvt, CR, T
|
||||
|
||||
|
||||
def test_types_module_imports():
|
||||
"""Every generated module imports cleanly with no SyntaxError."""
|
||||
assert T is not None and CC is not None and CR is not None and CEvt is not None
|
||||
|
||||
|
||||
def test_chat_type_is_literal_enum():
|
||||
"""ChatType should be a Literal of expected member set."""
|
||||
args = typing.get_args(T.ChatType)
|
||||
assert "direct" in args
|
||||
assert "group" in args
|
||||
assert "local" in args
|
||||
|
||||
|
||||
def test_known_command_has_cmd_string():
|
||||
s = CC.APICreateMyAddress_cmd_string({"userId": 1})
|
||||
assert s == "/_address 1"
|
||||
|
||||
|
||||
def test_chat_response_tag_alias_present():
|
||||
"""ChatResponse_Tag union of literals exists."""
|
||||
assert hasattr(CR, "ChatResponse_Tag")
|
||||
|
||||
|
||||
def test_chat_event_tag_alias_present():
|
||||
"""ChatEvent_Tag exists; covers the on_event Literal annotation."""
|
||||
assert hasattr(CEvt, "ChatEvent_Tag")
|
||||
args = typing.get_args(CEvt.ChatEvent_Tag)
|
||||
assert "newChatItems" in args
|
||||
|
||||
|
||||
def test_chat_ref_cmd_string_direct():
|
||||
"""Sanity check the codegen fix for ChatRef-bearing commands."""
|
||||
assert T.ChatRef_cmd_string({"chatType": "direct", "chatId": 7}) == "@7"
|
||||
assert T.ChatRef_cmd_string({"chatType": "group", "chatId": 42}) == "#42"
|
||||
@@ -0,0 +1,83 @@
|
||||
import re
|
||||
|
||||
from simplex_chat.filters import compile_message_filter
|
||||
|
||||
|
||||
def _msg(content_type="text", text=None, chat_type="direct", group_id=None):
|
||||
"""Build a minimal mock Message-like object for filter testing."""
|
||||
|
||||
class M:
|
||||
pass
|
||||
|
||||
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 {}),
|
||||
}
|
||||
}
|
||||
return m
|
||||
|
||||
|
||||
def test_no_filters_matches_all():
|
||||
f = compile_message_filter({})
|
||||
assert f(_msg(content_type="text"))
|
||||
assert f(_msg(content_type="image"))
|
||||
|
||||
|
||||
def test_content_type_singular():
|
||||
f = compile_message_filter({"content_type": "text"})
|
||||
assert f(_msg(content_type="text"))
|
||||
assert not f(_msg(content_type="image"))
|
||||
|
||||
|
||||
def test_content_type_tuple_or():
|
||||
f = compile_message_filter({"content_type": ("text", "image")})
|
||||
assert f(_msg(content_type="text"))
|
||||
assert f(_msg(content_type="image"))
|
||||
assert not f(_msg(content_type="voice"))
|
||||
|
||||
|
||||
def test_text_exact():
|
||||
f = compile_message_filter({"text": "hello"})
|
||||
assert f(_msg(text="hello"))
|
||||
assert not f(_msg(text="world"))
|
||||
|
||||
|
||||
def test_text_regex():
|
||||
f = compile_message_filter({"text": re.compile(r"^\d+$")})
|
||||
assert f(_msg(text="123"))
|
||||
assert not f(_msg(text="abc"))
|
||||
|
||||
|
||||
def test_when_callable():
|
||||
f = compile_message_filter({"when": lambda m: m.content["type"] == "voice"})
|
||||
assert f(_msg(content_type="voice"))
|
||||
assert not f(_msg(content_type="text"))
|
||||
|
||||
|
||||
def test_combined_and():
|
||||
f = compile_message_filter({"content_type": "text", "text": re.compile(r"\d")})
|
||||
assert f(_msg(content_type="text", text="abc123"))
|
||||
assert not f(_msg(content_type="text", text="abc"))
|
||||
assert not f(_msg(content_type="image"))
|
||||
|
||||
|
||||
def test_chat_type_filter():
|
||||
f = compile_message_filter({"chat_type": "group"})
|
||||
assert f(_msg(chat_type="group", group_id=1))
|
||||
assert not f(_msg(chat_type="direct"))
|
||||
|
||||
|
||||
def test_group_id_filter():
|
||||
f = compile_message_filter({"group_id": 42})
|
||||
assert f(_msg(chat_type="group", group_id=42))
|
||||
assert not f(_msg(chat_type="group", group_id=99))
|
||||
assert not f(_msg(chat_type="direct"))
|
||||
|
||||
|
||||
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))
|
||||
@@ -0,0 +1,92 @@
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from simplex_chat._native import _cache_root, _resolve_libs_dir, _download
|
||||
|
||||
|
||||
def test_cache_root_linux(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
assert _cache_root() == tmp_path / "simplex-chat"
|
||||
|
||||
|
||||
def test_cache_root_macos(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
assert _cache_root() == tmp_path / "Library" / "Caches" / "simplex-chat"
|
||||
|
||||
|
||||
def test_override_via_env(tmp_path, monkeypatch):
|
||||
# _resolve_libs_dir intentionally does not validate the override directory —
|
||||
# it returns it verbatim; the eventual ctypes.CDLL call surfaces any mistake.
|
||||
monkeypatch.setenv("SIMPLEX_LIBS_DIR", str(tmp_path))
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
assert _resolve_libs_dir("sqlite") == tmp_path
|
||||
|
||||
|
||||
def test_resolve_downloads_when_missing(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64")
|
||||
|
||||
called = {}
|
||||
|
||||
def fake_download(target_root: Path, backend: str) -> None:
|
||||
called["target"] = target_root
|
||||
called["backend"] = backend
|
||||
target_root.mkdir(parents=True, exist_ok=True)
|
||||
(target_root / "libsimplex.so").touch()
|
||||
|
||||
monkeypatch.setattr("simplex_chat._native._download", fake_download)
|
||||
libs_dir = _resolve_libs_dir("sqlite")
|
||||
assert libs_dir == tmp_path / "simplex-chat" / "v6.5.1" / "sqlite"
|
||||
assert called["backend"] == "sqlite"
|
||||
assert (libs_dir / "libsimplex.so").exists()
|
||||
|
||||
|
||||
def test_resolve_uses_cache_on_second_call(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
cached = tmp_path / "simplex-chat" / "v6.5.1" / "sqlite"
|
||||
cached.mkdir(parents=True)
|
||||
(cached / "libsimplex.so").touch()
|
||||
# Should NOT call _download — use the cached file.
|
||||
monkeypatch.setattr(
|
||||
"simplex_chat._native._download", lambda *a: pytest.fail("download should not be called")
|
||||
)
|
||||
assert _resolve_libs_dir("sqlite") == cached
|
||||
|
||||
|
||||
def test_postgres_on_macos_rejected(monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "macos-aarch64")
|
||||
with pytest.raises(RuntimeError, match="postgres.*linux-x86_64"):
|
||||
_resolve_libs_dir("postgres")
|
||||
|
||||
|
||||
def test_atomic_install(tmp_path, monkeypatch):
|
||||
"""Build a fake libs zip, mock _stream_to_file, verify extraction + atomic rename."""
|
||||
# Build zip: libs/libsimplex.so + libs/libHS-stub.so
|
||||
src = tmp_path / "src" / "libs"
|
||||
src.mkdir(parents=True)
|
||||
(src / "libsimplex.so").write_text("fake-so")
|
||||
(src / "libHS-stub.so").write_text("fake-hs")
|
||||
zip_path = tmp_path / "fake-libs.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
for f in src.iterdir():
|
||||
zf.write(f, f"libs/{f.name}")
|
||||
|
||||
def fake_stream(url, dest, *, timeout=60.0):
|
||||
import shutil
|
||||
|
||||
shutil.copy(zip_path, dest)
|
||||
|
||||
monkeypatch.setattr("simplex_chat._native._stream_to_file", fake_stream)
|
||||
monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64")
|
||||
|
||||
target = tmp_path / "out"
|
||||
_download(target, "sqlite")
|
||||
assert (target / "libsimplex.so").read_text() == "fake-so"
|
||||
assert (target / "libHS-stub.so").read_text() == "fake-hs"
|
||||
@@ -0,0 +1,55 @@
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
from simplex_chat._native import _platform_tag, _libs_url, _libname
|
||||
|
||||
|
||||
@patch("sys.platform", "linux")
|
||||
@patch("platform.machine", return_value="x86_64")
|
||||
def test_platform_linux_x64(_):
|
||||
assert _platform_tag() == "linux-x86_64"
|
||||
|
||||
|
||||
@patch("sys.platform", "darwin")
|
||||
@patch("platform.machine", return_value="arm64")
|
||||
def test_platform_macos_arm64(_):
|
||||
assert _platform_tag() == "macos-aarch64"
|
||||
|
||||
|
||||
@patch("sys.platform", "win32")
|
||||
@patch("platform.machine", return_value="AMD64")
|
||||
def test_platform_windows_x64(_):
|
||||
assert _platform_tag() == "windows-x86_64"
|
||||
|
||||
|
||||
@patch("sys.platform", "freebsd")
|
||||
@patch("platform.machine", return_value="x86_64")
|
||||
def test_platform_unsupported(_):
|
||||
with pytest.raises(RuntimeError, match="Unsupported"):
|
||||
_platform_tag()
|
||||
|
||||
|
||||
def test_libname_per_platform():
|
||||
with patch("sys.platform", "linux"):
|
||||
assert _libname() == "libsimplex.so"
|
||||
with patch("sys.platform", "darwin"):
|
||||
assert _libname() == "libsimplex.dylib"
|
||||
with patch("sys.platform", "win32"):
|
||||
assert _libname() == "libsimplex.dll"
|
||||
|
||||
|
||||
@patch("simplex_chat._native._platform_tag", return_value="linux-x86_64")
|
||||
def test_url_sqlite(_):
|
||||
assert (
|
||||
_libs_url("sqlite")
|
||||
== "https://github.com/simplex-chat/simplex-chat-libs/releases/download/"
|
||||
"v6.5.1/simplex-chat-libs-linux-x86_64.zip"
|
||||
)
|
||||
|
||||
|
||||
@patch("simplex_chat._native._platform_tag", return_value="linux-x86_64")
|
||||
def test_url_postgres(_):
|
||||
assert (
|
||||
_libs_url("postgres")
|
||||
== "https://github.com/simplex-chat/simplex-chat-libs/releases/download/"
|
||||
"v6.5.1/simplex-chat-libs-linux-x86_64-postgres.zip"
|
||||
)
|
||||
@@ -0,0 +1,175 @@
|
||||
from simplex_chat import util
|
||||
|
||||
|
||||
def test_chat_info_ref_direct():
|
||||
ci = {"type": "direct", "contact": {"contactId": 7}}
|
||||
assert util.chat_info_ref(ci) == {"chatType": "direct", "chatId": 7}
|
||||
|
||||
|
||||
def test_chat_info_ref_group():
|
||||
ci = {"type": "group", "groupInfo": {"groupId": 42}}
|
||||
assert util.chat_info_ref(ci) == {"chatType": "group", "chatId": 42}
|
||||
|
||||
|
||||
def test_chat_info_ref_group_with_member_support_scope():
|
||||
ci = {
|
||||
"type": "group",
|
||||
"groupInfo": {"groupId": 42},
|
||||
"groupChatScope": {"type": "memberSupport", "groupMember_": {"groupMemberId": 99}},
|
||||
}
|
||||
ref = util.chat_info_ref(ci)
|
||||
assert ref == {
|
||||
"chatType": "group",
|
||||
"chatId": 42,
|
||||
"chatScope": {"type": "memberSupport", "groupMemberId_": 99},
|
||||
}
|
||||
|
||||
|
||||
def test_chat_info_ref_group_with_member_support_scope_no_member():
|
||||
ci = {
|
||||
"type": "group",
|
||||
"groupInfo": {"groupId": 42},
|
||||
"groupChatScope": {"type": "memberSupport"},
|
||||
}
|
||||
ref = util.chat_info_ref(ci)
|
||||
# No groupMember_ → no groupMemberId_ in the wire scope.
|
||||
assert ref == {
|
||||
"chatType": "group",
|
||||
"chatId": 42,
|
||||
"chatScope": {"type": "memberSupport"},
|
||||
}
|
||||
|
||||
|
||||
def test_chat_info_ref_returns_none_for_non_targets():
|
||||
assert util.chat_info_ref({"type": "contactRequest"}) is None
|
||||
assert util.chat_info_ref({"type": "contactConnection"}) is None
|
||||
|
||||
|
||||
def test_chat_info_name_direct():
|
||||
ci = {"type": "direct", "contact": {"profile": {"displayName": "Alice"}}}
|
||||
assert util.chat_info_name(ci) == "@Alice"
|
||||
|
||||
|
||||
def test_chat_info_name_group():
|
||||
ci = {"type": "group", "groupInfo": {"groupProfile": {"displayName": "MyGroup"}}}
|
||||
assert util.chat_info_name(ci) == "#MyGroup"
|
||||
|
||||
|
||||
def test_chat_info_name_group_with_member_support():
|
||||
ci = {
|
||||
"type": "group",
|
||||
"groupInfo": {"groupProfile": {"displayName": "MyGroup"}},
|
||||
"groupChatScope": {
|
||||
"type": "memberSupport",
|
||||
"groupMember_": {"memberProfile": {"displayName": "Carol"}},
|
||||
},
|
||||
}
|
||||
assert util.chat_info_name(ci) == "#MyGroup(support Carol)"
|
||||
|
||||
|
||||
def test_chat_info_name_local():
|
||||
assert util.chat_info_name({"type": "local"}) == "private notes"
|
||||
|
||||
|
||||
def test_chat_info_name_contact_request():
|
||||
ci = {"type": "contactRequest", "contactRequest": {"profile": {"displayName": "Eve"}}}
|
||||
assert util.chat_info_name(ci) == "request from @Eve"
|
||||
|
||||
|
||||
def test_chat_info_name_contact_connection():
|
||||
assert util.chat_info_name({"type": "contactConnection", "contactConnection": {}}) == (
|
||||
"pending connection"
|
||||
)
|
||||
assert (
|
||||
util.chat_info_name({"type": "contactConnection", "contactConnection": {"localAlias": "X"}})
|
||||
== "pending connection (X)"
|
||||
)
|
||||
|
||||
|
||||
def test_sender_name_direct_uses_chat_name():
|
||||
ci = {"type": "direct", "contact": {"profile": {"displayName": "Alice"}}}
|
||||
chat_dir = {"type": "directRcv"}
|
||||
assert util.sender_name(ci, chat_dir) == "@Alice"
|
||||
|
||||
|
||||
def test_sender_name_group_appends_member():
|
||||
ci = {"type": "group", "groupInfo": {"groupProfile": {"displayName": "MyGroup"}}}
|
||||
chat_dir = {"type": "groupRcv", "groupMember": {"memberProfile": {"displayName": "Bob"}}}
|
||||
assert util.sender_name(ci, chat_dir) == "#MyGroup @Bob"
|
||||
|
||||
|
||||
def test_contact_address_str_prefers_short():
|
||||
assert util.contact_address_str({"connFullLink": "full", "connShortLink": "short"}) == "short"
|
||||
|
||||
|
||||
def test_contact_address_str_falls_back_to_full():
|
||||
assert util.contact_address_str({"connFullLink": "full"}) == "full"
|
||||
|
||||
|
||||
def test_from_local_profile_strips_extras_and_undefined():
|
||||
local = {
|
||||
"displayName": "x",
|
||||
"fullName": "X Y",
|
||||
"shortDescr": None,
|
||||
"image": "data:image/png;base64,...",
|
||||
"contactLink": None,
|
||||
"preferences": {},
|
||||
"peerType": "bot",
|
||||
"profileId": 99, # extra LocalProfile field
|
||||
"localAlias": "alias", # extra LocalProfile field
|
||||
}
|
||||
p = util.from_local_profile(local)
|
||||
assert p == {
|
||||
"displayName": "x",
|
||||
"fullName": "X Y",
|
||||
"image": "data:image/png;base64,...",
|
||||
"preferences": {},
|
||||
"peerType": "bot",
|
||||
}
|
||||
|
||||
|
||||
def test_ci_content_text_rcv():
|
||||
ci = {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "hello"}}}
|
||||
assert util.ci_content_text(ci) == "hello"
|
||||
|
||||
|
||||
def test_ci_content_text_snd():
|
||||
ci = {"content": {"type": "sndMsgContent", "msgContent": {"type": "text", "text": "world"}}}
|
||||
assert util.ci_content_text(ci) == "world"
|
||||
|
||||
|
||||
def test_ci_content_text_other():
|
||||
ci = {"content": {"type": "rcvGroupEvent"}}
|
||||
assert util.ci_content_text(ci) is None
|
||||
|
||||
|
||||
def test_ci_bot_command_match():
|
||||
ci = {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "/ping"}}}
|
||||
assert util.ci_bot_command(ci) == ("ping", "")
|
||||
|
||||
|
||||
def test_ci_bot_command_with_args():
|
||||
ci = {
|
||||
"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "/echo hi "}}
|
||||
}
|
||||
assert util.ci_bot_command(ci) == ("echo", "hi")
|
||||
|
||||
|
||||
def test_ci_bot_command_not_a_command():
|
||||
ci = {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "hello"}}}
|
||||
assert util.ci_bot_command(ci) is None
|
||||
|
||||
|
||||
def test_ci_bot_command_no_text():
|
||||
ci = {"content": {"type": "rcvGroupEvent"}}
|
||||
assert util.ci_bot_command(ci) is None
|
||||
|
||||
|
||||
def test_reaction_text_emoji():
|
||||
r = {"chatReaction": {"reaction": {"type": "emoji", "emoji": "🎉"}}}
|
||||
assert util.reaction_text(r) == "🎉"
|
||||
|
||||
|
||||
def test_reaction_text_tag():
|
||||
r = {"chatReaction": {"reaction": {"type": "unknown", "tag": "thumbs_up"}}}
|
||||
assert util.reaction_text(r) == "thumbs_up"
|
||||
Reference in New Issue
Block a user