Files
omarchy-beeper/tests/support.py
T

186 lines
5.5 KiB
Python
Raw Normal View History

"""Shared helpers for mocked contract tests. Never talks to Beeper."""
from __future__ import annotations
import json
import sys
import urllib.error
import urllib.parse
from contextlib import contextmanager, redirect_stdout
from io import BytesIO, StringIO
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
LIB = ROOT / "lib"
if str(LIB) not in sys.path:
sys.path.insert(0, str(LIB))
def capture_json(fn, *args, **kwargs) -> dict:
buf = StringIO()
with redirect_stdout(buf):
try:
fn(*args, **kwargs)
except SystemExit:
pass
text = buf.getvalue().strip()
if not text:
raise AssertionError("expected JSON on stdout")
return json.loads(text.splitlines()[-1])
class FakeResponse:
"""Enough of an http.client.HTTPResponse for read_http_body()."""
def __init__(self, payload: object, status: int = 200, content_length: object | None = None):
if isinstance(payload, (bytes, bytearray)):
raw = bytes(payload)
else:
raw = json.dumps(payload).encode("utf-8")
self._buf = BytesIO(raw)
self.status = status
declared = len(raw) if content_length is None else content_length
self.headers = {} if declared is False else {"Content-Length": str(declared)}
def read(self, n: int = -1) -> bytes:
return self._buf.read(n)
def __enter__(self) -> FakeResponse:
return self
def __exit__(self, *exc: object) -> bool:
return False
class Call:
def __init__(self, method: str, url: str, body: object) -> None:
parsed = urllib.parse.urlsplit(url)
self.method = method
self.url = url
self.host = parsed.netloc
self.path = parsed.path
self.query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
self.body = body
def one(self, key: str, default: str = "") -> str:
values = self.query.get(key) or []
return values[0] if values else default
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<Call {self.method} {self.path} {self.query}>"
class FakeHttp:
"""Route (METHOD, path) to a callable or a fixed response."""
def __init__(self, routes: dict[tuple[str, str], object] | None = None) -> None:
self.routes = dict(routes or {})
self.calls: list[Call] = []
def route(self, method: str, path: str, handler: object) -> None:
self.routes[(method, path)] = handler
def open(self, req, timeout: int | None = None): # noqa: D102 - urlopen shape
body = None
if req.data:
body = json.loads(req.data.decode("utf-8"))
call = Call(req.get_method(), req.full_url, body)
self.calls.append(call)
handler = self.routes.get((call.method, call.path))
if handler is None:
raise urllib.error.HTTPError(call.url, 404, "Not Found", {}, BytesIO(b"{}"))
result = handler(call) if callable(handler) else handler
if isinstance(result, Exception):
raise result
if isinstance(result, FakeResponse):
return result
return FakeResponse(result)
def paths(self, method: str = "") -> list[str]:
return [c.path for c in self.calls if not method or c.method == method]
@contextmanager
def fake_api(http: FakeHttp):
import beeper
with patch.object(beeper, "_OPENER", http):
yield http
def http_error(status: int, message: str = "nope", url: str = "http://localhost:23373/v1/x"):
body = json.dumps({"message": message, "code": "error"}).encode("utf-8")
return urllib.error.HTTPError(url, status, message, {"Content-Length": str(len(body))}, BytesIO(body))
def url_error(reason: Exception | None = None):
return urllib.error.URLError(reason or ConnectionRefusedError(111, "Connection refused"))
def chat(
chat_id: str,
*,
title: str = "Ada Lovelace",
network: str = "WhatsApp",
account: str = "local-whatsapp_1",
last_activity: str = "2026-02-11T10:00:00.000Z",
unread: int = 2,
) -> dict:
return {
"id": chat_id,
"localChatID": "1",
"accountID": account,
"network": network,
"title": title,
"type": "single",
"participants": {"items": [], "hasMore": False, "total": 1},
"lastActivity": last_activity,
"unreadCount": unread,
"isArchived": False,
"isMuted": False,
"isPinned": False,
}
def message(
chat_id: str,
*,
text: str = "are you coming tonight?",
sender: str = "Ada",
timestamp: str = "2026-02-11T10:00:00.000Z",
kind: str = "TEXT",
sort_key: str = "100",
) -> dict:
return {
"id": "m1",
"chatID": chat_id,
"accountID": "local-whatsapp_1",
"senderID": "@ada:example",
"senderName": sender,
"timestamp": timestamp,
"sortKey": sort_key,
"type": kind,
"text": text,
}
def account(account_id: str, network: str) -> dict:
return {
"accountID": account_id,
"loginID": account_id,
"bridge": {"id": network.lower(), "type": network.lower(), "provider": "local"},
"network": network,
"user": {"id": "@me:example", "isSelf": True},
"status": "connected",
}
def search_page(chats: list[dict], has_more: bool = False, cursor: str | None = None) -> dict:
return {
"items": chats,
"hasMore": has_more,
"oldestCursor": cursor,
"newestCursor": None,
}