Gateway (docs/19): - Remove ws_server.py; frame dispatch factored into dispatch.py - http_server: media upload/pull, pairing over HTTP - protocol: media frames mirrored; tests + ws_probe updated for HTTP App: - HttpGateway: postFrame/uploadMedia/pullMedia no longer throw on network failure (PostResult ok=false / Result.failure) — uncaught SocketTimeoutException on Dispatchers.Default crashed the app - GatewayClient: dead-stream watchdog (health probe every 10s, 2 failures -> redial in ~20s instead of the 45s SSE read timeout); state flips to Reconnecting when the stream dies, restored from the last hello.ack on long-poll success; poke() + backoff reset on app resume (MainActivity.onResume) - Offline sends: composer enabled while disconnected; a send with no response (status 0) stays queued (Pending) and is auto-resent on the next (re)connect after a 2s outbox-replay grace; gateway 4xx rejections fail the bubble (tap to retry, no auto-loop) - ChatStore: echo-replace and thread-relocate also match Failed bubbles (POST response lost in a network drop); loadHistory dedupes local failed bubbles the server already has; failMessage() - MainActivity: poke() on resume so a backgrounded app reconnects promptly instead of waiting out the backoff
827 lines
27 KiB
Python
827 lines
27 KiB
Python
"""Tests for the android plugin's HTTP fallback transport (docs/19).
|
|
|
|
The plugin lives in the sibling ``iris_x_hermes`` checkout; tests load it
|
|
from the source tree directly (same pattern as ``test_android.py``).
|
|
|
|
Coverage (docs/19 §19.12):
|
|
* auth: bad/missing token -> 401; missing device header -> 401;
|
|
allowlist rejection -> 401
|
|
* ``POST /v1/frame``: valid ``message.send`` dispatches (202 + echo on
|
|
the SSE stream); empty text -> 400 error frame; automation channel ->
|
|
400; bad JSON -> 400; wrong content-type -> 400; oversize body -> 413;
|
|
media frames -> 400 (WS-only in v1); rate limit -> 429
|
|
* SSE: catch-up rows carry correct ``id``s + cursor envelope;
|
|
``event: hello`` present; a live frame appended after connect arrives
|
|
on the stream; ``Last-Event-ID`` resume replays exactly the delta;
|
|
heartbeat observed
|
|
* long-poll: returns on new frame; empty 200 at timeout with advanced
|
|
cursor
|
|
* **delivery counting (docs/19 §19.8)**: a frame with only an SSE
|
|
subscriber is ``delivered >= 1`` -> NO push fired (the critical
|
|
regression test)
|
|
|
|
Run via ``scripts/run_tests.sh tests/gateway/test_android_http.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import contextlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from http.client import HTTPConnection
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
# Test-only token (not a credential; the adapter is built with it via
|
|
# monkeypatch in the fixture below).
|
|
# pi-lens-ignore: S105
|
|
TOKEN = "test-android-http-token-0123456789"
|
|
DEVICE_ID = "test-http-device"
|
|
CHAT_ID = "android:default"
|
|
|
|
# 1x1 PNG (same fixture as test_android.py).
|
|
PNG_1X1 = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
|
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
|
)
|
|
|
|
|
|
def _plugin_dir() -> Path:
|
|
env = os.environ.get("ANDROID_PLUGIN_DIR")
|
|
if env:
|
|
return Path(env)
|
|
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
|
|
# plugin dir is parents[1]) or the hermes-agent/tests/gateway/ mirror
|
|
# (repo root is parents[3]).
|
|
here = Path(__file__).resolve()
|
|
for candidate in (here.parents[1], here.parents[3] / "gateway-plugin"):
|
|
if (candidate / "protocol.py").is_file():
|
|
return candidate
|
|
return here.parents[1]
|
|
|
|
|
|
def _load_plugin():
|
|
"""Load the gateway-plugin package under a unique module name (same
|
|
pattern as test_android.py)."""
|
|
name = "android_plugin_http_under_test"
|
|
cached = sys.modules.get(name)
|
|
if cached is not None:
|
|
return cached
|
|
pkg_dir = _plugin_dir()
|
|
if not (pkg_dir / "__init__.py").is_file():
|
|
pytest.fail(f"android plugin not found at {pkg_dir}")
|
|
spec = importlib.util.spec_from_file_location(
|
|
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
|
|
)
|
|
if spec is None or spec.loader is None:
|
|
pytest.fail(f"could not build import spec for {pkg_dir}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = module
|
|
try:
|
|
spec.loader.exec_module(module)
|
|
except Exception:
|
|
sys.modules.pop(name, None)
|
|
raise
|
|
return module
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def plugin():
|
|
return _load_plugin()
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter(plugin, monkeypatch):
|
|
"""A live AndroidAdapter with an isolated HERMES_HOME (conftest)."""
|
|
monkeypatch.setenv("ANDROID_TOKEN", TOKEN)
|
|
from gateway.platform_registry import PlatformEntry, platform_registry
|
|
|
|
if not platform_registry.is_registered("android"):
|
|
platform_registry.register(
|
|
PlatformEntry(
|
|
name="android",
|
|
label="Android",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True,
|
|
)
|
|
)
|
|
|
|
config = SimpleNamespace(
|
|
extra={
|
|
"host": "127.0.0.1",
|
|
"port": 0, # ephemeral WS port
|
|
"http_port": 0, # ephemeral HTTP port
|
|
"max_upload_bytes": 1024 * 1024,
|
|
},
|
|
home_channel=None,
|
|
)
|
|
a = plugin.adapter.AndroidAdapter(config)
|
|
yield a
|
|
with contextlib.suppress(Exception):
|
|
a._devices.close()
|
|
with contextlib.suppress(Exception):
|
|
a._outbox.close()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def gw(adapter):
|
|
"""Connected adapter (WS + HTTP legs up); the HTTP port is ephemeral."""
|
|
await adapter.connect()
|
|
try:
|
|
yield adapter
|
|
finally:
|
|
await adapter.disconnect()
|
|
|
|
|
|
def http_port(adapter) -> int:
|
|
assert adapter._http_server.enabled, "HTTP leg should be enabled after connect()"
|
|
return adapter._http_server.bound_port
|
|
|
|
|
|
# ── Blocking HTTP helpers (run via asyncio.to_thread) ──────────────────────
|
|
|
|
|
|
def _request(
|
|
port: int,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
token: str | None = TOKEN,
|
|
device: str | None = DEVICE_ID,
|
|
body: bytes | str | None = None,
|
|
content_type: str = "application/json",
|
|
timeout: float = 10.0,
|
|
extra_headers: dict | None = None,
|
|
) -> tuple[int, bytes]:
|
|
conn = HTTPConnection("127.0.0.1", port, timeout=timeout)
|
|
headers = {}
|
|
if token is not None:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
if device is not None:
|
|
headers["X-Iris-Device"] = device
|
|
if extra_headers:
|
|
headers.update(extra_headers)
|
|
if body is not None:
|
|
data = body if isinstance(body, bytes) else body.encode("utf-8")
|
|
headers["Content-Type"] = content_type
|
|
conn.request(method, path, body=data, headers=headers)
|
|
else:
|
|
conn.request(method, path, headers=headers)
|
|
resp = conn.getresponse()
|
|
payload = resp.read()
|
|
status = resp.status
|
|
conn.close()
|
|
return status, payload
|
|
|
|
|
|
def _post_frame(port: int, frame: dict, **kw) -> tuple[int, dict]:
|
|
status, payload = _request(port, "POST", "/v1/frame", body=json.dumps(frame), **kw)
|
|
return status, json.loads(payload)
|
|
|
|
|
|
def _frame_json(frame: dict) -> dict:
|
|
return {"v": 1, **frame}
|
|
|
|
|
|
def _parse_sse(lines: list[str]) -> tuple[list[tuple[str | None, str | None, str]], int]:
|
|
"""Parse raw SSE lines into ``[(event, id, data), ...]`` + comment count."""
|
|
events: list[tuple[str | None, str | None, str]] = []
|
|
comments = 0
|
|
cur_event: str | None = None
|
|
cur_id: str | None = None
|
|
cur_data: list[str] = []
|
|
for raw in lines:
|
|
line = raw.rstrip("\r\n")
|
|
if line == "":
|
|
if cur_data:
|
|
events.append((cur_event, cur_id, "\n".join(cur_data)))
|
|
cur_event, cur_id, cur_data = None, None, []
|
|
elif line.startswith(":"):
|
|
comments += 1
|
|
else:
|
|
field, _, value = line.partition(":")
|
|
if value.startswith(" "):
|
|
value = value[1:]
|
|
if field == "event":
|
|
cur_event = value
|
|
elif field == "id":
|
|
cur_id = value
|
|
elif field == "data":
|
|
cur_data.append(value)
|
|
return events, comments
|
|
|
|
|
|
def _sse_open(port: int, *, cursor: int | None = None, last_event_id: str | None = None):
|
|
"""Open an SSE connection (blocking); returns the HTTPResponse (read
|
|
lines via ``_sse_read_lines``; close with ``resp.close()``)."""
|
|
conn = HTTPConnection("127.0.0.1", port, timeout=30)
|
|
path = "/v1/events" + (f"?cursor={cursor}" if cursor is not None else "")
|
|
headers = {
|
|
"Authorization": f"Bearer {TOKEN}",
|
|
"X-Iris-Device": DEVICE_ID,
|
|
}
|
|
if last_event_id is not None:
|
|
headers["Last-Event-ID"] = last_event_id
|
|
conn.request("GET", path, headers=headers)
|
|
resp = conn.getresponse()
|
|
assert resp.status == 200, f"SSE open failed: {resp.status}"
|
|
assert resp.getheader("Content-Type", "").startswith("text/event-stream")
|
|
return resp
|
|
|
|
|
|
def _sse_read_lines(resp, n: int, timeout: float = 10.0) -> list[str]:
|
|
"""Read up to n lines from the SSE stream (blocking)."""
|
|
raw = resp.fp.raw
|
|
sock = getattr(raw, "_sock", None)
|
|
if sock is not None:
|
|
sock.settimeout(timeout)
|
|
lines: list[str] = []
|
|
while len(lines) < n:
|
|
line = resp.fp.readline()
|
|
if not line:
|
|
break
|
|
lines.append(line.decode("utf-8"))
|
|
return lines
|
|
|
|
|
|
# ── /v1/health ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_no_auth(gw):
|
|
status, payload = await asyncio.to_thread(
|
|
_request, http_port(gw), "GET", "/v1/health", token=None, device=None
|
|
)
|
|
assert status == 200
|
|
assert json.loads(payload) == {"ok": True}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_path_404(gw):
|
|
status, _ = await asyncio.to_thread(_request, http_port(gw), "GET", "/v1/nope")
|
|
assert status == 404
|
|
|
|
|
|
# ── Auth ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_bad_token_401(gw):
|
|
status, _ = await asyncio.to_thread(
|
|
_request,
|
|
http_port(gw),
|
|
"POST",
|
|
"/v1/frame",
|
|
token="wrong-token",
|
|
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
|
|
)
|
|
assert status == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_missing_token_401(gw):
|
|
status, _ = await asyncio.to_thread(
|
|
_request,
|
|
http_port(gw),
|
|
"POST",
|
|
"/v1/frame",
|
|
token=None,
|
|
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
|
|
)
|
|
assert status == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_missing_device_401(gw):
|
|
status, _ = await asyncio.to_thread(
|
|
_request,
|
|
http_port(gw),
|
|
"POST",
|
|
"/v1/frame",
|
|
device=None,
|
|
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
|
|
)
|
|
assert status == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_allowlist_rejection_401(gw):
|
|
gw.allowed_users = ["some-other-device"]
|
|
gw.allow_all = False
|
|
status, _ = await asyncio.to_thread(
|
|
_request,
|
|
http_port(gw),
|
|
"POST",
|
|
"/v1/frame",
|
|
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
|
|
)
|
|
assert status == 401
|
|
|
|
|
|
# ── POST /v1/frame: validation ─────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_bad_json_400(gw):
|
|
status, payload = await asyncio.to_thread(
|
|
_request, http_port(gw), "POST", "/v1/frame", body=b"not json"
|
|
)
|
|
assert status == 400
|
|
frame = json.loads(payload)
|
|
assert frame["type"] == "error"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_wrong_content_type_400(gw):
|
|
status, _ = await asyncio.to_thread(
|
|
_request,
|
|
http_port(gw),
|
|
"POST",
|
|
"/v1/frame",
|
|
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
|
|
content_type="text/plain",
|
|
)
|
|
assert status == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_oversize_body_413(gw):
|
|
big = json.dumps(_frame_json({"type": "ping", "payload": {"pad": "x" * (1024 * 1024 + 1)}}))
|
|
status, _ = await asyncio.to_thread(_request, http_port(gw), "POST", "/v1/frame", body=big)
|
|
assert status == 413
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_empty_message_400(gw):
|
|
gw.handle_message = AsyncMock()
|
|
status, payload = await asyncio.to_thread(
|
|
_post_frame,
|
|
http_port(gw),
|
|
_frame_json(
|
|
{"id": 7, "type": "message.send", "chat_id": CHAT_ID, "payload": {"text": " "}}
|
|
),
|
|
)
|
|
assert status == 400
|
|
frame = payload
|
|
assert frame["type"] == "error"
|
|
assert frame["id"] == 7
|
|
assert frame["payload"]["code"] == "unsupported"
|
|
gw.handle_message.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_automation_channel_400(gw):
|
|
gw.handle_message = AsyncMock()
|
|
entry = gw._channels.create(name="Cron")
|
|
gw._channels.set_automation(entry["chat_id"], True)
|
|
status, payload = await asyncio.to_thread(
|
|
_post_frame,
|
|
http_port(gw),
|
|
_frame_json(
|
|
{
|
|
"id": 8,
|
|
"type": "message.send",
|
|
"chat_id": entry["chat_id"],
|
|
"payload": {"text": "hi"},
|
|
}
|
|
),
|
|
)
|
|
assert status == 400
|
|
assert payload["type"] == "error"
|
|
gw.handle_message.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_rate_limit_429(gw):
|
|
gw.handle_message = AsyncMock()
|
|
port = http_port(gw)
|
|
# Exhaust the per-device bucket (INBOUND_BURST = 40) then expect 429.
|
|
got_429 = False
|
|
for i in range(60):
|
|
status, _ = await asyncio.to_thread(
|
|
_post_frame,
|
|
port,
|
|
_frame_json({"id": i, "type": "ping", "payload": {}}),
|
|
)
|
|
if status == 429:
|
|
got_429 = True
|
|
break
|
|
assert got_429, "expected a 429 within 60 rapid frames"
|
|
|
|
|
|
# ── POST /v1/frame: dispatch ────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_message_send_dispatches(gw):
|
|
"""202 ack; the user echo + read receipt arrive on the SSE stream; the
|
|
agent turn fires (docs/19 §19.7: async responses on the event stream)."""
|
|
gw.handle_message = AsyncMock()
|
|
port = http_port(gw)
|
|
conn = _sse_open(port)
|
|
try:
|
|
# Consume the open sequence (hello + status = 6 lines) first.
|
|
lines = _sse_read_lines(conn, 6, timeout=5)
|
|
events, _ = _parse_sse(lines)
|
|
assert events[0][0] == "hello"
|
|
|
|
status, payload = await asyncio.to_thread(
|
|
_post_frame,
|
|
port,
|
|
_frame_json(
|
|
{
|
|
"id": 42,
|
|
"type": "message.send",
|
|
"chat_id": CHAT_ID,
|
|
"payload": {"text": "hi there"},
|
|
}
|
|
),
|
|
)
|
|
# The read receipt (sent when the turn is handed to the agent) is
|
|
# the handler's single point-to-point reply -> 200 with the frame
|
|
# as the body (a plain 202 {"ok": true} is also valid when no
|
|
# synchronous reply exists).
|
|
assert status in (200, 202)
|
|
if status == 200:
|
|
assert payload["type"] == "read.receipt"
|
|
else:
|
|
assert payload == {"ok": True}
|
|
|
|
# The echo must arrive on the stream, tagged with its outbox
|
|
# cursor as the SSE id (live frames carry no cursor in the
|
|
# envelope, same as the WS path).
|
|
deadline = time.monotonic() + 10
|
|
echo = None
|
|
while time.monotonic() < deadline and echo is None:
|
|
lines = _sse_read_lines(conn, 4, timeout=5)
|
|
for _event, sse_id, data in _parse_sse(lines)[0]:
|
|
frame = json.loads(data)
|
|
if (
|
|
frame.get("type") == "message"
|
|
and frame.get("payload", {}).get("text") == "hi there"
|
|
):
|
|
echo = (frame, sse_id)
|
|
assert echo is not None, "user echo did not arrive on the SSE stream"
|
|
assert echo[1] is not None # SSE id = outbox cursor
|
|
await asyncio.sleep(0.2)
|
|
gw.handle_message.assert_called_once()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ── SSE: catch-up, hello, live, resume, heartbeat ──────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sse_catchup_and_hello(gw):
|
|
"""Catch-up rows carry correct ids + cursor envelope; hello present."""
|
|
port = http_port(gw)
|
|
# Park two frames through the real outbound path (no live devices).
|
|
push_calls: list = []
|
|
gw._maybe_push = AsyncMock(side_effect=lambda *a, **k: push_calls.append(a))
|
|
for text in ("one", "two"):
|
|
await _park_frame(gw, text)
|
|
cursors = [1, 2]
|
|
|
|
conn = _sse_open(port, cursor=0)
|
|
try:
|
|
# 2 replayed frames (id + event + data + blank = 4 lines each) +
|
|
# hello (3 lines) + status (3 lines) = 14 lines.
|
|
lines = _sse_read_lines(conn, 14, timeout=5)
|
|
events, _ = _parse_sse(lines)
|
|
assert events[0][0] == "frame"
|
|
assert events[0][1] == str(cursors[0])
|
|
f0 = json.loads(events[0][2])
|
|
assert f0["payload"]["text"] == "one"
|
|
assert f0["cursor"] == cursors[0]
|
|
assert events[1][0] == "frame"
|
|
assert events[1][1] == str(cursors[1])
|
|
assert json.loads(events[1][2])["payload"]["text"] == "two"
|
|
assert events[2][0] == "hello"
|
|
hello = json.loads(events[2][2])
|
|
assert hello["type"] == "hello.ack"
|
|
assert hello["payload"]["sync_cursor"] == 2
|
|
assert events[3][0] == "frame"
|
|
assert json.loads(events[3][2])["type"] == "status"
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sse_live_frame_after_connect(gw):
|
|
port = http_port(gw)
|
|
conn = _sse_open(port)
|
|
try:
|
|
# Consume the open sequence (hello + status = 6 lines).
|
|
_sse_read_lines(conn, 6, timeout=5)
|
|
await _park_frame(gw, "live!")
|
|
deadline = time.monotonic() + 10
|
|
got = None
|
|
while time.monotonic() < deadline and got is None:
|
|
lines = _sse_read_lines(conn, 4, timeout=5)
|
|
for _event, sse_id, data in _parse_sse(lines)[0]:
|
|
frame = json.loads(data)
|
|
if frame.get("payload", {}).get("text") == "live!":
|
|
got = (frame, sse_id)
|
|
assert got is not None, "live frame did not arrive on the SSE stream"
|
|
assert got[1] is not None # SSE id = outbox cursor
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sse_last_event_id_resume(gw):
|
|
"""Resume with Last-Event-ID replays exactly the delta."""
|
|
port = http_port(gw)
|
|
for text in ("a", "b", "c"):
|
|
await _park_frame(gw, text)
|
|
|
|
conn = _sse_open(port, last_event_id="1")
|
|
try:
|
|
# Frames 2 and 3 replayed (8 lines) + hello (3) + status (3) = 14.
|
|
lines = _sse_read_lines(conn, 14, timeout=5)
|
|
events, _ = _parse_sse(lines)
|
|
replayed = [e for e in events if e[0] == "frame" and e[1] is not None]
|
|
assert [e[1] for e in replayed] == ["2", "3"]
|
|
assert json.loads(replayed[0][2])["payload"]["text"] == "b"
|
|
assert json.loads(replayed[1][2])["payload"]["text"] == "c"
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sse_heartbeat(gw, monkeypatch, plugin):
|
|
"""A comment heartbeat is written when the stream is idle."""
|
|
monkeypatch.setattr(plugin.http_server, "SSE_HEARTBEAT_S", 1.0)
|
|
port = http_port(gw)
|
|
conn = _sse_open(port)
|
|
try:
|
|
# Consume the open sequence (6 lines), then wait for the heartbeat.
|
|
_sse_read_lines(conn, 6, timeout=5)
|
|
lines = _sse_read_lines(conn, 2, timeout=5)
|
|
events, comments = _parse_sse(lines)
|
|
assert comments >= 1, f"no heartbeat comment in {lines!r}"
|
|
assert events == []
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ── Long-poll ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_returns_on_new_frame(gw):
|
|
port = http_port(gw)
|
|
await _park_frame(gw, "pre") # cursor 1: returned immediately (catch-up)
|
|
|
|
# A second poll at the high-water mark blocks until a new frame lands.
|
|
def poll_and_broadcast():
|
|
status, payload = _request(port, "GET", "/v1/poll?cursor=1", timeout=30)
|
|
return status, json.loads(payload)
|
|
|
|
async def late_frame():
|
|
await asyncio.sleep(0.5)
|
|
await _park_frame(gw, "late")
|
|
|
|
poll_task = asyncio.create_task(asyncio.to_thread(poll_and_broadcast))
|
|
late_task = asyncio.create_task(late_frame())
|
|
status, body = await asyncio.wait_for(poll_task, timeout=15)
|
|
await late_task
|
|
assert status == 200
|
|
assert body["cursor"] >= 2
|
|
assert len(body["frames"]) == 1
|
|
assert json.loads(body["frames"][0])["payload"]["text"] == "late"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_timeout_empty(gw, monkeypatch, plugin):
|
|
monkeypatch.setattr(plugin.http_server, "POLL_TIMEOUT_S", 1.0)
|
|
port = http_port(gw)
|
|
await _park_frame(gw, "x")
|
|
hwm = gw._outbox.latest_cursor()
|
|
status, payload = await asyncio.to_thread(
|
|
_request, port, "GET", f"/v1/poll?cursor={hwm}", timeout=15
|
|
)
|
|
body = json.loads(payload)
|
|
assert status == 200
|
|
assert body["frames"] == []
|
|
assert body["cursor"] == hwm
|
|
|
|
|
|
# ── Delivery counting (docs/19 §19.8 — the critical regression) ────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sse_subscriber_counts_as_delivered_no_push(gw):
|
|
"""A frame with only an SSE subscriber is delivered >= 1 -> NO push."""
|
|
port = http_port(gw)
|
|
conn = _sse_open(port)
|
|
try:
|
|
_sse_read_lines(conn, 6, timeout=5) # open sequence
|
|
push = AsyncMock()
|
|
gw._maybe_push = push
|
|
await _park_frame(gw, "no push for me")
|
|
await asyncio.sleep(0.2)
|
|
push.assert_not_called()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_subscribers_still_pushes(gw):
|
|
"""Control: with no live devices at all, the push path still fires."""
|
|
push = AsyncMock()
|
|
gw._maybe_push = push
|
|
await _park_frame(gw, "wake me up")
|
|
await asyncio.sleep(0.2)
|
|
push.assert_called_once()
|
|
|
|
|
|
# ── Media over HTTP (docs/19 §19.15, v2) ──────────────────────────────────
|
|
|
|
|
|
def _upload(
|
|
port: int,
|
|
data: bytes,
|
|
*,
|
|
media_ref: str = "mu_http1",
|
|
kind: str = "image",
|
|
mime: str = "image/png",
|
|
filename: str = "t.png",
|
|
sha256: str | None = None,
|
|
**kw,
|
|
) -> tuple[int, dict]:
|
|
import hashlib
|
|
|
|
headers = {
|
|
"X-Iris-Media-Ref": media_ref,
|
|
"X-Iris-Media-Kind": kind,
|
|
"X-Iris-Media-Filename": filename,
|
|
"X-Iris-Media-Sha256": sha256 if sha256 is not None else hashlib.sha256(data).hexdigest(),
|
|
}
|
|
status, payload = _request(
|
|
port,
|
|
"POST",
|
|
"/v1/media",
|
|
body=data,
|
|
content_type=mime,
|
|
extra_headers=headers,
|
|
**kw,
|
|
)
|
|
return status, json.loads(payload)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_upload_ok(gw):
|
|
port = http_port(gw)
|
|
status, body = await asyncio.to_thread(_upload, port, PNG_1X1)
|
|
assert status == 201, body
|
|
assert body["type"] == "media.upload.ack"
|
|
assert body["payload"]["ok"] is True
|
|
assert body["payload"]["media_ref"] == "mu_http1"
|
|
entry = gw._media.get_inbound("mu_http1")
|
|
assert entry is not None
|
|
assert entry.kind == "image"
|
|
assert entry.size == len(PNG_1X1)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_upload_sha_mismatch(gw):
|
|
port = http_port(gw)
|
|
status, body = await asyncio.to_thread(
|
|
_upload, port, PNG_1X1, media_ref="mu_badsha", sha256="0" * 64
|
|
)
|
|
assert status == 500, body # internal: digest mismatch
|
|
assert body["type"] == "error"
|
|
assert body["payload"]["code"] == "internal"
|
|
assert gw._media.get_inbound("mu_badsha") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_upload_oversize_413(gw):
|
|
port = http_port(gw)
|
|
oversize = b"x" * (gw.max_upload_bytes + 1)
|
|
status, body = await asyncio.to_thread(_upload, port, oversize, media_ref="mu_big")
|
|
assert status == 413, body
|
|
assert body["payload"]["code"] == "media_too_large"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_upload_missing_ref_400(gw):
|
|
port = http_port(gw)
|
|
status, payload = await asyncio.to_thread(
|
|
_request,
|
|
port,
|
|
"POST",
|
|
"/v1/media",
|
|
body=PNG_1X1,
|
|
content_type="image/png",
|
|
extra_headers={"X-Iris-Media-Kind": "image"},
|
|
)
|
|
body = json.loads(payload)
|
|
assert status == 400
|
|
assert body["payload"]["code"] == "unsupported"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_upload_bad_kind_400(gw):
|
|
port = http_port(gw)
|
|
status, body = await asyncio.to_thread(_upload, port, PNG_1X1, kind="hologram")
|
|
assert status == 400
|
|
assert body["payload"]["code"] == "unsupported"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_upload_auth_401(gw):
|
|
port = http_port(gw)
|
|
status, _ = await asyncio.to_thread(_upload, port, PNG_1X1, token="wrong-token")
|
|
assert status == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_upload_liar_reclassified(gw):
|
|
"""Lies about being a PNG: magic-byte re-sniff keeps it out of the image
|
|
cache (lands as a document) — same contract as the WS path."""
|
|
port = http_port(gw)
|
|
payload = b"<html>not an image</html>"
|
|
status, body = await asyncio.to_thread(
|
|
_upload, port, payload, media_ref="mu_liar", filename="liar.html"
|
|
)
|
|
assert status == 201, body
|
|
entry = gw._media.get_inbound("mu_liar")
|
|
assert entry is not None
|
|
assert entry.kind == "document"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_pull_ok(gw):
|
|
from gateway.platforms.base import get_image_cache_dir
|
|
|
|
img = get_image_cache_dir() / "http_pull_test.png"
|
|
img.write_bytes(PNG_1X1)
|
|
entry = gw._media.register_outbound(
|
|
str(img), "image", "image/png", "http_pull_test.png", len(PNG_1X1)
|
|
)
|
|
port = http_port(gw)
|
|
status, payload = await asyncio.to_thread(_request, port, "GET", f"/v1/media/{entry.media_id}")
|
|
assert status == 200
|
|
assert payload == PNG_1X1
|
|
conn = HTTPConnection("127.0.0.1", port, timeout=10)
|
|
conn.request(
|
|
"GET",
|
|
f"/v1/media/{entry.media_id}",
|
|
headers={"Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID},
|
|
)
|
|
resp = conn.getresponse()
|
|
resp.read()
|
|
assert resp.getheader("Content-Type") == "image/png"
|
|
assert resp.getheader("Content-Length") == str(len(PNG_1X1))
|
|
conn.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_pull_unknown_404(gw):
|
|
port = http_port(gw)
|
|
status, payload = await asyncio.to_thread(_request, port, "GET", "/v1/media/md_nope")
|
|
body = json.loads(payload)
|
|
assert status == 404
|
|
assert body["payload"]["code"] == "not_found"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_pull_denied_path_404(gw):
|
|
"""Known id, but the path fails delivery validation (denylist) — same
|
|
re-check at pull time as the WS path."""
|
|
entry = gw._media.register_outbound("/etc/passwd", "document", "text/plain", "passwd", 100)
|
|
port = http_port(gw)
|
|
status, payload = await asyncio.to_thread(_request, port, "GET", f"/v1/media/{entry.media_id}")
|
|
body = json.loads(payload)
|
|
assert status == 404
|
|
assert body["payload"]["code"] == "not_found"
|
|
|
|
|
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def _park_frame(adapter, text: str) -> int:
|
|
"""Emit one message frame through ``_broadcast_or_log`` (the real
|
|
outbound path); returns the outbox cursor."""
|
|
plugin = _load_plugin()
|
|
frame = plugin.protocol.message(
|
|
chat_id=CHAT_ID,
|
|
message_id=f"m_{abs(hash(text)) % 10**8:08x}",
|
|
role="assistant",
|
|
text=text,
|
|
)
|
|
await adapter._broadcast_or_log(CHAT_ID, frame)
|
|
return adapter._outbox.latest_cursor()
|