mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-07-10 15:41:38 +00:00
487 lines
14 KiB
Python
487 lines
14 KiB
Python
# SPDX-License-Identifier: 0BSD
|
|
|
|
import base64
|
|
import json
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import MagicMock
|
|
|
|
import LXMF
|
|
|
|
from meshchatx.src.backend.lxmf_utils import (
|
|
compute_lxmf_conversation_unread_from_latest_row,
|
|
convert_db_lxmf_message_to_dict,
|
|
convert_lxmf_message_to_dict,
|
|
convert_lxmf_state_to_string,
|
|
lxmf_sidebar_preview_for_conversation_latest_row,
|
|
)
|
|
|
|
|
|
def test_convert_lxmf_message_to_dict_basic():
|
|
mock_msg = MagicMock(spec=LXMF.LXMessage)
|
|
mock_msg.hash = b"msg_hash"
|
|
mock_msg.source_hash = b"src_hash"
|
|
mock_msg.destination_hash = b"dst_hash"
|
|
mock_msg.incoming = True
|
|
mock_msg.state = LXMF.LXMessage.SENT
|
|
mock_msg.progress = 0.5
|
|
mock_msg.method = LXMF.LXMessage.DIRECT
|
|
mock_msg.delivery_attempts = 1
|
|
mock_msg.title = b"Test Title"
|
|
mock_msg.content = b"Test Content"
|
|
mock_msg.timestamp = 1234567890
|
|
mock_msg.rssi = -50
|
|
mock_msg.snr = 10
|
|
mock_msg.q = 3
|
|
mock_msg.get_fields.return_value = {}
|
|
|
|
result = convert_lxmf_message_to_dict(mock_msg)
|
|
|
|
assert result["hash"] == "6d73675f68617368"
|
|
assert result["title"] == "Test Title"
|
|
assert result["content"] == "Test Content"
|
|
assert result["progress"] == 50.0
|
|
assert result["state"] == "sent"
|
|
assert result["method"] == "direct"
|
|
|
|
|
|
def test_convert_lxmf_message_to_dict_with_attachments():
|
|
mock_msg = MagicMock(spec=LXMF.LXMessage)
|
|
mock_msg.hash = b"hash"
|
|
mock_msg.source_hash = b"src"
|
|
mock_msg.destination_hash = b"dst"
|
|
mock_msg.incoming = False
|
|
mock_msg.state = LXMF.LXMessage.DELIVERED
|
|
mock_msg.progress = 1.0
|
|
mock_msg.method = LXMF.LXMessage.PROPAGATED
|
|
mock_msg.delivery_attempts = 1
|
|
mock_msg.title = b""
|
|
mock_msg.content = b""
|
|
mock_msg.timestamp = 1234567890
|
|
mock_msg.rssi = None
|
|
mock_msg.snr = None
|
|
mock_msg.q = None
|
|
|
|
# Setup fields
|
|
fields = {
|
|
LXMF.FIELD_FILE_ATTACHMENTS: [("file1.txt", b"content1")],
|
|
LXMF.FIELD_IMAGE: ("png", b"image_data"),
|
|
LXMF.FIELD_AUDIO: ("voice", b"audio_data"),
|
|
}
|
|
mock_msg.get_fields.return_value = fields
|
|
|
|
result = convert_lxmf_message_to_dict(mock_msg)
|
|
|
|
assert result["fields"]["file_attachments"][0]["file_name"] == "file1.txt"
|
|
assert (
|
|
result["fields"]["file_attachments"][0]["file_bytes"]
|
|
== base64.b64encode(b"content1").decode()
|
|
)
|
|
assert result["fields"]["image"]["image_type"] == "png"
|
|
assert (
|
|
result["fields"]["image"]["image_bytes"]
|
|
== base64.b64encode(b"image_data").decode()
|
|
)
|
|
assert result["fields"]["audio"]["audio_mode"] == "voice"
|
|
assert (
|
|
result["fields"]["audio"]["audio_bytes"]
|
|
== base64.b64encode(b"audio_data").decode()
|
|
)
|
|
|
|
|
|
def test_convert_lxmf_state_to_string():
|
|
mock_msg = MagicMock()
|
|
|
|
states = {
|
|
LXMF.LXMessage.GENERATING: "generating",
|
|
LXMF.LXMessage.OUTBOUND: "outbound",
|
|
LXMF.LXMessage.SENDING: "sending",
|
|
LXMF.LXMessage.SENT: "sent",
|
|
LXMF.LXMessage.DELIVERED: "delivered",
|
|
LXMF.LXMessage.REJECTED: "rejected",
|
|
LXMF.LXMessage.CANCELLED: "cancelled",
|
|
LXMF.LXMessage.FAILED: "failed",
|
|
}
|
|
|
|
for state, expected in states.items():
|
|
mock_msg.state = state
|
|
assert convert_lxmf_state_to_string(mock_msg) == expected
|
|
|
|
|
|
def test_convert_db_lxmf_message_to_dict():
|
|
db_msg = {
|
|
"id": 1,
|
|
"hash": "hash_hex",
|
|
"source_hash": "src_hex",
|
|
"destination_hash": "dst_hex",
|
|
"is_incoming": 1,
|
|
"state": "delivered",
|
|
"progress": 100.0,
|
|
"method": "direct",
|
|
"delivery_attempts": 1,
|
|
"next_delivery_attempt_at": None,
|
|
"title": "Title",
|
|
"content": "Content",
|
|
"fields": json.dumps(
|
|
{
|
|
"image": {
|
|
"image_type": "jpg",
|
|
"image_bytes": base64.b64encode(b"img").decode(),
|
|
},
|
|
"audio": {
|
|
"audio_mode": "ogg",
|
|
"audio_bytes": base64.b64encode(b"audio").decode(),
|
|
},
|
|
"file_attachments": [
|
|
{
|
|
"file_name": "f.txt",
|
|
"file_bytes": base64.b64encode(b"file").decode(),
|
|
},
|
|
],
|
|
},
|
|
),
|
|
"timestamp": 1234567890,
|
|
"rssi": -60,
|
|
"snr": 5,
|
|
"quality": 2,
|
|
"is_spam": 0,
|
|
"path_hops_at_send": 4,
|
|
"path_interface_at_send": "RNode Interface",
|
|
"created_at": "2023-01-01 12:00:00",
|
|
"updated_at": "2023-01-01 12:05:00",
|
|
}
|
|
|
|
# Test with attachments
|
|
result = convert_db_lxmf_message_to_dict(db_msg, include_attachments=True)
|
|
assert result["fields"]["image"]["image_bytes"] is not None
|
|
assert result["created_at"].endswith("Z")
|
|
assert result["path_hops_at_send"] == 4
|
|
assert result["path_interface_at_send"] == "RNode Interface"
|
|
|
|
# Test without attachments
|
|
result_no_att = convert_db_lxmf_message_to_dict(db_msg, include_attachments=False)
|
|
assert result_no_att["fields"]["image"]["image_bytes"] is None
|
|
assert result_no_att["fields"]["image"]["image_size"] == len(b"img")
|
|
assert result_no_att["fields"]["audio"]["audio_size"] == len(b"audio")
|
|
assert result_no_att["fields"]["file_attachments"][0]["file_size"] == len(b"file")
|
|
|
|
|
|
def test_convert_db_lxmf_message_to_dict_defaults_missing_method():
|
|
db_msg = {
|
|
"id": 1,
|
|
"hash": "a" * 32,
|
|
"source_hash": "b" * 32,
|
|
"destination_hash": "c" * 32,
|
|
"is_incoming": 0,
|
|
"state": "sent",
|
|
"progress": 100.0,
|
|
"delivery_attempts": 0,
|
|
"next_delivery_attempt_at": None,
|
|
"title": "",
|
|
"content": "x",
|
|
"fields": "{}",
|
|
"timestamp": 1.0,
|
|
"rssi": None,
|
|
"snr": None,
|
|
"quality": None,
|
|
"is_spam": 0,
|
|
"created_at": "2023-01-01 12:00:00",
|
|
"updated_at": "2023-01-01 12:00:00",
|
|
}
|
|
result = convert_db_lxmf_message_to_dict(db_msg)
|
|
assert result["method"] == "unknown"
|
|
|
|
|
|
def test_convert_lxmf_message_to_dict_with_reply():
|
|
mock_msg = MagicMock(spec=LXMF.LXMessage)
|
|
mock_msg.hash = b"msg_hash"
|
|
mock_msg.source_hash = b"src_hash"
|
|
mock_msg.destination_hash = b"dst_hash"
|
|
mock_msg.incoming = True
|
|
mock_msg.state = LXMF.LXMessage.SENT
|
|
mock_msg.progress = 1.0
|
|
mock_msg.method = LXMF.LXMessage.DIRECT
|
|
mock_msg.delivery_attempts = 1
|
|
mock_msg.title = b""
|
|
mock_msg.content = b"Reply text"
|
|
mock_msg.timestamp = 1234567890
|
|
mock_msg.rssi = None
|
|
mock_msg.snr = None
|
|
mock_msg.q = None
|
|
|
|
# Reply to hash
|
|
reply_hash = b"original_msg_hash"
|
|
mock_msg.get_fields.return_value = {0x30: reply_hash}
|
|
|
|
result = convert_lxmf_message_to_dict(mock_msg)
|
|
assert result["fields"]["reply_to"] == reply_hash.hex()
|
|
|
|
|
|
def test_convert_db_lxmf_message_to_dict_with_reply():
|
|
db_msg = {
|
|
"id": 1,
|
|
"hash": "hash_hex",
|
|
"source_hash": "src_hex",
|
|
"destination_hash": "dst_hex",
|
|
"is_incoming": 1,
|
|
"state": "delivered",
|
|
"progress": 100.0,
|
|
"method": "direct",
|
|
"delivery_attempts": 1,
|
|
"next_delivery_attempt_at": None,
|
|
"title": "Title",
|
|
"content": "Content",
|
|
"fields": "{}",
|
|
"timestamp": 1234567890,
|
|
"rssi": -60,
|
|
"snr": 5,
|
|
"quality": 2,
|
|
"is_spam": 0,
|
|
"reply_to_hash": "original_hash_hex",
|
|
"created_at": "2023-01-01 12:00:00",
|
|
"updated_at": "2023-01-01 12:05:00",
|
|
}
|
|
result = convert_db_lxmf_message_to_dict(db_msg)
|
|
assert result["reply_to_hash"] == "original_hash_hex"
|
|
|
|
|
|
def test_compute_unread_outgoing_latest_never_unread_even_without_read_cursor():
|
|
row = {
|
|
"is_incoming": 0,
|
|
"last_read_at": None,
|
|
"timestamp": 1_700_000_000.0,
|
|
}
|
|
assert compute_lxmf_conversation_unread_from_latest_row(row) is False
|
|
|
|
|
|
def test_compute_unread_incoming_no_read_cursor_is_unread():
|
|
row = {
|
|
"is_incoming": 1,
|
|
"last_read_at": None,
|
|
"timestamp": 1_700_000_000.0,
|
|
}
|
|
assert compute_lxmf_conversation_unread_from_latest_row(row) is True
|
|
|
|
|
|
def test_compute_unread_incoming_not_unread_when_read_cursor_covers_message():
|
|
ts = 1_700_000_000.0
|
|
read_after_msg = datetime.fromtimestamp(ts + 60, UTC).isoformat()
|
|
row = {
|
|
"is_incoming": 1,
|
|
"last_read_at": read_after_msg,
|
|
"timestamp": ts,
|
|
}
|
|
assert compute_lxmf_conversation_unread_from_latest_row(row) is False
|
|
|
|
|
|
def test_compute_unread_incoming_newer_than_read_cursor_unread():
|
|
ts = 1_700_000_000.0
|
|
read = datetime.fromtimestamp(ts - 120, UTC).isoformat()
|
|
row = {
|
|
"is_incoming": 1,
|
|
"last_read_at": read,
|
|
"timestamp": ts,
|
|
}
|
|
assert compute_lxmf_conversation_unread_from_latest_row(row) is True
|
|
|
|
|
|
def test_sidebar_preview_reaction_incoming_uses_peer_name():
|
|
local = "a" * 32
|
|
row = {
|
|
"content": "",
|
|
"fields": json.dumps(
|
|
{"app_extensions": {"reaction_to": "abc123", "emoji": "\U0001f44d"}},
|
|
),
|
|
"is_incoming": 1,
|
|
"source_hash": "b" * 32,
|
|
}
|
|
out = lxmf_sidebar_preview_for_conversation_latest_row(
|
|
row,
|
|
local_hash=local,
|
|
peer_display_name="Charlie",
|
|
)
|
|
assert out == "Charlie reacted \U0001f44d"
|
|
|
|
|
|
def test_sidebar_preview_reaction_outbound_from_self_is_you():
|
|
me = "c" * 32
|
|
row = {
|
|
"content": "",
|
|
"fields": json.dumps(
|
|
{"app_extensions": {"reaction_to": "abc123", "emoji": "\u2764\ufe0f"}},
|
|
),
|
|
"is_incoming": 0,
|
|
"source_hash": me,
|
|
}
|
|
out = lxmf_sidebar_preview_for_conversation_latest_row(
|
|
row,
|
|
local_hash=me,
|
|
peer_display_name="Dana",
|
|
)
|
|
assert out == "You reacted \u2764\ufe0f"
|
|
|
|
|
|
def test_sidebar_preview_prefers_non_empty_content():
|
|
row = {
|
|
"content": " hi ",
|
|
"fields": json.dumps(
|
|
{"app_extensions": {"reaction_to": "abc123", "emoji": "\U0001f44d"}},
|
|
),
|
|
"is_incoming": 1,
|
|
}
|
|
out = lxmf_sidebar_preview_for_conversation_latest_row(
|
|
row,
|
|
local_hash="a" * 32,
|
|
peer_display_name="Eve",
|
|
)
|
|
assert out == " hi "
|
|
|
|
|
|
def test_sidebar_preview_telemetry_location_incoming():
|
|
local = "a" * 32
|
|
row = {
|
|
"content": "",
|
|
"fields": json.dumps(
|
|
{"telemetry": {"location": {"latitude": 1.0, "longitude": 2.0}}},
|
|
),
|
|
"is_incoming": 1,
|
|
"source_hash": "b" * 32,
|
|
}
|
|
out = lxmf_sidebar_preview_for_conversation_latest_row(
|
|
row,
|
|
local_hash=local,
|
|
peer_display_name="Riley",
|
|
)
|
|
assert out == "Riley shared their location"
|
|
|
|
|
|
def test_sidebar_preview_telemetry_location_outbound_you():
|
|
me = "c" * 32
|
|
row = {
|
|
"content": "",
|
|
"fields": json.dumps(
|
|
{"telemetry": {"location": {"latitude": 1.0, "longitude": 2.0}}},
|
|
),
|
|
"is_incoming": 0,
|
|
"source_hash": me,
|
|
}
|
|
out = lxmf_sidebar_preview_for_conversation_latest_row(
|
|
row,
|
|
local_hash=me,
|
|
peer_display_name="Sam",
|
|
)
|
|
assert out == "You shared your location"
|
|
|
|
|
|
def test_sidebar_preview_location_request_outbound_you():
|
|
me = "c" * 32
|
|
row = {
|
|
"content": "",
|
|
"fields": json.dumps({"commands": [{"0x01": 1_700_000_000}]}),
|
|
"is_incoming": 0,
|
|
"source_hash": me,
|
|
}
|
|
out = lxmf_sidebar_preview_for_conversation_latest_row(
|
|
row,
|
|
local_hash=me,
|
|
peer_display_name="Sam",
|
|
)
|
|
assert out == "You sent a location request"
|
|
|
|
|
|
def test_sidebar_preview_telemetry_battery_only():
|
|
row = {
|
|
"content": "",
|
|
"fields": json.dumps({"telemetry": {"battery": {"charge_percent": 50}}}),
|
|
"is_incoming": 1,
|
|
"source_hash": "b" * 32,
|
|
}
|
|
out = lxmf_sidebar_preview_for_conversation_latest_row(
|
|
row,
|
|
local_hash="a" * 32,
|
|
peer_display_name="Taylor",
|
|
)
|
|
assert out == "Taylor sent telemetry"
|
|
|
|
|
|
def test_sidebar_preview_telemetry_stream():
|
|
row = {
|
|
"content": "",
|
|
"fields": json.dumps({"telemetry_stream": [{"t": 1}]}),
|
|
"is_incoming": 1,
|
|
"source_hash": "b" * 32,
|
|
}
|
|
out = lxmf_sidebar_preview_for_conversation_latest_row(
|
|
row,
|
|
local_hash="a" * 32,
|
|
peer_display_name="Jordan",
|
|
)
|
|
assert out == "Jordan sent a telemetry stream"
|
|
|
|
|
|
def test_sidebar_preview_image_incoming():
|
|
row = {
|
|
"content": "",
|
|
"fields": json.dumps({"image": {"image_type": "png", "image_size": 12}}),
|
|
"is_incoming": 1,
|
|
"source_hash": "b" * 32,
|
|
}
|
|
out = lxmf_sidebar_preview_for_conversation_latest_row(
|
|
row,
|
|
local_hash="a" * 32,
|
|
peer_display_name="Quinn",
|
|
)
|
|
assert out == "Quinn sent an image"
|
|
|
|
|
|
def test_sidebar_preview_image_outbound_you():
|
|
me = "c" * 32
|
|
row = {
|
|
"content": "",
|
|
"fields": json.dumps({"image": {"image_type": "jpeg", "image_size": 99}}),
|
|
"is_incoming": 0,
|
|
"source_hash": me,
|
|
}
|
|
out = lxmf_sidebar_preview_for_conversation_latest_row(
|
|
row,
|
|
local_hash=me,
|
|
peer_display_name="Pat",
|
|
)
|
|
assert out == "You sent an image"
|
|
|
|
|
|
def test_sidebar_preview_audio_voice_note():
|
|
row = {
|
|
"content": "",
|
|
"fields": json.dumps({"audio": {"audio_mode": "opus", "audio_size": 500}}),
|
|
"is_incoming": 1,
|
|
"source_hash": "b" * 32,
|
|
}
|
|
out = lxmf_sidebar_preview_for_conversation_latest_row(
|
|
row,
|
|
local_hash="a" * 32,
|
|
peer_display_name="Morgan",
|
|
)
|
|
assert out == "Morgan sent a voice note"
|
|
|
|
|
|
def test_sidebar_preview_file_attachments_plural():
|
|
row = {
|
|
"content": "",
|
|
"fields": json.dumps(
|
|
{
|
|
"file_attachments": [
|
|
{"file_name": "a.txt", "file_size": 1},
|
|
{"file_name": "b.txt", "file_size": 2},
|
|
],
|
|
},
|
|
),
|
|
"is_incoming": 0,
|
|
"source_hash": "d" * 32,
|
|
}
|
|
out = lxmf_sidebar_preview_for_conversation_latest_row(
|
|
row,
|
|
local_hash="d" * 32,
|
|
peer_display_name="Casey",
|
|
)
|
|
assert out == "You sent 2 files"
|