| """Tests for the BlueBubbles iMessage gateway adapter.""" |
| import pytest |
|
|
| from gateway.config import Platform, PlatformConfig |
|
|
|
|
| def _make_adapter(monkeypatch, **extra): |
| monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") |
| monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret") |
| from gateway.platforms.bluebubbles import BlueBubblesAdapter |
|
|
| cfg = PlatformConfig( |
| enabled=True, |
| extra={ |
| "server_url": "http://localhost:1234", |
| "password": "secret", |
| **extra, |
| }, |
| ) |
| return BlueBubblesAdapter(cfg) |
|
|
|
|
| class TestBlueBubblesConfigLoading: |
| def test_apply_env_overrides_bluebubbles(self, monkeypatch): |
| monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") |
| monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret") |
| monkeypatch.setenv("BLUEBUBBLES_WEBHOOK_PORT", "9999") |
| from gateway.config import GatewayConfig, _apply_env_overrides |
|
|
| config = GatewayConfig() |
| _apply_env_overrides(config) |
| assert Platform.BLUEBUBBLES in config.platforms |
| bc = config.platforms[Platform.BLUEBUBBLES] |
| assert bc.enabled is True |
| assert bc.extra["server_url"] == "http://localhost:1234" |
| assert bc.extra["password"] == "secret" |
| assert bc.extra["webhook_port"] == 9999 |
|
|
| def test_home_channel_set_from_env(self, monkeypatch): |
| monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") |
| monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret") |
| monkeypatch.setenv("BLUEBUBBLES_HOME_CHANNEL", "user@example.com") |
| from gateway.config import GatewayConfig, _apply_env_overrides |
|
|
| config = GatewayConfig() |
| _apply_env_overrides(config) |
| hc = config.platforms[Platform.BLUEBUBBLES].home_channel |
| assert hc is not None |
| assert hc.chat_id == "user@example.com" |
|
|
| def test_not_connected_without_password(self, monkeypatch): |
| monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") |
| monkeypatch.delenv("BLUEBUBBLES_PASSWORD", raising=False) |
| from gateway.config import GatewayConfig, _apply_env_overrides |
|
|
| config = GatewayConfig() |
| _apply_env_overrides(config) |
| assert Platform.BLUEBUBBLES not in config.get_connected_platforms() |
|
|
|
|
| class TestBlueBubblesHelpers: |
| def test_check_requirements(self, monkeypatch): |
| monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") |
| monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret") |
| from gateway.platforms.bluebubbles import check_bluebubbles_requirements |
|
|
| assert check_bluebubbles_requirements() is True |
|
|
| def test_format_message_strips_markdown(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch) |
| assert adapter.format_message("**Hello** `world`") == "Hello world" |
|
|
| def test_strip_markdown_headers(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch) |
| assert adapter.format_message("## Heading\ntext") == "Heading\ntext" |
|
|
| def test_strip_markdown_links(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch) |
| assert adapter.format_message("[click here](http://example.com)") == "click here" |
|
|
| def test_init_normalizes_webhook_path(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch, webhook_path="bluebubbles-webhook") |
| assert adapter.webhook_path == "/bluebubbles-webhook" |
|
|
| def test_init_preserves_leading_slash(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch, webhook_path="/my-hook") |
| assert adapter.webhook_path == "/my-hook" |
|
|
| def test_server_url_normalized(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch, server_url="http://localhost:1234/") |
| assert adapter.server_url == "http://localhost:1234" |
|
|
| def test_server_url_adds_scheme(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch, server_url="localhost:1234") |
| assert adapter.server_url == "http://localhost:1234" |
|
|
|
|
| class TestBlueBubblesWebhookParsing: |
| def test_webhook_prefers_chat_guid_over_message_guid(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch) |
| payload = { |
| "guid": "MESSAGE-GUID", |
| "chatGuid": "iMessage;-;user@example.com", |
| "chatIdentifier": "user@example.com", |
| } |
| record = adapter._extract_payload_record(payload) or {} |
| chat_guid = adapter._value( |
| record.get("chatGuid"), |
| payload.get("chatGuid"), |
| record.get("chat_guid"), |
| payload.get("chat_guid"), |
| payload.get("guid"), |
| ) |
| assert chat_guid == "iMessage;-;user@example.com" |
|
|
| def test_webhook_can_fall_back_to_sender_when_chat_fields_missing(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch) |
| payload = { |
| "data": { |
| "guid": "MESSAGE-GUID", |
| "text": "hello", |
| "handle": {"address": "user@example.com"}, |
| "isFromMe": False, |
| } |
| } |
| record = adapter._extract_payload_record(payload) or {} |
| chat_guid = adapter._value( |
| record.get("chatGuid"), |
| payload.get("chatGuid"), |
| record.get("chat_guid"), |
| payload.get("chat_guid"), |
| payload.get("guid"), |
| ) |
| chat_identifier = adapter._value( |
| record.get("chatIdentifier"), |
| record.get("identifier"), |
| payload.get("chatIdentifier"), |
| payload.get("identifier"), |
| ) |
| sender = ( |
| adapter._value( |
| record.get("handle", {}).get("address") |
| if isinstance(record.get("handle"), dict) |
| else None, |
| record.get("sender"), |
| record.get("from"), |
| record.get("address"), |
| ) |
| or chat_identifier |
| or chat_guid |
| ) |
| if not (chat_guid or chat_identifier) and sender: |
| chat_identifier = sender |
| assert chat_identifier == "user@example.com" |
|
|
| def test_webhook_extracts_chat_guid_from_chats_array_dm(self, monkeypatch): |
| """BB v1.9+ webhook payloads omit top-level chatGuid; GUID is in chats[0].guid.""" |
| adapter = _make_adapter(monkeypatch) |
| payload = { |
| "type": "new-message", |
| "data": { |
| "guid": "MESSAGE-GUID", |
| "text": "hello", |
| "handle": {"address": "+15551234567"}, |
| "isFromMe": False, |
| "chats": [ |
| {"guid": "any;-;+15551234567", "chatIdentifier": "+15551234567"} |
| ], |
| }, |
| } |
| record = adapter._extract_payload_record(payload) or {} |
| chat_guid = adapter._value( |
| record.get("chatGuid"), |
| payload.get("chatGuid"), |
| record.get("chat_guid"), |
| payload.get("chat_guid"), |
| payload.get("guid"), |
| ) |
| if not chat_guid: |
| _chats = record.get("chats") or [] |
| if _chats and isinstance(_chats[0], dict): |
| chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid") |
| assert chat_guid == "any;-;+15551234567" |
|
|
| def test_webhook_extracts_chat_guid_from_chats_array_group(self, monkeypatch): |
| """Group chat GUIDs contain ;+; and must be extracted from chats array.""" |
| adapter = _make_adapter(monkeypatch) |
| payload = { |
| "type": "new-message", |
| "data": { |
| "guid": "MESSAGE-GUID", |
| "text": "hello everyone", |
| "handle": {"address": "+15551234567"}, |
| "isFromMe": False, |
| "isGroup": True, |
| "chats": [{"guid": "any;+;chat-uuid-abc123"}], |
| }, |
| } |
| record = adapter._extract_payload_record(payload) or {} |
| chat_guid = adapter._value( |
| record.get("chatGuid"), |
| payload.get("chatGuid"), |
| record.get("chat_guid"), |
| payload.get("chat_guid"), |
| payload.get("guid"), |
| ) |
| if not chat_guid: |
| _chats = record.get("chats") or [] |
| if _chats and isinstance(_chats[0], dict): |
| chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid") |
| assert chat_guid == "any;+;chat-uuid-abc123" |
|
|
| def test_extract_payload_record_accepts_list_data(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch) |
| payload = { |
| "type": "new-message", |
| "data": [ |
| { |
| "text": "hello", |
| "chatGuid": "iMessage;-;user@example.com", |
| "chatIdentifier": "user@example.com", |
| } |
| ], |
| } |
| record = adapter._extract_payload_record(payload) |
| assert record == payload["data"][0] |
|
|
| def test_extract_payload_record_dict_data(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch) |
| payload = {"data": {"text": "hello", "chatGuid": "iMessage;-;+1234"}} |
| record = adapter._extract_payload_record(payload) |
| assert record["text"] == "hello" |
|
|
| def test_extract_payload_record_fallback_to_message(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch) |
| payload = {"message": {"text": "hello"}} |
| record = adapter._extract_payload_record(payload) |
| assert record["text"] == "hello" |
|
|
|
|
| class TestBlueBubblesGuidResolution: |
| def test_raw_guid_returned_as_is(self, monkeypatch): |
| """If target already contains ';' it's a raw GUID — return unchanged.""" |
| adapter = _make_adapter(monkeypatch) |
| import asyncio |
|
|
| result = asyncio.get_event_loop().run_until_complete( |
| adapter._resolve_chat_guid("iMessage;-;user@example.com") |
| ) |
| assert result == "iMessage;-;user@example.com" |
|
|
| def test_empty_target_returns_none(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch) |
| import asyncio |
|
|
| result = asyncio.get_event_loop().run_until_complete( |
| adapter._resolve_chat_guid("") |
| ) |
| assert result is None |
|
|
|
|
| class TestBlueBubblesAttachmentDownload: |
| """Verify _download_attachment routes to the correct cache helper.""" |
|
|
| def test_download_image_uses_image_cache(self, monkeypatch): |
| """Image MIME routes to cache_image_from_bytes.""" |
| adapter = _make_adapter(monkeypatch) |
| import asyncio |
| import httpx |
|
|
| |
| class MockResponse: |
| status_code = 200 |
| content = b"\x89PNG\r\n\x1a\n" |
|
|
| def raise_for_status(self): |
| pass |
|
|
| async def mock_get(*args, **kwargs): |
| return MockResponse() |
|
|
| adapter.client = type("MockClient", (), {"get": mock_get})() |
|
|
| cached_path = None |
|
|
| def mock_cache_image(data, ext): |
| nonlocal cached_path |
| cached_path = f"/tmp/test_image{ext}" |
| return cached_path |
|
|
| monkeypatch.setattr( |
| "gateway.platforms.bluebubbles.cache_image_from_bytes", |
| mock_cache_image, |
| ) |
|
|
| att_meta = {"mimeType": "image/png", "transferName": "photo.png"} |
| result = asyncio.get_event_loop().run_until_complete( |
| adapter._download_attachment("att-guid-123", att_meta) |
| ) |
| assert result == "/tmp/test_image.png" |
|
|
| def test_download_audio_uses_audio_cache(self, monkeypatch): |
| """Audio MIME routes to cache_audio_from_bytes.""" |
| adapter = _make_adapter(monkeypatch) |
| import asyncio |
|
|
| class MockResponse: |
| status_code = 200 |
| content = b"fake-audio-data" |
|
|
| def raise_for_status(self): |
| pass |
|
|
| async def mock_get(*args, **kwargs): |
| return MockResponse() |
|
|
| adapter.client = type("MockClient", (), {"get": mock_get})() |
|
|
| cached_path = None |
|
|
| def mock_cache_audio(data, ext): |
| nonlocal cached_path |
| cached_path = f"/tmp/test_audio{ext}" |
| return cached_path |
|
|
| monkeypatch.setattr( |
| "gateway.platforms.bluebubbles.cache_audio_from_bytes", |
| mock_cache_audio, |
| ) |
|
|
| att_meta = {"mimeType": "audio/mpeg", "transferName": "voice.mp3"} |
| result = asyncio.get_event_loop().run_until_complete( |
| adapter._download_attachment("att-guid-456", att_meta) |
| ) |
| assert result == "/tmp/test_audio.mp3" |
|
|
| def test_download_document_uses_document_cache(self, monkeypatch): |
| """Non-image/audio MIME routes to cache_document_from_bytes.""" |
| adapter = _make_adapter(monkeypatch) |
| import asyncio |
|
|
| class MockResponse: |
| status_code = 200 |
| content = b"fake-doc-data" |
|
|
| def raise_for_status(self): |
| pass |
|
|
| async def mock_get(*args, **kwargs): |
| return MockResponse() |
|
|
| adapter.client = type("MockClient", (), {"get": mock_get})() |
|
|
| cached_path = None |
|
|
| def mock_cache_doc(data, filename): |
| nonlocal cached_path |
| cached_path = f"/tmp/{filename}" |
| return cached_path |
|
|
| monkeypatch.setattr( |
| "gateway.platforms.bluebubbles.cache_document_from_bytes", |
| mock_cache_doc, |
| ) |
|
|
| att_meta = {"mimeType": "application/pdf", "transferName": "report.pdf"} |
| result = asyncio.get_event_loop().run_until_complete( |
| adapter._download_attachment("att-guid-789", att_meta) |
| ) |
| assert result == "/tmp/report.pdf" |
|
|
| def test_download_returns_none_without_client(self, monkeypatch): |
| """No client → returns None gracefully.""" |
| adapter = _make_adapter(monkeypatch) |
| adapter.client = None |
| import asyncio |
|
|
| result = asyncio.get_event_loop().run_until_complete( |
| adapter._download_attachment("att-guid", {"mimeType": "image/png"}) |
| ) |
| assert result is None |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestBlueBubblesWebhookUrl: |
| """_webhook_url property normalises local hosts to 'localhost'.""" |
|
|
| def test_default_host(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch) |
| |
| assert "localhost" in adapter._webhook_url |
| assert str(adapter.webhook_port) in adapter._webhook_url |
| assert adapter.webhook_path in adapter._webhook_url |
|
|
| @pytest.mark.parametrize("host", ["0.0.0.0", "127.0.0.1", "localhost", "::"]) |
| def test_local_hosts_normalized(self, monkeypatch, host): |
| adapter = _make_adapter(monkeypatch, webhook_host=host) |
| assert adapter._webhook_url.startswith("http://localhost:") |
|
|
| def test_custom_host_preserved(self, monkeypatch): |
| adapter = _make_adapter(monkeypatch, webhook_host="192.168.1.50") |
| assert "192.168.1.50" in adapter._webhook_url |
|
|
| def test_register_url_embeds_password(self, monkeypatch): |
| """_webhook_register_url should append ?password=... for inbound auth.""" |
| adapter = _make_adapter(monkeypatch, password="secret123") |
| assert adapter._webhook_register_url.endswith("?password=secret123") |
| assert adapter._webhook_register_url.startswith(adapter._webhook_url) |
|
|
| def test_register_url_url_encodes_password(self, monkeypatch): |
| """Passwords with special characters must be URL-encoded.""" |
| adapter = _make_adapter(monkeypatch, password="W9fTC&L5JL*@") |
| assert "password=W9fTC%26L5JL%2A%40" in adapter._webhook_register_url |
|
|
| def test_register_url_omits_query_when_no_password(self, monkeypatch): |
| """If no password is configured, the register URL should be the bare URL.""" |
| monkeypatch.delenv("BLUEBUBBLES_PASSWORD", raising=False) |
| from gateway.platforms.bluebubbles import BlueBubblesAdapter |
| cfg = PlatformConfig( |
| enabled=True, |
| extra={"server_url": "http://localhost:1234", "password": ""}, |
| ) |
| adapter = BlueBubblesAdapter(cfg) |
| assert adapter._webhook_register_url == adapter._webhook_url |
|
|
|
|
| class TestBlueBubblesWebhookRegistration: |
| """Tests for _register_webhook, _unregister_webhook, _find_registered_webhooks.""" |
|
|
| @staticmethod |
| def _mock_client(get_response=None, post_response=None, delete_ok=True): |
| """Build a tiny mock httpx.AsyncClient.""" |
|
|
| async def mock_get(*args, **kwargs): |
| class R: |
| status_code = 200 |
| def raise_for_status(self): |
| pass |
| def json(self): |
| return get_response or {"status": 200, "data": []} |
| return R() |
|
|
| async def mock_post(*args, **kwargs): |
| class R: |
| status_code = 200 |
| def raise_for_status(self): |
| pass |
| def json(self): |
| return post_response or {"status": 200, "data": {}} |
| return R() |
|
|
| async def mock_delete(*args, **kwargs): |
| class R: |
| status_code = 200 if delete_ok else 500 |
| def raise_for_status(self_inner): |
| if not delete_ok: |
| raise Exception("delete failed") |
| return R() |
|
|
| return type( |
| "MockClient", (), |
| {"get": mock_get, "post": mock_post, "delete": mock_delete}, |
| )() |
|
|
| |
|
|
| def test_find_registered_webhooks_returns_matches(self, monkeypatch): |
| import asyncio |
| adapter = _make_adapter(monkeypatch) |
| url = adapter._webhook_url |
| adapter.client = self._mock_client( |
| get_response={"status": 200, "data": [ |
| {"id": 1, "url": url, "events": ["new-message"]}, |
| {"id": 2, "url": "http://other:9999/hook", "events": ["message"]}, |
| ]} |
| ) |
| result = asyncio.get_event_loop().run_until_complete( |
| adapter._find_registered_webhooks(url) |
| ) |
| assert len(result) == 1 |
| assert result[0]["id"] == 1 |
|
|
| def test_find_registered_webhooks_empty_when_none(self, monkeypatch): |
| import asyncio |
| adapter = _make_adapter(monkeypatch) |
| adapter.client = self._mock_client( |
| get_response={"status": 200, "data": []} |
| ) |
| result = asyncio.get_event_loop().run_until_complete( |
| adapter._find_registered_webhooks(adapter._webhook_url) |
| ) |
| assert result == [] |
|
|
| def test_find_registered_webhooks_handles_api_error(self, monkeypatch): |
| import asyncio |
| adapter = _make_adapter(monkeypatch) |
| adapter.client = self._mock_client() |
|
|
| |
| async def bad_get(path): |
| raise ConnectionError("server down") |
| adapter._api_get = bad_get |
|
|
| result = asyncio.get_event_loop().run_until_complete( |
| adapter._find_registered_webhooks(adapter._webhook_url) |
| ) |
| assert result == [] |
|
|
| |
|
|
| def test_register_fresh(self, monkeypatch): |
| """No existing webhook → POST creates one.""" |
| import asyncio |
| adapter = _make_adapter(monkeypatch) |
| adapter.client = self._mock_client( |
| get_response={"status": 200, "data": []}, |
| post_response={"status": 200, "data": {"id": 42}}, |
| ) |
| ok = asyncio.get_event_loop().run_until_complete( |
| adapter._register_webhook() |
| ) |
| assert ok is True |
|
|
| def test_register_accepts_201(self, monkeypatch): |
| """BB might return 201 Created — must still succeed.""" |
| import asyncio |
| adapter = _make_adapter(monkeypatch) |
| adapter.client = self._mock_client( |
| get_response={"status": 200, "data": []}, |
| post_response={"status": 201, "data": {"id": 43}}, |
| ) |
| ok = asyncio.get_event_loop().run_until_complete( |
| adapter._register_webhook() |
| ) |
| assert ok is True |
|
|
| def test_register_reuses_existing(self, monkeypatch): |
| """Crash resilience — existing registration is reused, no POST needed.""" |
| import asyncio |
| adapter = _make_adapter(monkeypatch) |
| url = adapter._webhook_register_url |
| adapter.client = self._mock_client( |
| get_response={"status": 200, "data": [ |
| {"id": 7, "url": url, "events": ["new-message"]}, |
| ]}, |
| ) |
|
|
| |
| post_called = False |
| orig_api_post = adapter._api_post |
| async def tracking_post(path, payload): |
| nonlocal post_called |
| post_called = True |
| return await orig_api_post(path, payload) |
| adapter._api_post = tracking_post |
|
|
| ok = asyncio.get_event_loop().run_until_complete( |
| adapter._register_webhook() |
| ) |
| assert ok is True |
| assert not post_called, "Should reuse existing, not POST again" |
|
|
| def test_register_returns_false_without_client(self, monkeypatch): |
| import asyncio |
| adapter = _make_adapter(monkeypatch) |
| adapter.client = None |
| ok = asyncio.get_event_loop().run_until_complete( |
| adapter._register_webhook() |
| ) |
| assert ok is False |
|
|
| def test_register_returns_false_on_server_error(self, monkeypatch): |
| import asyncio |
| adapter = _make_adapter(monkeypatch) |
| adapter.client = self._mock_client( |
| get_response={"status": 200, "data": []}, |
| post_response={"status": 500, "message": "internal error"}, |
| ) |
| ok = asyncio.get_event_loop().run_until_complete( |
| adapter._register_webhook() |
| ) |
| assert ok is False |
|
|
| |
|
|
| def test_unregister_removes_matching(self, monkeypatch): |
| import asyncio |
| adapter = _make_adapter(monkeypatch) |
| url = adapter._webhook_register_url |
| adapter.client = self._mock_client( |
| get_response={"status": 200, "data": [ |
| {"id": 10, "url": url}, |
| ]}, |
| ) |
| ok = asyncio.get_event_loop().run_until_complete( |
| adapter._unregister_webhook() |
| ) |
| assert ok is True |
|
|
| def test_unregister_removes_all_duplicates(self, monkeypatch): |
| """Multiple orphaned registrations for same URL — all get removed.""" |
| import asyncio |
| adapter = _make_adapter(monkeypatch) |
| url = adapter._webhook_register_url |
| deleted_ids = [] |
|
|
| async def mock_delete(*args, **kwargs): |
| |
| url_str = args[0] if args else "" |
| deleted_ids.append(url_str) |
| class R: |
| status_code = 200 |
| def raise_for_status(self): |
| pass |
| return R() |
|
|
| adapter.client = self._mock_client( |
| get_response={"status": 200, "data": [ |
| {"id": 1, "url": url}, |
| {"id": 2, "url": url}, |
| {"id": 3, "url": "http://other/hook"}, |
| ]}, |
| ) |
| adapter.client.delete = mock_delete |
|
|
| ok = asyncio.get_event_loop().run_until_complete( |
| adapter._unregister_webhook() |
| ) |
| assert ok is True |
| assert len(deleted_ids) == 2 |
|
|
| def test_unregister_returns_false_without_client(self, monkeypatch): |
| import asyncio |
| adapter = _make_adapter(monkeypatch) |
| adapter.client = None |
| ok = asyncio.get_event_loop().run_until_complete( |
| adapter._unregister_webhook() |
| ) |
| assert ok is False |
|
|
| def test_unregister_handles_api_failure_gracefully(self, monkeypatch): |
| import asyncio |
| adapter = _make_adapter(monkeypatch) |
| adapter.client = self._mock_client() |
|
|
| async def bad_get(path): |
| raise ConnectionError("server down") |
| adapter._api_get = bad_get |
|
|
| ok = asyncio.get_event_loop().run_until_complete( |
| adapter._unregister_webhook() |
| ) |
| assert ok is False |
|
|