chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
"""Tests for the Photon auth module (device login + dashboard API)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.platforms.photon import auth as photon_auth
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake httpx — we don't want to hit the real Photon API in unit tests.
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status: int = 200,
|
||||
json_body: Any = None,
|
||||
headers: Dict[str, str] | None = None,
|
||||
text: str = "",
|
||||
) -> None:
|
||||
self.status_code = status
|
||||
self._json = json_body if json_body is not None else {}
|
||||
self.headers = headers or {}
|
||||
self.text = text
|
||||
|
||||
def json(self) -> Any:
|
||||
return self._json
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
raise RuntimeError(f"HTTP {self.status_code}")
|
||||
|
||||
|
||||
_PHOTON_ENV = (
|
||||
"PHOTON_PROJECT_ID",
|
||||
"PHOTON_PROJECT_SECRET",
|
||||
"PHOTON_DASHBOARD_PROJECT_ID",
|
||||
"PHOTON_SPECTRUM_HOST",
|
||||
"PHOTON_ALLOWED_USERS",
|
||||
"PHOTON_HOME_CHANNEL",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
for key in _PHOTON_ENV:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
yield home
|
||||
# save_env_value() mutates os.environ directly, so scrub any leakage.
|
||||
for key in _PHOTON_ENV:
|
||||
os.environ.pop(key, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential storage
|
||||
|
||||
def test_store_and_load_photon_token(tmp_hermes_home: Path) -> None:
|
||||
photon_auth.store_photon_token("abc123def456")
|
||||
assert photon_auth.load_photon_token() == "abc123def456"
|
||||
|
||||
auth_json = json.loads((tmp_hermes_home / "auth.json").read_text())
|
||||
assert auth_json["credential_pool"]["photon"][0]["access_token"] == "abc123def456"
|
||||
|
||||
|
||||
def test_store_project_credentials_round_trip(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Don't touch .env / os.environ here — exercise the auth.json path.
|
||||
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id="sp-123",
|
||||
project_secret="secret-key",
|
||||
dashboard_project_id="dash-456",
|
||||
name="Hermes Agent",
|
||||
)
|
||||
for key in _PHOTON_ENV:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
sid, secret = photon_auth.load_project_credentials()
|
||||
assert sid == "sp-123"
|
||||
assert secret == "secret-key"
|
||||
# Post-unification the management id resolves to the Spectrum id, not the
|
||||
# stored dashboard id — so a pre-backfill diverged install (whose old
|
||||
# dashboard id was rewritten and now 404s) still reaches the live row.
|
||||
assert photon_auth.load_dashboard_project_id() == "sp-123"
|
||||
|
||||
|
||||
def test_store_project_credentials_writes_env(tmp_hermes_home: Path) -> None:
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id="sp-789",
|
||||
project_secret="sek-ret",
|
||||
dashboard_project_id="dash-1",
|
||||
)
|
||||
env_text = (tmp_hermes_home / ".env").read_text()
|
||||
assert "PHOTON_PROJECT_ID=sp-789" in env_text
|
||||
assert "PHOTON_PROJECT_SECRET=sek-ret" in env_text
|
||||
|
||||
|
||||
def test_store_user_numbers_round_trip(tmp_hermes_home: Path) -> None:
|
||||
photon_auth.store_user_numbers(
|
||||
phone_number="+15551234567",
|
||||
assigned_phone_number="+16282679185",
|
||||
user_id="user-uuid",
|
||||
dashboard_project_id="dash-uuid",
|
||||
)
|
||||
|
||||
phone, assigned = photon_auth.load_user_numbers()
|
||||
assert phone == "+15551234567"
|
||||
assert assigned == "+16282679185"
|
||||
|
||||
summary = photon_auth.credential_summary()
|
||||
assert summary["phone_number"] == "+15551234567"
|
||||
assert summary["assigned_phone_number"] == "+16282679185"
|
||||
|
||||
rendered: list[str] = []
|
||||
photon_auth.print_credential_summary(rendered.append)
|
||||
assert " my number : +15551234567" in rendered[0]
|
||||
assert " assigned number : +16282679185" in rendered[0]
|
||||
|
||||
|
||||
def test_load_user_numbers_falls_back_to_home_channel(
|
||||
tmp_hermes_home: Path,
|
||||
) -> None:
|
||||
from hermes_cli.config import save_env_value
|
||||
|
||||
save_env_value("PHOTON_HOME_CHANNEL", "+15551234567")
|
||||
|
||||
phone, assigned = photon_auth.load_user_numbers()
|
||||
assert phone == "+15551234567"
|
||||
assert assigned is None
|
||||
|
||||
|
||||
def test_refresh_user_numbers_reads_existing_assignment(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
photon_auth.store_user_numbers(phone_number="+15551234567")
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
assert kwargs.get("headers", {}).get("Authorization") == (
|
||||
"Basic " + b64encode(b"sp:secret").decode("ascii")
|
||||
)
|
||||
assert url.endswith("/projects/sp/users/")
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
|
||||
"id": "user-uuid",
|
||||
"phoneNumber": "+1 (555) 123-4567",
|
||||
"assignedPhoneNumber": "+16282679185",
|
||||
}]}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
|
||||
phone, assigned = photon_auth.refresh_user_numbers("sp", "secret")
|
||||
assert phone == "+15551234567"
|
||||
assert assigned == "+16282679185"
|
||||
assert photon_auth.load_user_numbers() == ("+15551234567", "+16282679185")
|
||||
|
||||
|
||||
def test_load_project_credentials_env_override(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id="from-file", project_secret="secret-file",
|
||||
)
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "from-env")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret-env")
|
||||
sid, secret = photon_auth.load_project_credentials()
|
||||
assert sid == "from-env"
|
||||
assert secret == "secret-env"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device login flow
|
||||
|
||||
def test_request_device_code_uses_photon_cli(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
captured["url"] = url
|
||||
captured["body"] = kwargs.get("json")
|
||||
return _FakeResponse(json_body={
|
||||
"device_code": "dev-code-xyz",
|
||||
"user_code": "ABCD-1234",
|
||||
"verification_uri": "https://app.photon.codes/device",
|
||||
"verification_uri_complete": "https://app.photon.codes/device?code=ABCD-1234",
|
||||
"expires_in": 600,
|
||||
"interval": 5,
|
||||
})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
|
||||
code = photon_auth.request_device_code()
|
||||
assert code.device_code == "dev-code-xyz"
|
||||
assert code.user_code == "ABCD-1234"
|
||||
assert "/api/auth/device/code" in captured["url"]
|
||||
# Hosted Photon allowlists registered device clients — an unregistered
|
||||
# client_id is rejected with 400 invalid_client. We use Photon's published
|
||||
# CLI device client and send the standard scope.
|
||||
assert captured["body"]["client_id"] == "photon-cli"
|
||||
assert captured["body"]["scope"] == "openid profile email"
|
||||
|
||||
|
||||
def _device_code() -> "photon_auth.DeviceCode":
|
||||
return photon_auth.DeviceCode(
|
||||
device_code="d", user_code="u",
|
||||
verification_uri="https://x", verification_uri_complete=None,
|
||||
expires_in=10, interval=0,
|
||||
)
|
||||
|
||||
|
||||
def test_poll_for_token_body_access_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(status=200, json_body={"access_token": "tok-body"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) == "tok-body"
|
||||
|
||||
|
||||
def test_poll_for_token_session_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(status=200, json_body={"session": {"access_token": "tok-sess"}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) == "tok-sess"
|
||||
|
||||
|
||||
def test_poll_for_token_header_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(status=200, json_body={}, headers={"set-auth-token": "tok-hdr"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) == "tok-hdr"
|
||||
|
||||
|
||||
def test_poll_for_token_pending_then_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return _FakeResponse(status=400, json_body={"error": "authorization_pending"})
|
||||
return _FakeResponse(status=200, json_body={"access_token": "tok-eventual"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=5) == "tok-eventual"
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_poll_for_token_access_denied(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(status=400, json_body={"error": "access_denied"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
with pytest.raises(RuntimeError, match="access_denied"):
|
||||
photon_auth.poll_for_token(_device_code(), interval=0, timeout=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projects
|
||||
|
||||
def test_list_projects_unwraps_list(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body=[{"id": "p1", "name": "Hermes Agent"}])
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
projects = photon_auth.list_projects("tok")
|
||||
assert projects[0]["id"] == "p1"
|
||||
|
||||
|
||||
def test_find_project_by_name_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body={"data": [
|
||||
{"id": "p1", "name": "Other"},
|
||||
{"id": "p2", "name": "hermes agent"},
|
||||
]})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
proj = photon_auth.find_project_by_name("tok", "Hermes Agent")
|
||||
assert proj is not None and proj["id"] == "p2"
|
||||
|
||||
|
||||
def test_create_project_omits_spectrum_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
captured["url"] = url
|
||||
captured["body"] = kwargs.get("json")
|
||||
captured["headers"] = kwargs.get("headers")
|
||||
return _FakeResponse(json_body={"success": True, "id": "new-proj"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
data = photon_auth.create_project("tok", name="Hermes Agent")
|
||||
assert data["id"] == "new-proj"
|
||||
# Spectrum is always provisioned at create-time; the field was dropped
|
||||
# from the API schema, so we must not send it.
|
||||
assert "spectrum" not in captured["body"]
|
||||
assert captured["body"]["name"] == "Hermes Agent"
|
||||
assert captured["headers"]["Authorization"] == "Bearer tok"
|
||||
assert captured["url"].endswith("/api/projects")
|
||||
|
||||
|
||||
def test_create_project_raises_without_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body={"success": True})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
with pytest.raises(RuntimeError, match="project id"):
|
||||
photon_auth.create_project("tok")
|
||||
|
||||
|
||||
def test_regenerate_project_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
assert url.endswith("/regenerate-secret")
|
||||
return _FakeResponse(json_body={"success": True, "projectSecret": "rotated"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
assert photon_auth.regenerate_project_secret("tok", "p") == "rotated"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Users
|
||||
|
||||
def test_create_user_rejects_invalid_phone() -> None:
|
||||
with pytest.raises(ValueError, match="E.164"):
|
||||
photon_auth.create_user("proj", "secret", phone_number="not-a-number")
|
||||
|
||||
|
||||
def test_create_user_posts_dashboard_shape(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
captured["url"] = url
|
||||
captured["body"] = kwargs.get("json")
|
||||
captured["headers"] = kwargs.get("headers")
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {
|
||||
"id": "user-uuid", "phoneNumber": "+15551234567",
|
||||
}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
user = photon_auth.create_user("proj-id", "secret", phone_number="+15551234567")
|
||||
assert user["id"] == "user-uuid"
|
||||
assert captured["body"]["type"] == "shared"
|
||||
assert captured["body"]["phoneNumber"] == "+15551234567"
|
||||
assert captured["headers"]["Authorization"] == (
|
||||
"Basic " + b64encode(b"proj-id:secret").decode("ascii")
|
||||
)
|
||||
assert captured["url"].endswith("/projects/proj-id/users/")
|
||||
|
||||
|
||||
def test_register_user_if_absent_dedup(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
posted = {"n": 0}
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
|
||||
"id": "u1",
|
||||
"phoneNumber": "+1 (555) 123-4567",
|
||||
"assignedPhoneNumber": "+16282679185",
|
||||
}]}})
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
posted["n"] += 1
|
||||
return _FakeResponse(json_body={"success": True, "user": {}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
# Same number, different formatting — should match and NOT create.
|
||||
user, created = photon_auth.register_user_if_absent(
|
||||
"proj", "secret", phone_number="+15551234567",
|
||||
)
|
||||
assert created is False
|
||||
assert user["id"] == "u1"
|
||||
assert posted["n"] == 0
|
||||
# The reused user carries the assigned iMessage line ("TEXTS ON").
|
||||
assert photon_auth.user_assigned_line(user) == "+16282679185"
|
||||
|
||||
|
||||
def test_user_assigned_line() -> None:
|
||||
assert (
|
||||
photon_auth.user_assigned_line({"assignedPhoneNumber": "+16282679185"})
|
||||
== "+16282679185"
|
||||
)
|
||||
# Own number present but no assignment yet (e.g. freshly created user).
|
||||
assert photon_auth.user_assigned_line({"phoneNumber": "+15551234567"}) is None
|
||||
assert photon_auth.user_assigned_line({"assignedPhoneNumber": ""}) is None
|
||||
assert photon_auth.user_assigned_line({}) is None
|
||||
assert photon_auth.user_assigned_line(None) is None
|
||||
|
||||
|
||||
def test_register_user_if_absent_creates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {"users": []}})
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {"id": "u-new"}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
user, created = photon_auth.register_user_if_absent(
|
||||
"proj", "secret", phone_number="+15551234567",
|
||||
)
|
||||
assert created is True
|
||||
assert user["id"] == "u-new"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lines (assigned number)
|
||||
|
||||
def test_get_imessage_line_returns_existing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body=[
|
||||
{"id": "l1", "platform": "imessage", "phoneNumber": "+15559999999", "status": "active"},
|
||||
])
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
line = photon_auth.get_imessage_line("tok", "proj")
|
||||
assert line is not None and line["phoneNumber"] == "+15559999999"
|
||||
|
||||
|
||||
def test_get_imessage_line_provisions_when_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
added = {"n": 0}
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body=[])
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
added["n"] += 1
|
||||
assert kwargs.get("json", {}).get("platform") == "imessage"
|
||||
return _FakeResponse(json_body={"success": True, "line": {
|
||||
"id": "l-new", "platform": "imessage", "phoneNumber": "+15558888888",
|
||||
}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
line = photon_auth.get_imessage_line("tok", "proj")
|
||||
assert added["n"] == 1
|
||||
assert line["phoneNumber"] == "+15558888888"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential summary (no secret leakage)
|
||||
|
||||
def test_credential_summary_no_secret_leak(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
|
||||
photon_auth.store_photon_token("token-aaaaaaaaaaaaaaaa")
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id="sp-uuid",
|
||||
project_secret="secret-bbbbbbbbbbb",
|
||||
dashboard_project_id="dash-uuid",
|
||||
)
|
||||
summary = photon_auth.credential_summary()
|
||||
blob = "\n".join(summary.values())
|
||||
assert "token-aaaa" not in blob
|
||||
assert "secret-bbbb" not in blob
|
||||
assert summary["device_token"].startswith("✓")
|
||||
assert summary["project_key"].startswith("✓")
|
||||
# Unified id: dashboard id == Spectrum id, surfaced as one project id.
|
||||
assert summary["project_id"] == "sp-uuid"
|
||||
assert summary["phone_number"].startswith("✗ missing")
|
||||
assert summary["assigned_phone_number"].startswith("✗ missing")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-token candidate extraction + dashboard validation.
|
||||
|
||||
def test_device_response_candidates_covers_known_shapes() -> None:
|
||||
candidates = photon_auth._device_response_token_candidates(
|
||||
{
|
||||
"access_token": "tok-snake",
|
||||
"accessToken": "tok-camel",
|
||||
"data": {"access_token": "tok-data"},
|
||||
},
|
||||
headers={"set-auth-token": "Bearer tok-header"},
|
||||
)
|
||||
by_source = {c.source: c.token for c in candidates}
|
||||
assert by_source["access_token"] == "tok-snake"
|
||||
assert by_source["accessToken"] == "tok-camel"
|
||||
assert by_source["data.access_token"] == "tok-data"
|
||||
# "Bearer " prefix is stripped from the header value.
|
||||
assert by_source["set-auth-token"] == "tok-header"
|
||||
|
||||
|
||||
def test_device_response_candidates_dedupes() -> None:
|
||||
candidates = photon_auth._device_response_token_candidates(
|
||||
{"access_token": "same", "accessToken": "same"},
|
||||
)
|
||||
assert [c.token for c in candidates] == ["same"]
|
||||
|
||||
|
||||
def test_validate_photon_token_rejects_unrecognized_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/get-session"):
|
||||
return _FakeResponse(json_body={}) # no "user" key
|
||||
return _FakeResponse(json_body=[])
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
with pytest.raises(photon_auth.PhotonDashboardAuthError):
|
||||
photon_auth.validate_photon_token("some-token")
|
||||
|
||||
|
||||
def test_validate_photon_token_rejects_project_api_denial(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/get-session"):
|
||||
return _FakeResponse(json_body={"user": {"id": "u1"}})
|
||||
return _FakeResponse(status=403) # project API rejects
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
with pytest.raises(photon_auth.PhotonDashboardAuthError):
|
||||
photon_auth.validate_photon_token("some-token")
|
||||
|
||||
|
||||
def test_login_device_flow_validates_before_persisting(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/device/code"):
|
||||
return _FakeResponse(json_body={
|
||||
"device_code": "dev", "user_code": "AAAA",
|
||||
"verification_uri": "https://app.photon.codes/device",
|
||||
"verification_uri_complete": None,
|
||||
"expires_in": 600, "interval": 0,
|
||||
})
|
||||
# device/token approval
|
||||
return _FakeResponse(json_body={"access_token": "good-token"})
|
||||
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/get-session"):
|
||||
return _FakeResponse(json_body={"user": {"id": "u1"}})
|
||||
return _FakeResponse(json_body=[]) # projects OK
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
|
||||
token = photon_auth.login_device_flow(open_browser=False)
|
||||
assert token == "good-token"
|
||||
assert photon_auth.load_photon_token() == "good-token"
|
||||
|
||||
|
||||
def test_login_device_flow_raises_when_token_invalid(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/device/code"):
|
||||
return _FakeResponse(json_body={
|
||||
"device_code": "dev", "user_code": "AAAA",
|
||||
"verification_uri": "https://app.photon.codes/device",
|
||||
"verification_uri_complete": None,
|
||||
"expires_in": 600, "interval": 0,
|
||||
})
|
||||
return _FakeResponse(json_body={"access_token": "bad-token"})
|
||||
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
return _FakeResponse(status=401) # session lookup rejects
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
|
||||
with pytest.raises(photon_auth.PhotonDashboardAuthError):
|
||||
photon_auth.login_device_flow(open_browser=False)
|
||||
# A token that failed validation must never be persisted.
|
||||
assert photon_auth.load_photon_token() is None
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Inbound dispatch + dedup tests for PhotonAdapter.
|
||||
|
||||
These bypass the loopback HTTP stream — they call ``_dispatch_inbound`` /
|
||||
``_on_inbound_line`` / ``_is_duplicate`` directly, exercising the
|
||||
sidecar-event parsing without spawning the Node sidecar or binding ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]:
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle)
|
||||
return captured
|
||||
|
||||
|
||||
def _dm_event(text: str, msg_id: str = "spc-msg-abc") -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": msg_id,
|
||||
"platform": "iMessage",
|
||||
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "text", "text": text},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_text_dm(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_dm_event("hello world"))
|
||||
|
||||
assert len(captured) == 1
|
||||
event = captured[0]
|
||||
assert event.text == "hello world"
|
||||
assert event.message_type == MessageType.TEXT
|
||||
assert event.message_id == "spc-msg-abc"
|
||||
src = event.source
|
||||
assert src is not None
|
||||
assert src.platform == Platform("photon")
|
||||
assert src.chat_id == "+15551234567"
|
||||
assert src.chat_type == "dm"
|
||||
assert src.user_id == "+15551234567"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_group_type(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
event = {
|
||||
"messageId": "spc-msg-grp",
|
||||
"space": {"id": "group-guid-xyz", "type": "group", "phone": None},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "text", "text": "hi group"},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
await adapter._dispatch_inbound(event)
|
||||
assert captured[0].source.chat_type == "group"
|
||||
|
||||
|
||||
# A real 1x1 transparent PNG (passes base.py's _looks_like_image magic check).
|
||||
_PNG_1X1_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhf"
|
||||
"DwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
def _attachment_event(
|
||||
content: Dict[str, Any], msg_id: str = "spc-msg-att"
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": msg_id,
|
||||
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "attachment", **content},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
def _voice_event(
|
||||
content: Dict[str, Any], msg_id: str = "spc-msg-voice"
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": msg_id,
|
||||
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "voice", **content},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_attachment_without_bytes_surfaces_marker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No inline ``data`` (over cap / failed sidecar read) -> text marker, no media."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
event = _attachment_event(
|
||||
{"name": "IMG_4127.HEIC", "mimeType": "image/heic", "size": 12345}
|
||||
)
|
||||
await adapter._dispatch_inbound(event)
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert "Photon attachment received" in ev.text
|
||||
assert "IMG_4127.HEIC" in ev.text
|
||||
assert ev.message_type == MessageType.PHOTO
|
||||
assert ev.media_urls == []
|
||||
assert ev.media_types == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_attachment_downloads_image(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Inline base64 image bytes are decoded, cached, and exposed as media."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
raw = base64.b64decode(_PNG_1X1_B64)
|
||||
event = _attachment_event(
|
||||
{
|
||||
"name": "photo.png",
|
||||
"mimeType": "image/png",
|
||||
"size": len(raw),
|
||||
"data": _PNG_1X1_B64,
|
||||
"encoding": "base64",
|
||||
}
|
||||
)
|
||||
await adapter._dispatch_inbound(event)
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert ev.message_type == MessageType.PHOTO
|
||||
assert ev.media_types == ["image/png"]
|
||||
assert len(ev.media_urls) == 1
|
||||
cached = Path(ev.media_urls[0])
|
||||
try:
|
||||
assert cached.is_file()
|
||||
assert cached.read_bytes() == raw
|
||||
assert ev.text == "(attachment)"
|
||||
finally:
|
||||
cached.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_group_preserves_text_and_attachment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Spectrum group content from a mixed text+image iMessage must not drop text."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
raw = base64.b64decode(_PNG_1X1_B64)
|
||||
|
||||
event = _attachment_event(
|
||||
{},
|
||||
msg_id="spc-msg-mixed",
|
||||
)
|
||||
event["content"] = {
|
||||
"type": "group",
|
||||
"items": [
|
||||
{
|
||||
"id": "p:0/spc-msg-mixed",
|
||||
"content": {"type": "text", "text": "请分析这张图的重点"},
|
||||
},
|
||||
{
|
||||
"id": "p:1/spc-msg-mixed",
|
||||
"content": {
|
||||
"type": "attachment",
|
||||
"name": "photo.png",
|
||||
"mimeType": "image/png",
|
||||
"size": len(raw),
|
||||
"data": _PNG_1X1_B64,
|
||||
"encoding": "base64",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await adapter._dispatch_inbound(event)
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert ev.text == "请分析这张图的重点"
|
||||
assert ev.message_type == MessageType.PHOTO
|
||||
assert ev.media_types == ["image/png"]
|
||||
assert len(ev.media_urls) == 1
|
||||
cached = Path(ev.media_urls[0])
|
||||
try:
|
||||
assert cached.is_file()
|
||||
assert cached.read_bytes() == raw
|
||||
finally:
|
||||
cached.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_voice_downloads_audio(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Inbound Spectrum voice content is cached and routed to auto-STT."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
raw = b"OggS" + b"\x00" * 32
|
||||
event = _voice_event(
|
||||
{
|
||||
"name": "note.ogg",
|
||||
"mimeType": "audio/ogg",
|
||||
"duration": 7,
|
||||
"size": len(raw),
|
||||
"data": base64.b64encode(raw).decode("ascii"),
|
||||
"encoding": "base64",
|
||||
}
|
||||
)
|
||||
await adapter._dispatch_inbound(event)
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert ev.message_type == MessageType.VOICE
|
||||
assert ev.media_types == ["audio/ogg"]
|
||||
assert len(ev.media_urls) == 1
|
||||
cached = Path(ev.media_urls[0])
|
||||
try:
|
||||
assert cached.is_file()
|
||||
assert cached.read_bytes() == raw
|
||||
assert ev.text == "(voice)"
|
||||
finally:
|
||||
cached.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_voice_without_bytes_surfaces_marker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Metadata-only voice still tells the agent a voice note arrived."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
event = _voice_event(
|
||||
{"name": "note.m4a", "mimeType": "audio/mp4", "duration": 12, "size": 12345}
|
||||
)
|
||||
await adapter._dispatch_inbound(event)
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert "Photon voice received" in ev.text
|
||||
assert "note.m4a" in ev.text
|
||||
assert "duration: 12s" in ev.text
|
||||
assert ev.message_type == MessageType.VOICE
|
||||
assert ev.media_urls == []
|
||||
assert ev.media_types == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_attachment_downloads_document(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Non-image attachments route through the document cache as DOCUMENT."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
raw = b"%PDF-1.4 hermes test document"
|
||||
event = _attachment_event(
|
||||
{
|
||||
"name": "report.pdf",
|
||||
"mimeType": "application/pdf",
|
||||
"size": len(raw),
|
||||
"data": base64.b64encode(raw).decode("ascii"),
|
||||
"encoding": "base64",
|
||||
}
|
||||
)
|
||||
await adapter._dispatch_inbound(event)
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert ev.message_type == MessageType.DOCUMENT
|
||||
assert ev.media_types == ["application/pdf"]
|
||||
assert len(ev.media_urls) == 1
|
||||
cached = Path(ev.media_urls[0])
|
||||
try:
|
||||
assert cached.is_file()
|
||||
assert cached.read_bytes() == raw
|
||||
assert ev.text == "(attachment)"
|
||||
finally:
|
||||
cached.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_inbound_line_dispatches_and_dedups(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
line = json.dumps(_dm_event("ping", msg_id="dup-1"))
|
||||
await adapter._on_inbound_line(line)
|
||||
await adapter._on_inbound_line(line) # same messageId -> deduped
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].text == "ping"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_inbound_line_ignores_bad_json(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._on_inbound_line("{not json")
|
||||
assert captured == []
|
||||
|
||||
|
||||
def test_is_duplicate_window(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter._is_duplicate("id-1") is False
|
||||
assert adapter._is_duplicate("id-1") is True
|
||||
assert adapter._is_duplicate("id-2") is False
|
||||
assert adapter._is_duplicate("id-1") is True # still dup
|
||||
|
||||
|
||||
def test_is_duplicate_hard_size_bound(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# A burst of unique ids within the window must not grow the dedup map past
|
||||
# its bound — evict oldest (LRU), not only expired entries.
|
||||
import plugins.platforms.photon.adapter as ad
|
||||
|
||||
monkeypatch.setattr(ad, "_DEDUP_MAX_SIZE", 5)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
for i in range(100):
|
||||
adapter._is_duplicate(f"id-{i}")
|
||||
assert len(adapter._seen_messages) <= 5
|
||||
assert adapter._is_duplicate("id-99") is True # recent still deduped
|
||||
assert adapter._is_duplicate("id-0") is False # oldest evicted
|
||||
|
||||
|
||||
def test_check_requirements_without_node(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# If no node binary on PATH the adapter should refuse to start.
|
||||
from plugins.platforms.photon import adapter as adapter_mod
|
||||
|
||||
monkeypatch.setattr(adapter_mod.shutil, "which", lambda _name: None)
|
||||
assert adapter_mod.check_requirements() is False
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Markdown handling tests for PhotonAdapter.
|
||||
|
||||
Markdown is on by default (the sidecar sends it via spectrum-ts'
|
||||
``markdown()`` builder and iMessage renders it); ``PHOTON_MARKDOWN=false``
|
||||
reverts to the stripped-plain-text path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
_MD = "**bold** and `code`"
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
calls: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
return {"ok": True, "messageId": "msg-123"}
|
||||
|
||||
adapter._sidecar_call = _fake_call # type: ignore[assignment]
|
||||
return calls
|
||||
|
||||
|
||||
def test_format_message_passthrough_by_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter.format_message(_MD) == _MD
|
||||
|
||||
|
||||
def test_format_message_strips_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PHOTON_MARKDOWN", "false")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter.format_message(_MD) == "bold and code"
|
||||
|
||||
|
||||
def test_supports_code_blocks_mirrors_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
assert _make_adapter(monkeypatch).supports_code_blocks is True
|
||||
monkeypatch.setenv("PHOTON_MARKDOWN", "false")
|
||||
assert _make_adapter(monkeypatch).supports_code_blocks is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sidecar_send_includes_markdown_format(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send("+15551234567", _MD)
|
||||
|
||||
path, body = calls[0]
|
||||
assert path == "/send"
|
||||
assert body["format"] == "markdown"
|
||||
assert body["text"] == _MD # passed through unstripped
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sidecar_send_omits_format_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Old-sidecar compat: the key is absent, not "text", when disabled."""
|
||||
monkeypatch.setenv("PHOTON_MARKDOWN", "false")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send("+15551234567", _MD)
|
||||
|
||||
_, body = calls[0]
|
||||
assert "format" not in body
|
||||
assert body["text"] == "bold and code"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_send_includes_markdown_format(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
|
||||
|
||||
posted: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json() -> Dict[str, Any]:
|
||||
return {"ok": True, "messageId": "m-9"}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def post(self, url: str, json: Dict[str, Any], headers=None):
|
||||
posted.append((url, json))
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
|
||||
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
result = await photon_adapter._standalone_send(cfg, "+15551234567", _MD)
|
||||
|
||||
assert result.get("success") is True
|
||||
assert posted[0][1]["format"] == "markdown"
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Group-chat mention-gating tests for PhotonAdapter.
|
||||
|
||||
Parity with the BlueBubbles iMessage channel: when ``require_mention`` is
|
||||
enabled, group messages are dropped unless they hit a wake-word pattern,
|
||||
and the leading wake word is stripped from the ones that pass. DMs are
|
||||
never gated.
|
||||
|
||||
These call ``_dispatch_inbound`` directly (no aiohttp / ports) and assert
|
||||
on what reaches ``handle_message``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch, extra: dict | None = None) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
monkeypatch.delenv("PHOTON_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("PHOTON_MENTION_PATTERNS", raising=False)
|
||||
cfg = PlatformConfig(enabled=True, token="", extra=extra or {})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _group_payload(text: str) -> dict:
|
||||
return {
|
||||
"messageId": f"grp-{abs(hash(text))}",
|
||||
"space": {"id": "group-guid-xyz", "type": "group", "phone": None},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "text", "text": text},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
def _dm_payload(text: str) -> dict:
|
||||
return {
|
||||
"messageId": f"dm-{abs(hash(text))}",
|
||||
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "text", "text": text},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
def _capture(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]:
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle)
|
||||
return captured
|
||||
|
||||
|
||||
def test_require_mention_defaults_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter.require_mention is False
|
||||
# Defaults compile to the two Hermes wake-word patterns.
|
||||
assert len(adapter._mention_patterns) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_dropped_without_mention(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_group_payload("just chatting, no wake word"))
|
||||
assert captured == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_passes_and_strips_wake_word(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_group_payload("Hermes what's the weather"))
|
||||
assert len(captured) == 1
|
||||
# Leading wake word stripped before dispatch.
|
||||
assert captured[0].text == "what's the weather"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_never_gated(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_dm_payload("no wake word here"))
|
||||
assert len(captured) == 1
|
||||
assert captured[0].text == "no wake word here"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_off_passes_group_messages(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch) # require_mention defaults off
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_group_payload("plain group chatter"))
|
||||
assert len(captured) == 1
|
||||
assert captured[0].text == "plain group chatter"
|
||||
|
||||
|
||||
def test_custom_mention_patterns_from_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(
|
||||
monkeypatch,
|
||||
extra={"require_mention": True, "mention_patterns": [r"(?<![\w@])@?amos\b[,:\-]?"]},
|
||||
)
|
||||
assert adapter.require_mention is True
|
||||
assert len(adapter._mention_patterns) == 1
|
||||
assert adapter._message_matches_mention_patterns("amos help me") is True
|
||||
assert adapter._message_matches_mention_patterns("hermes help me") is False
|
||||
|
||||
|
||||
def test_mention_patterns_env_comma_separated(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true")
|
||||
monkeypatch.setenv("PHOTON_MENTION_PATTERNS", r"bot\b, assistant\b")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
adapter = PhotonAdapter(cfg)
|
||||
assert adapter.require_mention is True
|
||||
assert len(adapter._mention_patterns) == 2
|
||||
assert adapter._message_matches_mention_patterns("hey bot") is True
|
||||
|
||||
|
||||
def test_invalid_pattern_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(
|
||||
monkeypatch,
|
||||
extra={"require_mention": True, "mention_patterns": ["(unclosed", r"good\b"]},
|
||||
)
|
||||
# Bad regex dropped, good one kept.
|
||||
assert len(adapter._mention_patterns) == 1
|
||||
assert adapter._message_matches_mention_patterns("a good thing") is True
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Outbound-media tests for PhotonAdapter.
|
||||
|
||||
Photon ships outbound attachments via spectrum-ts' ``attachment()`` /
|
||||
``voice()`` content builders, reached through the Node sidecar's
|
||||
``/send-attachment`` endpoint. These tests stub ``_sidecar_call`` so we
|
||||
can assert the endpoint + body shape each ``send_*`` override produces
|
||||
without spawning Node or binding ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
monkeypatch.delenv("PHOTON_WEBHOOK_SECRET", raising=False)
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
"""Replace ``_sidecar_call`` with a recorder that returns a fixed id."""
|
||||
calls: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
return {"ok": True, "messageId": "msg-123"}
|
||||
|
||||
adapter._sidecar_call = _fake_call # type: ignore[assignment]
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def real_file(tmp_path) -> str:
|
||||
p = tmp_path / "photo.jpg"
|
||||
p.write_bytes(b"\xff\xd8\xff\xe0fake-jpeg")
|
||||
return str(p)
|
||||
|
||||
|
||||
def _patch_safe_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Make path validation a passthrough so tmp files outside the cache pass."""
|
||||
monkeypatch.setattr(
|
||||
PhotonAdapter,
|
||||
"validate_media_delivery_path",
|
||||
staticmethod(lambda p: p if os.path.exists(p) else None),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_file_hits_attachment_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch, real_file: str
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
result = await adapter.send_image_file(
|
||||
"any;-;+15551234567", real_file, caption="look"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == "msg-123"
|
||||
assert len(calls) == 1
|
||||
path, body = calls[0]
|
||||
assert path == "/send-attachment"
|
||||
assert body["spaceId"] == "any;-;+15551234567"
|
||||
assert body["path"] == real_file
|
||||
assert body["kind"] == "attachment"
|
||||
assert body["caption"] == "look"
|
||||
assert body["mimeType"] == "image/jpeg" # inferred from .jpg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_voice_marks_kind_voice(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
audio = tmp_path / "note.m4a"
|
||||
audio.write_bytes(b"fake-audio")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
result = await adapter.send_voice("any;-;+1", str(audio))
|
||||
|
||||
assert result.success is True
|
||||
path, body = calls[0]
|
||||
assert path == "/send-attachment"
|
||||
assert body["kind"] == "voice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_document_passes_filename(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
doc = tmp_path / "report.pdf"
|
||||
doc.write_bytes(b"%PDF-1.4 fake")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send_document("any;-;+1", str(doc), file_name="Q3.pdf")
|
||||
|
||||
_, body = calls[0]
|
||||
assert body["kind"] == "attachment"
|
||||
assert body["name"] == "Q3.pdf"
|
||||
assert body["mimeType"] == "application/pdf"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_video_passes_through(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
vid = tmp_path / "clip.mp4"
|
||||
vid.write_bytes(b"fake-mp4")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send_video("any;+;groupguid", str(vid), caption="watch")
|
||||
|
||||
_, body = calls[0]
|
||||
assert body["kind"] == "attachment"
|
||||
assert body["caption"] == "watch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_url_caches_then_sends_attachment(
|
||||
monkeypatch: pytest.MonkeyPatch, real_file: str
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
async def _fake_cache(url: str, *a, **k) -> str:
|
||||
assert url == "https://example.com/cat.jpg"
|
||||
return real_file
|
||||
|
||||
import gateway.platforms.base as base_mod
|
||||
|
||||
monkeypatch.setattr(base_mod, "cache_image_from_url", _fake_cache)
|
||||
|
||||
result = await adapter.send_image(
|
||||
"any;-;+1", "https://example.com/cat.jpg", caption="cat"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
path, body = calls[0]
|
||||
assert path == "/send-attachment"
|
||||
assert body["path"] == real_file
|
||||
assert body["caption"] == "cat"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_url_fetch_failure_falls_back_to_text(
|
||||
monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
async def _boom(url: str, *a, **k) -> str:
|
||||
raise RuntimeError("network down")
|
||||
|
||||
import gateway.platforms.base as base_mod
|
||||
|
||||
monkeypatch.setattr(base_mod, "cache_image_from_url", _boom)
|
||||
|
||||
result = await adapter.send_image(
|
||||
"any;-;+1", "https://example.com/cat.jpg", caption="cat"
|
||||
)
|
||||
|
||||
# Fallback path: base send_image() routes to send() → /send (text).
|
||||
assert result.success is True
|
||||
assert calls[0][0] == "/send"
|
||||
assert "https://example.com/cat.jpg" in calls[0][1]["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_attachment_rejects_unsafe_path(
|
||||
monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Default validation (no passthrough patch) should reject a nonexistent /
|
||||
# traversal path, returning a failed SendResult without calling the sidecar.
|
||||
monkeypatch.setattr(
|
||||
PhotonAdapter,
|
||||
"validate_media_delivery_path",
|
||||
staticmethod(lambda p: None),
|
||||
)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
result = await adapter.send_image_file("any;-;+1", "/etc/passwd")
|
||||
|
||||
assert result.success is False
|
||||
assert "unsafe" in (result.error or "")
|
||||
assert calls == [] # never reached the sidecar
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_send_text_then_attachments(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
img = tmp_path / "a.png"
|
||||
img.write_bytes(b"\x89PNG fake")
|
||||
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
|
||||
|
||||
posted: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json() -> Dict[str, Any]:
|
||||
return {"ok": True, "messageId": "m-9"}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def post(self, url: str, json: Dict[str, Any], headers=None):
|
||||
posted.append((url, json))
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
|
||||
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
result = await photon_adapter._standalone_send(
|
||||
cfg,
|
||||
"any;-;+1",
|
||||
"hello",
|
||||
media_files=[(str(img), False)],
|
||||
)
|
||||
|
||||
assert result.get("success") is True
|
||||
# First call is the text /send, second is /send-attachment.
|
||||
assert posted[0][0].endswith("/send")
|
||||
assert posted[0][1]["text"] == "hello"
|
||||
assert posted[1][0].endswith("/send-attachment")
|
||||
assert posted[1][1]["path"] == str(img)
|
||||
assert posted[1][1]["kind"] == "attachment"
|
||||
assert posted[1][1]["mimeType"] == "image/png"
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Photon adapter resilience to transient Spectrum/Envoy upstream overflow.
|
||||
|
||||
Covers the three behaviors that let the adapter ride through a Photon
|
||||
"reset reason: overflow" event instead of degrading delivery and silently
|
||||
dying (issue #50185):
|
||||
|
||||
1. ``_is_retryable_error`` classifies the Envoy/sidecar overflow strings as
|
||||
retryable so ``_send_with_retry`` actually engages its backoff loop.
|
||||
2. ``send_typing`` is rate-gated per chat, and ``stop_typing`` resets the
|
||||
gate so the next turn's typing indicator fires immediately.
|
||||
3. ``_supervise_sidecar`` detects an unexpected sidecar exit and raises a
|
||||
``retryable=True`` fatal so the gateway reconnect watcher revives the
|
||||
platform — instead of returning silently and leaving ``_inbound_loop``
|
||||
spinning against a dead port.
|
||||
4. ``_monitor_sidecar_health`` promotes degraded upstream stream health
|
||||
reported by ``/healthz`` into the same retryable reconnect path.
|
||||
|
||||
No Node sidecar is spawned and no ports are bound.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
# -- Gap 1: retryable classification of overflow errors ---------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
"UNAVAILABLE: internal sidecar error",
|
||||
"upstream connect error or disconnect/reset before headers",
|
||||
"reset reason: overflow",
|
||||
# Case-insensitive: real strings arrive with mixed case.
|
||||
"Internal Sidecar Error",
|
||||
],
|
||||
)
|
||||
def test_overflow_strings_classified_retryable(error: str) -> None:
|
||||
assert PhotonAdapter._is_retryable_error(error) is True
|
||||
|
||||
|
||||
def test_unrelated_error_not_retryable() -> None:
|
||||
# A genuine permanent failure must NOT be retried.
|
||||
assert PhotonAdapter._is_retryable_error("400 bad request: invalid spaceId") is False
|
||||
assert PhotonAdapter._is_retryable_error(None) is False
|
||||
|
||||
|
||||
def test_base_network_patterns_still_match() -> None:
|
||||
# The override delegates to the base classifier first, so generic
|
||||
# network strings keep working.
|
||||
assert PhotonAdapter._is_retryable_error("ConnectError: connection refused") is True
|
||||
|
||||
|
||||
# -- Gap 2: typing-indicator cooldown ---------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_cooldown_suppresses_rapid_repeats(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls: list[Dict[str, Any]] = []
|
||||
|
||||
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
||||
calls.append(payload)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
||||
|
||||
# First call fires; immediate repeats are suppressed by the cooldown.
|
||||
await adapter.send_typing("chat-1")
|
||||
await adapter.send_typing("chat-1")
|
||||
await adapter.send_typing("chat-1")
|
||||
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_cooldown_is_per_chat(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls: list[str] = []
|
||||
|
||||
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
||||
calls.append(payload["spaceId"])
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
||||
|
||||
# Different chats have independent cooldowns.
|
||||
await adapter.send_typing("chat-1")
|
||||
await adapter.send_typing("chat-2")
|
||||
|
||||
assert calls == ["chat-1", "chat-2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_resets_cooldown(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
starts = 0
|
||||
|
||||
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
||||
nonlocal starts
|
||||
if payload.get("state") == "start":
|
||||
starts += 1
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
||||
|
||||
# A start, then a stop (end of turn), then a start for the next turn must
|
||||
# fire immediately — the cooldown only suppresses rapid consecutive starts
|
||||
# without an intervening stop.
|
||||
await adapter.send_typing("chat-1")
|
||||
await adapter.stop_typing("chat-1")
|
||||
await adapter.send_typing("chat-1")
|
||||
|
||||
assert starts == 2
|
||||
|
||||
|
||||
# -- Gap 3: sidecar crash detection -----------------------------------------
|
||||
|
||||
class _EofStdout:
|
||||
"""A proc.stdout whose readline() reports immediate EOF (dead sidecar)."""
|
||||
|
||||
def readline(self) -> bytes:
|
||||
return b""
|
||||
|
||||
|
||||
class _DeadProc:
|
||||
"""Minimal subprocess.Popen stand-in for a sidecar that has exited."""
|
||||
|
||||
def __init__(self, exit_code: int = 1) -> None:
|
||||
self.stdout = _EofStdout()
|
||||
self.stdin = None
|
||||
self._exit_code = exit_code
|
||||
|
||||
def poll(self) -> int:
|
||||
return self._exit_code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_sidecar_exit_raises_retryable_fatal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
# Simulate a live session whose sidecar then dies underneath it.
|
||||
adapter._inbound_running = True
|
||||
|
||||
notified: list[bool] = []
|
||||
|
||||
async def _fake_notify() -> None:
|
||||
notified.append(True)
|
||||
|
||||
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
|
||||
|
||||
await adapter._supervise_sidecar(_DeadProc(exit_code=137)) # type: ignore[arg-type]
|
||||
|
||||
assert adapter.has_fatal_error is True
|
||||
assert adapter.fatal_error_code == "SIDECAR_CRASHED"
|
||||
# retryable=True routes the platform into the reconnect watcher rather
|
||||
# than crashing the whole gateway.
|
||||
assert adapter.fatal_error_retryable is True
|
||||
assert adapter._running is False
|
||||
assert notified == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clean_shutdown_does_not_raise_fatal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
# disconnect() sets _inbound_running = False before stopping the sidecar,
|
||||
# so the detection block must NOT fire on a clean shutdown.
|
||||
adapter._inbound_running = False
|
||||
|
||||
notified: list[bool] = []
|
||||
|
||||
async def _fake_notify() -> None:
|
||||
notified.append(True)
|
||||
|
||||
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
|
||||
|
||||
await adapter._supervise_sidecar(_DeadProc(exit_code=0)) # type: ignore[arg-type]
|
||||
|
||||
assert adapter.has_fatal_error is False
|
||||
assert notified == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_degraded_stream_health_raises_retryable_fatal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter._inbound_running = True
|
||||
adapter._sidecar_health_interval = 0.0
|
||||
|
||||
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
||||
assert path == "/healthz"
|
||||
return {
|
||||
"ok": True,
|
||||
"stream": {
|
||||
"ok": False,
|
||||
"state": "degraded",
|
||||
"degradedForMs": 120000,
|
||||
"lastIssue": "[spectrum.stream] stream interrupted; reconnecting",
|
||||
},
|
||||
}
|
||||
|
||||
notified: list[bool] = []
|
||||
|
||||
async def _fake_notify() -> None:
|
||||
notified.append(True)
|
||||
adapter._inbound_running = False
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
||||
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
|
||||
|
||||
await adapter._monitor_sidecar_health()
|
||||
|
||||
assert adapter.has_fatal_error is True
|
||||
assert adapter.fatal_error_code == "UPSTREAM_STREAM_DEGRADED"
|
||||
assert adapter.fatal_error_retryable is True
|
||||
assert notified == [True]
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Reaction (tapback) tests for PhotonAdapter.
|
||||
|
||||
Outbound reactions go through the sidecar's ``/react`` / ``/unreact``
|
||||
endpoints; these tests stub ``_sidecar_call`` to assert endpoint + body
|
||||
shape. Inbound reaction events are fed straight to ``_dispatch_inbound``.
|
||||
Neither path spawns the Node sidecar or binds ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
_EYES = "\U0001f440"
|
||||
_THUMBS_UP = "\U0001f44d"
|
||||
_THUMBS_DOWN = "\U0001f44e"
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
calls: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
return {"ok": True, "messageId": "msg-123", "reactionId": "react-1"}
|
||||
|
||||
adapter._sidecar_call = _fake_call # type: ignore[assignment]
|
||||
return calls
|
||||
|
||||
|
||||
def _capture_handled(
|
||||
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> List[MessageEvent]:
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle)
|
||||
return captured
|
||||
|
||||
|
||||
def _message_event(adapter: PhotonAdapter) -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text="hi",
|
||||
message_type=MessageType.TEXT,
|
||||
source=adapter.build_source(
|
||||
chat_id="+15551234567",
|
||||
chat_name="+15551234567",
|
||||
chat_type="dm",
|
||||
user_id="+15551234567",
|
||||
user_name=None,
|
||||
),
|
||||
message_id="target-msg-1",
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _reaction_event(
|
||||
emoji: str = "❤️",
|
||||
target_id: str = "bot-msg-1",
|
||||
target_direction: Any = "outbound",
|
||||
space_type: str = "dm",
|
||||
target_text: Any = "the bot's earlier reply",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": "reaction-evt-1",
|
||||
"platform": "iMessage",
|
||||
"space": {"id": "+15551234567", "type": space_type, "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {
|
||||
"type": "reaction",
|
||||
"emoji": emoji,
|
||||
"targetMessageId": target_id,
|
||||
"targetDirection": target_direction,
|
||||
# The sidecar always emits this key (hydrated reaction target);
|
||||
# null when the reacted-to message carried no text.
|
||||
"targetText": target_text,
|
||||
},
|
||||
"timestamp": "2026-06-11T10:00:00.000Z",
|
||||
}
|
||||
|
||||
|
||||
# -- Outbound: /react and /unreact body shapes ------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_reaction_posts_react(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
ok = await adapter._add_reaction("+15551234567", "target-msg-1", _EYES)
|
||||
|
||||
assert ok is True
|
||||
assert calls == [
|
||||
(
|
||||
"/react",
|
||||
{
|
||||
"spaceId": "+15551234567",
|
||||
"messageId": "target-msg-1",
|
||||
"emoji": _EYES,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_reaction_posts_unreact(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
ok = await adapter._remove_reaction("+15551234567", "target-msg-1")
|
||||
|
||||
assert ok is True
|
||||
assert calls == [
|
||||
("/unreact", {"spaceId": "+15551234567", "messageId": "target-msg-1"})
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaction_failure_is_soft(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
|
||||
async def _boom(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise RuntimeError("sidecar down")
|
||||
|
||||
adapter._sidecar_call = _boom # type: ignore[assignment]
|
||||
|
||||
assert await adapter._add_reaction("+1", "m", _EYES) is False
|
||||
assert await adapter._remove_reaction("+1", "m") is False
|
||||
|
||||
|
||||
# -- Lifecycle hooks ---------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hooks_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("PHOTON_REACTIONS", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
event = _message_event(adapter)
|
||||
await adapter.on_processing_start(event)
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_start_adds_eyes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PHOTON_REACTIONS", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.on_processing_start(_message_event(adapter))
|
||||
|
||||
assert len(calls) == 1
|
||||
path, body = calls[0]
|
||||
assert path == "/react"
|
||||
assert body["emoji"] == _EYES
|
||||
assert body["messageId"] == "target-msg-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_success_swaps_to_thumbs_up(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PHOTON_REACTIONS", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.on_processing_complete(
|
||||
_message_event(adapter), ProcessingOutcome.SUCCESS
|
||||
)
|
||||
|
||||
assert [path for path, _ in calls] == ["/unreact", "/react"]
|
||||
assert calls[1][1]["emoji"] == _THUMBS_UP
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_failure_swaps_to_thumbs_down(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PHOTON_REACTIONS", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.on_processing_complete(
|
||||
_message_event(adapter), ProcessingOutcome.FAILURE
|
||||
)
|
||||
|
||||
assert [path for path, _ in calls] == ["/unreact", "/react"]
|
||||
assert calls[1][1]["emoji"] == _THUMBS_DOWN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_cancelled_only_removes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PHOTON_REACTIONS", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.on_processing_complete(
|
||||
_message_event(adapter), ProcessingOutcome.CANCELLED
|
||||
)
|
||||
|
||||
assert [path for path, _ in calls] == ["/unreact"]
|
||||
|
||||
|
||||
# -- Inbound reaction routing ------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_on_bot_message_routed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_reaction_event(emoji="❤️"))
|
||||
|
||||
assert len(captured) == 1
|
||||
event = captured[0]
|
||||
assert event.text == "reaction:added:❤️"
|
||||
assert event.message_type == MessageType.TEXT
|
||||
assert event.source.chat_id == "+15551234567"
|
||||
# The tapback correlates to the bot message it reacted to, so the gateway
|
||||
# can inject `[Replying to your previous message: "..."]` for context.
|
||||
assert event.reply_to_message_id == "bot-msg-1"
|
||||
assert event.reply_to_text == "the bot's earlier reply"
|
||||
assert event.reply_to_is_own_message is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_without_target_text_correlates_id_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A tapback on an attachment-only bot message (no text) still correlates the
|
||||
id, but leaves reply_to_text unset — the gateway then skips the reply pointer
|
||||
(it injects only when both id and text are present)."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_reaction_event(target_text=None))
|
||||
|
||||
assert len(captured) == 1
|
||||
event = captured[0]
|
||||
assert event.reply_to_message_id == "bot-msg-1"
|
||||
assert event.reply_to_text is None
|
||||
assert event.reply_to_is_own_message is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_sent_ids_fallback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No targetDirection from the provider — gate on our own sent-id cache."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
adapter._record_sent_message("bot-msg-1")
|
||||
|
||||
await adapter._dispatch_inbound(
|
||||
_reaction_event(target_id="bot-msg-1", target_direction=None)
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_on_foreign_message_dropped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(
|
||||
_reaction_event(target_id="someone-elses-msg", target_direction=None)
|
||||
)
|
||||
|
||||
assert captured == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_bypasses_require_mention(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A tapback never carries a wake word — it must skip group gating."""
|
||||
monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_reaction_event(space_type="group"))
|
||||
|
||||
assert len(captured) == 1
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for `hermes photon setup`'s access auto-configuration.
|
||||
|
||||
`_autoconfigure_access` allowlists the operator and points the cron home
|
||||
channel at their DM, writing to the per-test ~/.hermes/.env (the hermetic
|
||||
HERMES_HOME fixture isolates this). It must fill only unset keys so a re-run
|
||||
never clobbers a hand-tuned allowlist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
from plugins.platforms.photon.adapter import _env_enablement
|
||||
from plugins.platforms.photon import cli
|
||||
|
||||
|
||||
def test_autoconfigure_access_fills_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("PHOTON_ALLOWED_USERS", raising=False)
|
||||
monkeypatch.delenv("PHOTON_HOME_CHANNEL", raising=False)
|
||||
|
||||
cli._autoconfigure_access("+15551234567")
|
||||
|
||||
assert get_env_value("PHOTON_ALLOWED_USERS") == "+15551234567"
|
||||
assert get_env_value("PHOTON_HOME_CHANNEL") == "+15551234567"
|
||||
|
||||
|
||||
def test_autoconfigure_access_preserves_existing_allowlist(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_ALLOWED_USERS", raising=False)
|
||||
monkeypatch.delenv("PHOTON_HOME_CHANNEL", raising=False)
|
||||
# A hand-tuned allowlist already in place must survive a setup re-run.
|
||||
save_env_value("PHOTON_ALLOWED_USERS", "+19998887777,+15551112222")
|
||||
|
||||
cli._autoconfigure_access("+15551234567")
|
||||
|
||||
assert get_env_value("PHOTON_ALLOWED_USERS") == "+19998887777,+15551112222"
|
||||
# The still-unset home channel is filled.
|
||||
assert get_env_value("PHOTON_HOME_CHANNEL") == "+15551234567"
|
||||
|
||||
|
||||
def test_env_enablement_seeds_home_channel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "project_123")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret_123")
|
||||
monkeypatch.setenv("PHOTON_HOME_CHANNEL", "+15551234567")
|
||||
monkeypatch.setenv("PHOTON_HOME_CHANNEL_NAME", "Primary DM")
|
||||
|
||||
seed = _env_enablement()
|
||||
|
||||
assert seed is not None
|
||||
assert seed["home_channel"] == {
|
||||
"chat_id": "+15551234567",
|
||||
"name": "Primary DM",
|
||||
}
|
||||
|
||||
|
||||
def test_env_enablement_home_channel_defaults_name(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "project_123")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret_123")
|
||||
monkeypatch.setenv("PHOTON_HOME_CHANNEL", "+15551234567")
|
||||
monkeypatch.delenv("PHOTON_HOME_CHANNEL_NAME", raising=False)
|
||||
|
||||
seed = _env_enablement()
|
||||
|
||||
assert seed is not None
|
||||
assert seed["home_channel"] == {
|
||||
"chat_id": "+15551234567",
|
||||
"name": "Home",
|
||||
}
|
||||
|
||||
|
||||
def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
|
||||
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
|
||||
# The dashboard id *is* the Spectrum project id (ids unified), so setup no
|
||||
# longer enables Spectrum or fetches a separate spectrumProjectId — it
|
||||
# reuses this id directly.
|
||||
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"regenerate_project_secret",
|
||||
lambda token, dashboard_id: "secret_123",
|
||||
)
|
||||
monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"register_user_if_absent",
|
||||
lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+15551234567"}, True),
|
||||
)
|
||||
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+15557654321")
|
||||
monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None)
|
||||
monkeypatch.setattr(cli, "_install_sidecar", lambda: 0)
|
||||
|
||||
rc = cli._cmd_setup(
|
||||
argparse.Namespace(
|
||||
project_name=None,
|
||||
phone="+15551234567",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
email=None,
|
||||
no_browser=True,
|
||||
skip_sidecar_install=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Start the gateway: hermes gateway start" in out
|
||||
assert "--platform photon" not in out
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Regression tests for the Photon sidecar stale-dependency self-heal.
|
||||
|
||||
A `hermes update` that bumps the spectrum-ts pin rewrites the sidecar's
|
||||
``package-lock.json`` but never reinstalls ``node_modules``, so the sidecar
|
||||
spawns against stale deps and dies on every reconnect. ``_sidecar_deps_stale``
|
||||
detects that skew (lockfile newer than npm's install marker) so
|
||||
``_start_sidecar`` can reinstall before spawning.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import plugins.platforms.photon.adapter as photon_adapter
|
||||
|
||||
|
||||
def _seed(sidecar: Path, *, lock_mtime: float, marker_mtime: float | None) -> None:
|
||||
"""Create a fake sidecar dir with a lockfile and (optionally) npm's marker."""
|
||||
(sidecar / "node_modules").mkdir(parents=True)
|
||||
lock = sidecar / "package-lock.json"
|
||||
lock.write_text("{}", encoding="utf-8")
|
||||
os.utime(lock, (lock_mtime, lock_mtime))
|
||||
if marker_mtime is not None:
|
||||
marker = sidecar / "node_modules" / ".package-lock.json"
|
||||
marker.write_text("{}", encoding="utf-8")
|
||||
os.utime(marker, (marker_mtime, marker_mtime))
|
||||
|
||||
|
||||
def test_stale_when_lockfile_newer_than_marker(tmp_path, monkeypatch) -> None:
|
||||
"""The update-rewrites-lockfile-but-skips-install case must reinstall."""
|
||||
sidecar = tmp_path / "sidecar"
|
||||
_seed(sidecar, lock_mtime=2000.0, marker_mtime=1000.0)
|
||||
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
|
||||
assert photon_adapter._sidecar_deps_stale() is True
|
||||
|
||||
|
||||
def test_fresh_when_marker_newer_than_lockfile(tmp_path, monkeypatch) -> None:
|
||||
"""A normal install (marker at/after lockfile) must NOT trigger a reinstall."""
|
||||
sidecar = tmp_path / "sidecar"
|
||||
_seed(sidecar, lock_mtime=1000.0, marker_mtime=2000.0)
|
||||
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
|
||||
assert photon_adapter._sidecar_deps_stale() is False
|
||||
|
||||
|
||||
def test_not_stale_when_marker_missing(tmp_path, monkeypatch) -> None:
|
||||
"""No marker (first run / unreadable) must fail safe to False, never block start."""
|
||||
sidecar = tmp_path / "sidecar"
|
||||
_seed(sidecar, lock_mtime=2000.0, marker_mtime=None)
|
||||
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
|
||||
assert photon_adapter._sidecar_deps_stale() is False
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Sidecar lifecycle tests: orphan reaping and parent-death wiring.
|
||||
|
||||
A hard gateway exit used to leave the detached Node sidecar squatting the
|
||||
loopback port with a token the next gateway run doesn't know — every
|
||||
replacement spawn then died on EADDRINUSE. These tests cover the startup
|
||||
reaper (`_reap_stale_sidecar`) and the stdin-pipe lifetime binding, without
|
||||
spawning Node or binding ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
class _ProbeClient:
|
||||
"""Fake httpx.AsyncClient whose /healthz probe behavior is injectable."""
|
||||
|
||||
connects = True
|
||||
|
||||
def __init__(self, *a: Any, **k: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "_ProbeClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a: Any) -> bool:
|
||||
return False
|
||||
|
||||
async def post(self, *a: Any, **k: Any) -> Any:
|
||||
if not self.connects:
|
||||
raise photon_adapter.httpx.ConnectError("connection refused")
|
||||
|
||||
class _Resp:
|
||||
status_code = 401 # orphan with a different token
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
def _capture_kills(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[int, int]]:
|
||||
kills: List[Tuple[int, int]] = []
|
||||
|
||||
def _fake_kill(pid: int, sig: int) -> None:
|
||||
kills.append((pid, sig))
|
||||
|
||||
monkeypatch.setattr(photon_adapter.os, "kill", _fake_kill)
|
||||
return kills
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reap_noop_when_port_free(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
|
||||
class _Refused(_ProbeClient):
|
||||
connects = False
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _Refused)
|
||||
kills = _capture_kills(monkeypatch)
|
||||
|
||||
await adapter._reap_stale_sidecar()
|
||||
|
||||
assert kills == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reap_kills_verified_orphan(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _ProbeClient)
|
||||
monkeypatch.setattr(adapter, "_find_listener_pids", lambda port: [4242])
|
||||
monkeypatch.setattr(adapter, "_pid_is_sidecar", lambda pid: True)
|
||||
# Dies promptly on SIGTERM — no escalation expected.
|
||||
monkeypatch.setattr(adapter, "_pid_alive", lambda pid: False)
|
||||
kills = _capture_kills(monkeypatch)
|
||||
|
||||
await adapter._reap_stale_sidecar()
|
||||
|
||||
assert kills == [(4242, photon_adapter.signal.SIGTERM)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reap_escalates_to_sigkill(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _ProbeClient)
|
||||
monkeypatch.setattr(adapter, "_find_listener_pids", lambda port: [4242])
|
||||
monkeypatch.setattr(adapter, "_pid_is_sidecar", lambda pid: True)
|
||||
monkeypatch.setattr(adapter, "_pid_alive", lambda pid: True) # ignores TERM
|
||||
# No clock fakery (logging also calls time.time, which makes a fake clock
|
||||
# fragile) — this test rides out the real 3s SIGTERM grace window.
|
||||
kills = _capture_kills(monkeypatch)
|
||||
|
||||
await adapter._reap_stale_sidecar()
|
||||
|
||||
assert (4242, photon_adapter.signal.SIGTERM) in kills
|
||||
assert (4242, photon_adapter.signal.SIGKILL) in kills
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reap_raises_for_foreign_listener(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Never signal a process whose command line isn't our sidecar."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _ProbeClient)
|
||||
monkeypatch.setattr(adapter, "_find_listener_pids", lambda port: [777])
|
||||
monkeypatch.setattr(adapter, "_pid_is_sidecar", lambda pid: False)
|
||||
kills = _capture_kills(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError, match="in use by another process"):
|
||||
await adapter._reap_stale_sidecar()
|
||||
|
||||
assert kills == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sidecar_spawns_with_stdin_pipe(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""The spawn must hold a stdin pipe and enable the sidecar's EOF watch."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
|
||||
async def _no_reap() -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(adapter, "_reap_stale_sidecar", _no_reap)
|
||||
(tmp_path / "node_modules").mkdir()
|
||||
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", tmp_path)
|
||||
|
||||
spawned: Dict[str, Any] = {}
|
||||
|
||||
class _FakeProc:
|
||||
pid = 999
|
||||
stdout = None
|
||||
stdin = None
|
||||
|
||||
@staticmethod
|
||||
def poll() -> None:
|
||||
return None
|
||||
|
||||
def _fake_popen(cmd: List[str], **kwargs: Any) -> _FakeProc:
|
||||
spawned["cmd"] = cmd
|
||||
spawned["kwargs"] = kwargs
|
||||
return _FakeProc()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.subprocess, "Popen", _fake_popen)
|
||||
|
||||
class _HealthyClient(_ProbeClient):
|
||||
async def post(self, *a: Any, **k: Any) -> Any:
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _HealthyClient)
|
||||
|
||||
await adapter._start_sidecar()
|
||||
|
||||
kwargs = spawned["kwargs"]
|
||||
assert kwargs["stdin"] is subprocess.PIPE
|
||||
assert kwargs["env"]["PHOTON_SIDECAR_WATCH_STDIN"] == "1"
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Regression tests for Hermes' Spectrum mixed text+attachment workaround."""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_PATCHER = Path("plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs")
|
||||
|
||||
|
||||
def test_sidecar_applies_spectrum_patch_before_importing_sdk() -> None:
|
||||
"""Existing installs should self-heal at runtime, not only during npm postinstall."""
|
||||
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
|
||||
assert "import { patchSpectrumTs }" in index
|
||||
assert "patchSpectrumTs();" in index
|
||||
assert index.index("patchSpectrumTs();") < index.index('await import("spectrum-ts")')
|
||||
|
||||
|
||||
def test_sidecar_healthz_reports_stream_health() -> None:
|
||||
"""Local process health must include upstream stream health."""
|
||||
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
|
||||
assert "function streamHealthSnapshot()" in index
|
||||
assert 'return ok(res, { stream: streamHealthSnapshot() });' in index
|
||||
assert "STREAM_INTERRUPTED_DEGRADE_COUNT" in index
|
||||
assert "process.exit(75);" in index
|
||||
|
||||
|
||||
def test_sidecar_intercepts_both_console_channels() -> None:
|
||||
"""spectrum-ts routes its stream telemetry through @photon-ai/otel, which
|
||||
sends severity >= ERROR to console.error and WARN/INFO to console.log.
|
||||
The two lines the health monitor keys off land on *different* channels:
|
||||
`log.error("stream persistently failing")` -> console.error, but
|
||||
`log.warn("stream interrupted; reconnecting")` -> console.log. Patching
|
||||
only console.error would miss every interrupt burst (the primary silent-
|
||||
inbound symptom), so both channels must be intercepted.
|
||||
"""
|
||||
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
|
||||
assert "function classifyStreamLog(" in index
|
||||
assert "console.error = (...args) =>" in index
|
||||
assert "console.log = (...args) =>" in index
|
||||
# Both wrappers must feed the shared classifier.
|
||||
assert index.count("classifyStreamLog(text)") >= 2
|
||||
|
||||
|
||||
def test_sidecar_labels_catchup_internal_errors_as_upstream_photon() -> None:
|
||||
"""Photon cloud stream failures should not look like local auth problems."""
|
||||
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
|
||||
assert "function inboundStreamErrorMessage" in index
|
||||
assert "EventService/CatchUpEvents" in index
|
||||
assert "this is upstream of Hermes" in index
|
||||
assert "PHOTON_ALLOWED_USERS" in index
|
||||
|
||||
|
||||
def _tabify(src: str) -> str:
|
||||
"""Convert the fixture's two-space indentation to the tab indentation that
|
||||
spectrum-ts ships in `@spectrum-ts/imessage/dist`, so the patch anchors
|
||||
(which match tabs) apply exactly as they do against a real install."""
|
||||
out = []
|
||||
for line in src.split("\n"):
|
||||
stripped = line.lstrip(" ")
|
||||
indent = len(line) - len(stripped)
|
||||
out.append("\t" * (indent // 2) + " " * (indent % 2) + stripped)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# A faithful, *executable* slice of spectrum-ts 8.x's iMessage inbound mapper:
|
||||
# the two functions the patch rewrites (`rebuildFromAppleMessage` for
|
||||
# `space.getMessage`, `toInboundMessages` for the live stream), plus stubs of
|
||||
# the helpers they close over. Mirrors the published shape — tab-indented (via
|
||||
# `_tabify`), `const ... = async` declarations, single-line builder calls — so
|
||||
# the anchors exercise the real code path, and exporting the two functions lets
|
||||
# the test assert runtime behavior rather than only string shape.
|
||||
_SPECTRUM_IMESSAGE_FIXTURE = """
|
||||
const formatChildId = (partIndex, parentGuid) => `p:${partIndex}/${parentGuid}`;
|
||||
const asText = (text) => ({ type: "text", text });
|
||||
const asCustom = (message) => ({ type: "custom" });
|
||||
const asProviderGroup = (items) => ({ type: "group", items });
|
||||
const messageAttachments = (message) => message.content.attachments ?? [];
|
||||
const buildMessageBase = (message, chatGuidHint, timestamp, phone) => ({ direction: "inbound", sender: { id: "s" }, space: { id: "sp", type: "dm", phone }, timestamp });
|
||||
const buildAttachmentMessage = async (client, base, info, id, partIndex, parentId) => {
|
||||
const msg = { ...base, id, content: { type: "attachment", id: info.guid }, partIndex };
|
||||
if (parentId !== void 0) msg.parentId = parentId;
|
||||
return msg;
|
||||
};
|
||||
const cacheMessage = (cache, message) => { cache.set(message.id, message); };
|
||||
const rebuildFromAppleMessage = async (client, message, phone, chatGuidHint) => {
|
||||
const messageGuidStr = message.guid;
|
||||
const base = buildMessageBase(message, chatGuidHint, message.dateCreated ?? /* @__PURE__ */ new Date(), phone);
|
||||
const attachments = messageAttachments(message);
|
||||
if (attachments.length === 1) {
|
||||
const info = attachments[0];
|
||||
if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
|
||||
return buildAttachmentMessage(client, base, info, messageGuidStr, 0);
|
||||
}
|
||||
if (attachments.length > 1) {
|
||||
const items = [];
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
const info = attachments[i];
|
||||
if (!info) continue;
|
||||
items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
id: messageGuidStr,
|
||||
content: asProviderGroup(items)
|
||||
};
|
||||
}
|
||||
const text = message.content.text;
|
||||
return {
|
||||
...base,
|
||||
id: messageGuidStr,
|
||||
content: text ? asText(text) : asCustom(message)
|
||||
};
|
||||
};
|
||||
const toInboundMessages = async (client, cache, event, phone) => {
|
||||
const base = buildMessageBase(event.message, event.chatGuid, event.occurredAt, phone);
|
||||
const messageGuidStr = event.message.guid;
|
||||
const attachments = messageAttachments(event.message);
|
||||
if (attachments.length === 1) {
|
||||
const info = attachments[0];
|
||||
if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
|
||||
const msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0);
|
||||
cacheMessage(cache, msg);
|
||||
return [msg];
|
||||
}
|
||||
if (attachments.length > 1) {
|
||||
const items = [];
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
const info = attachments[i];
|
||||
if (!info) continue;
|
||||
items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
|
||||
}
|
||||
const parent = {
|
||||
...base,
|
||||
id: messageGuidStr,
|
||||
content: asProviderGroup(items)
|
||||
};
|
||||
cacheMessage(cache, parent);
|
||||
return [parent];
|
||||
}
|
||||
const text = event.message.content.text;
|
||||
const msg = {
|
||||
...base,
|
||||
id: messageGuidStr,
|
||||
content: text ? asText(text) : asCustom(event.message)
|
||||
};
|
||||
cacheMessage(cache, msg);
|
||||
return [msg];
|
||||
};
|
||||
export { rebuildFromAppleMessage, toInboundMessages };
|
||||
"""
|
||||
|
||||
|
||||
def _write_fixture(tmp_path: Path) -> Path:
|
||||
dist = tmp_path / "node_modules" / "@spectrum-ts" / "imessage" / "dist"
|
||||
dist.mkdir(parents=True)
|
||||
chunk = dist / "index.js"
|
||||
chunk.write_text(_tabify(_SPECTRUM_IMESSAGE_FIXTURE), encoding="utf-8")
|
||||
return chunk
|
||||
|
||||
|
||||
def test_spectrum_patch_rewrites_the_imessage_mapper(tmp_path: Path) -> None:
|
||||
"""The dependency patch must apply to the 8.x `@spectrum-ts/imessage` chunk
|
||||
and rewrite both inbound mappers to thread text through attachment bubbles."""
|
||||
chunk = _write_fixture(tmp_path)
|
||||
|
||||
result = subprocess.run(
|
||||
["node", str(_PATCHER), str(tmp_path)],
|
||||
cwd=Path.cwd(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
patched = chunk.read_text(encoding="utf-8")
|
||||
assert "Preserve mixed text + attachment iMessage payloads" in patched
|
||||
# Single-attachment bubbles wrap the text + attachment in a group...
|
||||
assert "content: asProviderGroup([textMsg, msg2])" in patched # rebuild
|
||||
assert "content: asProviderGroup([textMsg, msg])" in patched # inbound
|
||||
# ...multi-attachment bubbles keep the group and shift attachment indices.
|
||||
assert "content: asProviderGroup(items)" in patched
|
||||
assert "formatChildId(text2 ? i + 1 : i, messageGuidStr)" in patched
|
||||
# The text is captured in both mappers before the attachment branches run.
|
||||
assert "const text2 = message.content.text;" in patched
|
||||
assert "const text2 = event.message.content.text;" in patched
|
||||
|
||||
# Re-running is a no-op (idempotent self-heal on every sidecar start).
|
||||
again = subprocess.run(
|
||||
["node", str(_PATCHER), str(tmp_path)],
|
||||
cwd=Path.cwd(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert again.returncode == 0, again.stderr
|
||||
assert chunk.read_text(encoding="utf-8") == patched
|
||||
|
||||
|
||||
def test_spectrum_patch_preserves_text_at_runtime(tmp_path: Path) -> None:
|
||||
"""Execute the patched mappers and assert mixed bubbles become groups whose
|
||||
first child is the typed text, while text-free bubbles keep their exact
|
||||
original shape (id/partIndex/parentId) so message identity is unchanged."""
|
||||
chunk = _write_fixture(tmp_path)
|
||||
patch = subprocess.run(
|
||||
["node", str(_PATCHER), str(tmp_path)],
|
||||
cwd=Path.cwd(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert patch.returncode == 0, patch.stderr
|
||||
|
||||
harness = textwrap.dedent(
|
||||
f"""
|
||||
import {{ rebuildFromAppleMessage, toInboundMessages }} from {str(chunk)!r};
|
||||
const assert = (c, m) => {{ if (!c) {{ console.error("FAIL: " + m); process.exit(1); }} }};
|
||||
|
||||
// Mixed text + single attachment -> group [text@0, attachment@1].
|
||||
let r = await rebuildFromAppleMessage(null, {{ guid: "G", content: {{ text: "hello", attachments: [{{ guid: "A0" }}] }} }}, "+1");
|
||||
assert(r.content.type === "group" && r.id === "G", "single+text -> group parent id=guid");
|
||||
assert(r.content.items.length === 2, "two items");
|
||||
assert(r.content.items[0].content.type === "text" && r.content.items[0].content.text === "hello" && r.content.items[0].partIndex === 0 && r.content.items[0].id === "p:0/G", "text child @0");
|
||||
assert(r.content.items[1].content.type === "attachment" && r.content.items[1].partIndex === 1 && r.content.items[1].id === "p:1/G" && r.content.items[1].parentId === "G", "attachment child @1");
|
||||
|
||||
// Single attachment, no text -> unchanged bare attachment.
|
||||
r = await rebuildFromAppleMessage(null, {{ guid: "G", content: {{ text: "", attachments: [{{ guid: "A0" }}] }} }}, "+1");
|
||||
assert(r.content.type === "attachment" && r.id === "G" && r.partIndex === 0 && r.parentId === undefined, "no-text single attachment unchanged");
|
||||
|
||||
// Multi attachment + text via the live stream -> group [text@0, att@1, att@2].
|
||||
let arr = await toInboundMessages(null, new Map(), {{ message: {{ guid: "G2", content: {{ text: "cap", attachments: [{{ guid: "A0" }}, {{ guid: "A1" }}] }} }} }}, "+1");
|
||||
assert(arr.length === 1 && arr[0].content.type === "group", "multi+text -> single group");
|
||||
let items = arr[0].content.items;
|
||||
assert(items.length === 3 && items[0].content.type === "text" && items[0].partIndex === 0, "text first @0");
|
||||
assert(items[1].partIndex === 1 && items[1].id === "p:1/G2" && items[2].partIndex === 2 && items[2].id === "p:2/G2", "attachments shifted to @1,@2");
|
||||
|
||||
// Multi attachment, no text -> unchanged (attachments at @0,@1).
|
||||
arr = await toInboundMessages(null, new Map(), {{ message: {{ guid: "G3", content: {{ attachments: [{{ guid: "A0" }}, {{ guid: "A1" }}] }} }} }}, "+1");
|
||||
items = arr[0].content.items;
|
||||
assert(items.length === 2 && items[0].partIndex === 0 && items[0].id === "p:0/G3" && items[1].partIndex === 1, "no-text multi unchanged");
|
||||
|
||||
// Text only, no attachments -> plain text (unchanged).
|
||||
r = await rebuildFromAppleMessage(null, {{ guid: "G4", content: {{ text: "just text", attachments: [] }} }}, "+1");
|
||||
assert(r.content.type === "text" && r.content.text === "just text" && r.id === "G4", "text-only unchanged");
|
||||
"""
|
||||
)
|
||||
run = subprocess.run(
|
||||
["node", "--input-type=module", "-e", harness],
|
||||
cwd=Path.cwd(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
Reference in New Issue
Block a user