b4fbd6fe9f
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
937 lines
35 KiB
Python
937 lines
35 KiB
Python
"""Tests for the BlueBubbles iMessage gateway adapter."""
|
|
import asyncio
|
|
import json
|
|
|
|
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")
|
|
monkeypatch.setenv("BLUEBUBBLES_REQUIRE_MENTION", "true")
|
|
monkeypatch.setenv("BLUEBUBBLES_MENTION_PATTERNS", r'["(?i)^amos\\b"]')
|
|
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
|
|
assert bc.extra["require_mention"] is True
|
|
assert bc.extra["mention_patterns"] == ["(?i)^amos\\b"]
|
|
|
|
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_supports_message_editing_is_false(self, monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
assert adapter.SUPPORTS_MESSAGE_EDITING is False
|
|
|
|
def test_truncate_message_omits_pagination_suffixes(self, monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
chunks = adapter.truncate_message("abcdefghij", max_length=6)
|
|
assert len(chunks) > 1
|
|
assert "".join(chunks) == "abcdefghij"
|
|
assert all("(" not in chunk for chunk in chunks)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_splits_paragraphs_into_multiple_bubbles(self, monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
sent = []
|
|
|
|
async def fake_resolve_chat_guid(chat_id):
|
|
return "iMessage;-;user@example.com"
|
|
|
|
async def fake_api_post(path, payload):
|
|
sent.append(payload["message"])
|
|
return {"data": {"guid": f"msg-{len(sent)}"}}
|
|
|
|
monkeypatch.setattr(adapter, "_resolve_chat_guid", fake_resolve_chat_guid)
|
|
monkeypatch.setattr(adapter, "_api_post", fake_api_post)
|
|
|
|
result = await adapter.send("user@example.com", "first thought\n\nsecond thought")
|
|
|
|
assert result.success is True
|
|
assert sent == ["first thought", "second thought"]
|
|
|
|
def test_format_message_strips_markdown(self, monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
assert adapter.format_message("**Hello** `world`") == "Hello world"
|
|
|
|
def test_format_message_preserves_underscores_in_identifiers(self, monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
text = "Use /api_v2 with FEATURE_FLAG_NAME and config_file.json"
|
|
assert adapter.format_message(text) == text
|
|
|
|
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"
|
|
|
|
def test_default_mention_patterns_match_hermes_variants(self, monkeypatch):
|
|
adapter = _make_adapter(monkeypatch, require_mention=True)
|
|
|
|
assert adapter.require_mention is True
|
|
assert adapter._message_matches_mention_patterns("Hermes, summarize this")
|
|
assert adapter._message_matches_mention_patterns("@Hermes agent help")
|
|
assert not adapter._message_matches_mention_patterns("casual family chatter")
|
|
assert not adapter._message_matches_mention_patterns("antihermes should not match")
|
|
|
|
def test_custom_mention_patterns_override_defaults(self, monkeypatch):
|
|
adapter = _make_adapter(
|
|
monkeypatch,
|
|
require_mention=True,
|
|
mention_patterns=[r"(?<![\w@])@?amos\b[,:\-]?"],
|
|
)
|
|
|
|
assert adapter._message_matches_mention_patterns("Amos what is next?")
|
|
assert not adapter._message_matches_mention_patterns("Hermes what is next?")
|
|
|
|
def test_clean_mention_text_strips_leading_wake_word(self, monkeypatch):
|
|
adapter = _make_adapter(monkeypatch, require_mention=True)
|
|
|
|
assert adapter._clean_mention_text("Hermes, summarize this") == "summarize this"
|
|
assert adapter._clean_mention_text("Hermes agent: summarize this") == "summarize this"
|
|
assert adapter._clean_mention_text("please ask Hermes about this") == "please ask Hermes about this"
|
|
|
|
|
|
class _FakeBlueBubblesRequest:
|
|
def __init__(self, payload, password="secret"):
|
|
self.query = {"password": password}
|
|
self.headers = {}
|
|
self._body = json.dumps(payload).encode("utf-8")
|
|
|
|
async def read(self):
|
|
return self._body
|
|
|
|
|
|
class TestBlueBubblesMentionGating:
|
|
@pytest.mark.asyncio
|
|
async def test_group_message_without_mention_is_acknowledged_and_skipped(self, monkeypatch):
|
|
adapter = _make_adapter(
|
|
monkeypatch,
|
|
require_mention=True,
|
|
send_read_receipts=False,
|
|
)
|
|
handled = []
|
|
|
|
async def fake_handle_message(event):
|
|
handled.append(event)
|
|
|
|
monkeypatch.setattr(adapter, "handle_message", fake_handle_message)
|
|
response = await adapter._handle_webhook(_FakeBlueBubblesRequest({
|
|
"type": "new-message",
|
|
"data": {
|
|
"guid": "msg-1",
|
|
"text": "casual family chatter",
|
|
"handle": {"address": "+15555550100"},
|
|
"isFromMe": False,
|
|
"isGroup": True,
|
|
"chats": [{"guid": "iMessage;+;group-chat"}],
|
|
},
|
|
}))
|
|
await asyncio.sleep(0)
|
|
|
|
assert response.status == 200
|
|
assert handled == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_message_with_default_mention_is_dispatched_cleaned(self, monkeypatch):
|
|
adapter = _make_adapter(
|
|
monkeypatch,
|
|
require_mention=True,
|
|
send_read_receipts=False,
|
|
)
|
|
handled = []
|
|
|
|
async def fake_handle_message(event):
|
|
handled.append(event)
|
|
|
|
monkeypatch.setattr(adapter, "handle_message", fake_handle_message)
|
|
response = await adapter._handle_webhook(_FakeBlueBubblesRequest({
|
|
"type": "new-message",
|
|
"data": {
|
|
"guid": "msg-2",
|
|
"text": "Hermes, summarize this",
|
|
"handle": {"address": "+15555550100"},
|
|
"isFromMe": False,
|
|
"isGroup": True,
|
|
"chats": [{"guid": "iMessage;+;group-chat"}],
|
|
},
|
|
}))
|
|
await asyncio.sleep(0)
|
|
|
|
assert response.status == 200
|
|
assert [event.text for event in handled] == ["summarize this"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_message_does_not_require_mention(self, monkeypatch):
|
|
adapter = _make_adapter(
|
|
monkeypatch,
|
|
require_mention=True,
|
|
send_read_receipts=False,
|
|
)
|
|
handled = []
|
|
|
|
async def fake_handle_message(event):
|
|
handled.append(event)
|
|
|
|
monkeypatch.setattr(adapter, "handle_message", fake_handle_message)
|
|
response = await adapter._handle_webhook(_FakeBlueBubblesRequest({
|
|
"type": "new-message",
|
|
"data": {
|
|
"guid": "msg-3",
|
|
"text": "hello from a dm",
|
|
"handle": {"address": "user@example.com"},
|
|
"isFromMe": False,
|
|
"chatGuid": "iMessage;-;user@example.com",
|
|
"chatIdentifier": "user@example.com",
|
|
},
|
|
}))
|
|
await asyncio.sleep(0)
|
|
|
|
assert response.status == 200
|
|
assert [event.text for event in handled] == ["hello from a dm"]
|
|
|
|
|
|
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
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exact_chat_identifier_match_returns_dm_guid(self, monkeypatch):
|
|
"""A 1:1 DM whose chatIdentifier equals the target resolves to its guid."""
|
|
adapter = _make_adapter(monkeypatch)
|
|
|
|
async def fake_api_post(path, payload):
|
|
return {
|
|
"data": [
|
|
{
|
|
"guid": "iMessage;-;user@example.com",
|
|
"chatIdentifier": "user@example.com",
|
|
"participants": [{"address": "user@example.com"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
monkeypatch.setattr(adapter, "_api_post", fake_api_post)
|
|
result = await adapter._resolve_chat_guid("user@example.com")
|
|
assert result == "iMessage;-;user@example.com"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_participant_only_match_does_not_resolve_to_group(self, monkeypatch):
|
|
"""Regression for #24157: contact appearing as a participant in a group
|
|
chat must NOT be selected when no DM with that exact chatIdentifier exists.
|
|
|
|
Otherwise an outbound DM reply leaks into the group thread.
|
|
"""
|
|
adapter = _make_adapter(monkeypatch)
|
|
|
|
async def fake_api_post(path, payload):
|
|
return {
|
|
"data": [
|
|
{
|
|
"guid": "iMessage;+;chat0000000000-family-group",
|
|
"chatIdentifier": "chat0000000000",
|
|
"participants": [
|
|
{"address": "user@example.com"},
|
|
{"address": "+15555550100"},
|
|
],
|
|
}
|
|
]
|
|
}
|
|
|
|
monkeypatch.setattr(adapter, "_api_post", fake_api_post)
|
|
result = await adapter._resolve_chat_guid("user@example.com")
|
|
assert result is None, (
|
|
"participant-only match must not resolve to a group GUID — DM "
|
|
"replies would leak into the group thread"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_chosen_over_group_when_both_contain_contact(self, monkeypatch):
|
|
"""Even when a group chat is returned BEFORE a DM in the query result,
|
|
the resolver must lock onto the DM by chatIdentifier and not the
|
|
group via participant fallback.
|
|
"""
|
|
adapter = _make_adapter(monkeypatch)
|
|
|
|
async def fake_api_post(path, payload):
|
|
return {
|
|
"data": [
|
|
{
|
|
"guid": "iMessage;+;chat0000000000-family-group",
|
|
"chatIdentifier": "chat0000000000",
|
|
"participants": [{"address": "user@example.com"}],
|
|
},
|
|
{
|
|
"guid": "iMessage;-;user@example.com",
|
|
"chatIdentifier": "user@example.com",
|
|
"participants": [{"address": "user@example.com"}],
|
|
},
|
|
]
|
|
}
|
|
|
|
monkeypatch.setattr(adapter, "_api_post", fake_api_post)
|
|
result = await adapter._resolve_chat_guid("user@example.com")
|
|
assert result == "iMessage;-;user@example.com"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unresolved_target_is_not_cached(self, monkeypatch):
|
|
"""When no exact match is found, the resolver must NOT cache anything.
|
|
|
|
Otherwise a later attempt — after the DM has been created — would
|
|
keep returning the stale ``None`` from cache. Also guards against a
|
|
latent variant of #24157 where a group GUID could be cached under a
|
|
bare address key and persist across calls.
|
|
"""
|
|
adapter = _make_adapter(monkeypatch)
|
|
|
|
async def fake_api_post(path, payload):
|
|
return {
|
|
"data": [
|
|
{
|
|
"guid": "iMessage;+;chat0000000000-family-group",
|
|
"chatIdentifier": "chat0000000000",
|
|
"participants": [{"address": "user@example.com"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
monkeypatch.setattr(adapter, "_api_post", fake_api_post)
|
|
await adapter._resolve_chat_guid("user@example.com")
|
|
assert "user@example.com" not in adapter._guid_cache
|
|
|
|
|
|
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
|
|
|
|
# Mock the HTTP client response
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Webhook registration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBlueBubblesWebhookUrl:
|
|
"""_webhook_url property normalises local hosts to 'localhost'."""
|
|
|
|
def test_default_host(self, monkeypatch):
|
|
adapter = _make_adapter(monkeypatch)
|
|
# Default webhook_host is 0.0.0.0 → normalized to localhost
|
|
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_for_log_masks_password(self, monkeypatch):
|
|
"""Log-safe webhook URLs must never expose the webhook password."""
|
|
adapter = _make_adapter(monkeypatch, password="W9fTC&L5JL*@")
|
|
safe_url = adapter._webhook_register_url_for_log
|
|
assert safe_url.endswith("?password=***")
|
|
assert "W9fTC" not in safe_url
|
|
assert "%26" not in safe_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},
|
|
)()
|
|
|
|
# -- _find_registered_webhooks --
|
|
|
|
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()
|
|
|
|
# Override _api_get to raise
|
|
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 == []
|
|
|
|
# -- _register_webhook --
|
|
|
|
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"]},
|
|
]},
|
|
)
|
|
|
|
# Track whether POST was called
|
|
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
|
|
|
|
# -- _unregister_webhook --
|
|
|
|
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):
|
|
# Extract ID from URL
|
|
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
|