# 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"