mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-01 17:58:37 +00:00
core, python: fix refused renames, extend the client API (#7379)
* core: do not commit refused name changes * feat(python): add error base and missing commands * feat(python): let callers drive startup themselves * style(python): satisfy the linter, skip generated types * feat(python): expose the message of a command error * core: update query plans * core: fix the batch limit parse error on GHC 8.10 * feat(python): reject profile images no client can render
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
"""ChatApi commands and error classification, without the native controller.
|
||||
|
||||
`ChatApi` only touches the FFI through `send_chat_cmd`, so replacing that one
|
||||
method exercises every wrapper: the command string it builds and the response
|
||||
shape it accepts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from simplex_chat import ChatApi, ChatAPIError, ChatCommandError, ChatError
|
||||
|
||||
|
||||
class FakeCtrl(ChatApi):
|
||||
"""ChatApi with the FFI call replaced by a scripted response."""
|
||||
|
||||
def __init__(self, response: Any = None, raises: Exception | None = None) -> None:
|
||||
super().__init__(ctrl=1)
|
||||
self.response = response
|
||||
self.raises = raises
|
||||
self.sent: list[str] = []
|
||||
|
||||
async def send_chat_cmd(self, cmd: str) -> Any:
|
||||
self.sent.append(cmd)
|
||||
if self.raises is not None:
|
||||
raise self.raises
|
||||
return self.response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Error hierarchy
|
||||
# ---------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_both_command_failures_share_one_base():
|
||||
# The two are raised from different layers for the same kind of failure;
|
||||
# callers should not have to name both.
|
||||
assert issubclass(ChatAPIError, ChatError)
|
||||
assert issubclass(ChatCommandError, ChatError)
|
||||
|
||||
|
||||
def test_store_error_type_reads_the_nested_tag():
|
||||
e = ChatAPIError("x", {"type": "errorStore", "storeError": {"type": "duplicateName"}})
|
||||
assert e.store_error_type == "duplicateName"
|
||||
assert e.error_type is None
|
||||
|
||||
|
||||
def test_error_type_reads_the_nested_tag():
|
||||
e = ChatAPIError("x", {"type": "error", "errorType": {"type": "noActiveUser"}})
|
||||
assert e.error_type == "noActiveUser"
|
||||
assert e.store_error_type is None
|
||||
|
||||
|
||||
def test_command_error_carries_the_message():
|
||||
# The tag is always "commandError"; the message is the whole content.
|
||||
e = ChatAPIError(
|
||||
"x", {"type": "error", "errorType": {"type": "commandError", "message": "name too long"}}
|
||||
)
|
||||
assert e.command_error == "name too long"
|
||||
|
||||
|
||||
def test_command_error_of_another_failure():
|
||||
e = ChatAPIError("x", {"type": "errorStore", "storeError": {"type": "duplicateName"}})
|
||||
assert e.command_error is None
|
||||
|
||||
|
||||
def test_error_tags_of_an_unrelated_error():
|
||||
e = ChatAPIError("x", {"type": "errorAgent", "agentError": {"type": "CRITICAL"}})
|
||||
assert e.error_type is None
|
||||
assert e.store_error_type is None
|
||||
|
||||
|
||||
def test_error_tags_without_a_chat_error():
|
||||
# Raised when the controller returns something that is not valid JSON-RPC.
|
||||
e = ChatAPIError("invalid chat command result")
|
||||
assert e.error_type is None
|
||||
assert e.store_error_type is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Errors surfaced as absence
|
||||
# ---------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_missing_address_reads_as_none():
|
||||
api = FakeCtrl(
|
||||
raises=ChatAPIError(
|
||||
"x", {"type": "errorStore", "storeError": {"type": "userContactLinkNotFound"}}
|
||||
)
|
||||
)
|
||||
assert await api.api_get_user_address(1) is None
|
||||
|
||||
|
||||
async def test_another_store_error_still_raises():
|
||||
api = FakeCtrl(
|
||||
raises=ChatAPIError("x", {"type": "errorStore", "storeError": {"type": "dBBusyError"}})
|
||||
)
|
||||
with pytest.raises(ChatAPIError):
|
||||
await api.api_get_user_address(1)
|
||||
|
||||
|
||||
async def test_no_active_user_reads_as_none():
|
||||
api = FakeCtrl(
|
||||
raises=ChatAPIError("x", {"type": "error", "errorType": {"type": "noActiveUser"}})
|
||||
)
|
||||
assert await api.api_get_active_user() is None
|
||||
|
||||
|
||||
async def test_another_error_from_the_user_query_still_raises():
|
||||
api = FakeCtrl(
|
||||
raises=ChatAPIError("x", {"type": "error", "errorType": {"type": "invalidConnReq"}})
|
||||
)
|
||||
with pytest.raises(ChatAPIError):
|
||||
await api.api_get_active_user()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Member contacts
|
||||
# ---------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_accept_member_contact():
|
||||
contact = {"contactId": 7}
|
||||
api = FakeCtrl({"type": "memberContactAccepted", "contact": contact})
|
||||
assert await api.api_accept_member_contact(7) is contact
|
||||
assert api.sent == ["/_accept member contact @7"]
|
||||
|
||||
|
||||
async def test_accept_member_contact_rejected():
|
||||
# The core answers a second accept with a command error, not a contact.
|
||||
api = FakeCtrl({"type": "chatCmdError"})
|
||||
with pytest.raises(ChatCommandError):
|
||||
await api.api_accept_member_contact(7)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Custom data
|
||||
# ---------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_merge_contact_custom_data_keeps_other_keys():
|
||||
api = FakeCtrl({"type": "cmdOk"})
|
||||
contact = {"contactId": 4, "customData": {"other": 1}}
|
||||
await api.api_merge_contact_custom_data(contact, "mine", {"roster": "active"})
|
||||
assert api.sent == ['/_set custom @4 {"other": 1, "mine": {"roster": "active"}}']
|
||||
|
||||
|
||||
async def test_merge_contact_custom_data_removing_the_last_key_clears_the_column():
|
||||
api = FakeCtrl({"type": "cmdOk"})
|
||||
contact = {"contactId": 4, "customData": {"mine": 1}}
|
||||
await api.api_merge_contact_custom_data(contact, "mine", None)
|
||||
assert api.sent == ["/_set custom @4"]
|
||||
|
||||
|
||||
async def test_merge_group_custom_data_keeps_other_keys():
|
||||
api = FakeCtrl({"type": "cmdOk"})
|
||||
group = {"groupId": 9, "customData": {"other": 1}}
|
||||
await api.api_merge_group_custom_data(group, "mine", {"rostered": True})
|
||||
assert api.sent == ['/_set custom #9 {"other": 1, "mine": {"rostered": true}}']
|
||||
|
||||
|
||||
async def test_merge_group_custom_data_on_a_group_with_no_custom_data():
|
||||
api = FakeCtrl({"type": "cmdOk"})
|
||||
await api.api_merge_group_custom_data({"groupId": 9}, "mine", 1)
|
||||
assert api.sent == ['/_set custom #9 {"mine": 1}']
|
||||
|
||||
|
||||
async def test_a_failed_custom_data_write_raises():
|
||||
api = FakeCtrl({"type": "chatCmdError"})
|
||||
with pytest.raises(ChatCommandError):
|
||||
await api.api_merge_group_custom_data({"groupId": 9}, "mine", 1)
|
||||
Reference in New Issue
Block a user