srs-api/hermes-plugin/srs-gate/test_srs_gate.py
Aswin Biju 3640b2d785 feat(whatsapp): read slip photos and add them to the order
Shops can now send handwritten slips as WhatsApp photos. srs-api reads them
through OpenRouter in the background. The model only proposes text: the parser
decides what is an entry, and nothing is saved without the user's Save.
2026-11-04 13:46:50 +05:30

761 lines
29 KiB
Python

"""Tests for the srs-gate Hermes plugin. Standard library only.
Run from the repo root:
python -m unittest discover -s hermes-plugin/srs-gate -v
All phone digits, ids and the secret below are placeholders, not real values.
"""
from __future__ import annotations
import asyncio
import base64
import enum
import importlib.util
import inspect
import json
import logging
import os
import socket
import tempfile
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
HERE = Path(__file__).resolve().parent
def _load_plugin():
spec = importlib.util.spec_from_file_location("srs_gate", HERE / "__init__.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
gate = _load_plugin()
SKIP = {"action": "skip", "reason": "srs-gate"}
SECRET = "test-only-secret-not-real-0123456789"
PHONE = "910000000001"
SENDER = PHONE + "@s.whatsapp.net"
CHAT = "100000000000077@lid"
MID = "3EB0TESTMSGID01"
TEXT = "Shop Zq7\n0012-5\n4567 x 10\ncall " + PHONE
SENSITIVE = [SECRET, PHONE, SENDER, CHAT, "100000000000077", "Zq7", "4567 x 10"]
class MessageType(enum.Enum):
TEXT = "text"
LOCATION = "location"
PHOTO = "photo"
VIDEO = "video"
AUDIO = "audio"
VOICE = "voice"
DOCUMENT = "document"
STICKER = "sticker"
COMMAND = "command"
def make_event(text=TEXT):
source = SimpleNamespace(
platform="whatsapp",
chat_id=CHAT,
chat_type="dm",
user_id=SENDER,
user_id_alt=None,
chat_id_alt=None,
is_bot=False,
message_id=MID,
)
return SimpleNamespace(
text=text,
message_type=MessageType.TEXT,
message_id=MID,
source=source,
media_urls=[],
media_types=[],
internal=False,
edit_revision=None,
metadata={},
user_id=None,
)
class ExplodingEvent:
@property
def source(self):
raise RuntimeError("boom")
@property
def internal(self):
raise RuntimeError("boom")
# ---------------------------------------------------------------- stub srs-api
class _Server(ThreadingHTTPServer):
daemon_threads = True
block_on_close = False
def handle_error(self, request, client_address):
pass
class Stub:
"""Stands in for srs-api. `script` is a list of actions, one per request; after it runs out, 200.
Actions: ("status", code, delay_s) or ("garbage",).
"""
def __init__(self, script=None):
self.requests = []
self.script = list(script or [])
self._lock = threading.Lock()
stub = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_POST(self):
length = int(self.headers.get("content-length") or 0)
body = self.rfile.read(length)
with stub._lock:
stub.requests.append(
{"method": "POST", "path": self.path, "headers": dict(self.headers), "body": body}
)
action = stub.script.pop(0) if stub.script else ("status", 200, 0)
if action[0] == "garbage":
self.wfile.write(b"this is not http\r\n\r\n")
self.close_connection = True
return
_, code, delay = action
if delay:
time.sleep(delay)
payload = b'{"handled":true,"duplicate":false}'
self.send_response(code)
if 300 <= code < 400:
self.send_header("location", "/elsewhere")
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
do_GET = do_POST
self.server = _Server(("127.0.0.1", 0), Handler)
self.url = "http://127.0.0.1:%d" % self.server.server_address[1]
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
def stop(self):
self.server.shutdown()
self.server.server_close()
def closed_port_url():
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return "http://127.0.0.1:%d" % port
class ListHandler(logging.Handler):
def __init__(self):
super().__init__(level=logging.DEBUG)
self.records = []
def emit(self, record):
self.records.append(record)
# ---------------------------------------------------------------- base
class GateCase(unittest.IsolatedAsyncioTestCase):
"""Fresh stub, config file, fast timings and a log capture for every test.
Every test also checks, on teardown, that no log record anywhere holds the text, the secret or a number.
"""
def setUp(self):
self.stub = Stub()
self.addCleanup(self.stub.stop)
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
env = mock.patch.dict(os.environ, {"HERMES_HOME": self.tmp.name})
env.start()
self.addCleanup(env.stop)
self.write_config(url=self.stub.url)
for name, value in (("TRIES", 3), ("TRY_TIMEOUT_S", 1.0), ("BACKOFF_S", 0.01), ("OVERALL_TIMEOUT_S", 5.0)):
p = mock.patch.object(gate, name, value)
p.start()
self.addCleanup(p.stop)
gate._config_warned = False
self.logs = ListHandler()
root = logging.getLogger()
self._old_level = root.level
root.addHandler(self.logs)
root.setLevel(logging.DEBUG)
self.addCleanup(self._restore_logging)
def _restore_logging(self):
root = logging.getLogger()
root.removeHandler(self.logs)
root.setLevel(self._old_level)
def tearDown(self):
self.assert_no_sensitive_logs()
# helpers
def write_config(self, url=None, secret=SECRET, extra=""):
lines = ["# srs-gate test config"]
if url is not None:
lines.append("SRS_URL=" + url)
if secret is not None:
lines.append("SRS_SECRET=" + secret)
Path(self.tmp.name, "srs-gate.env").write_text("\n".join(lines) + "\n" + extra, encoding="utf-8")
def config_path(self):
return Path(self.tmp.name, "srs-gate.env")
async def call(self, event):
return await gate.on_inbound(event=event, gateway=object(), session_store=object())
def log_text(self):
out = []
for r in self.logs.records:
out.append(r.getMessage())
out.append(repr(r.args))
if r.exc_info or r.exc_text:
out.append(logging.Formatter().formatException(r.exc_info) if r.exc_info else r.exc_text)
return "\n".join(out)
def assert_no_sensitive_logs(self):
text = self.log_text()
for needle in SENSITIVE:
self.assertNotIn(needle, text, "sensitive value in logs")
def gate_messages(self):
return [r.getMessage() for r in self.logs.records if r.name == "srs_gate"]
# ---------------------------------------------------------------- payload
class PayloadTests(GateCase):
def test_exact_payload_for_direct_text(self):
self.assertEqual(
gate._payload(make_event()),
{"kind": "text", "message_id": MID, "sender_jid": SENDER, "chat_id": CHAT, "text": TEXT},
)
def test_plain_string_message_type_is_accepted(self):
ev = make_event()
ev.message_type = "text"
self.assertIsNotNone(gate._payload(ev))
async def test_text_arrives_byte_for_byte(self):
texts = {
"zeros_and_breaks": "0012\n0099\r\n 0001 \n\n0000 - 5\t",
"malayalam": "ഷോപ്പ് 0012 - 5",
"hundred_lines": "\n".join("%04d - %d" % (i, i % 7 + 1) for i in range(100)),
}
for name, text in texts.items():
with self.subTest(name):
before = len(self.stub.requests)
self.assertEqual(await self.call(make_event(text)), SKIP)
self.assertEqual(len(self.stub.requests), before + 1)
body = json.loads(self.stub.requests[-1]["body"].decode("utf-8"))
self.assertEqual(body["text"], text)
self.assertEqual(body["kind"], "text")
# ---------------------------------------------------------------- filters
def _set(path, value):
def apply(ev):
target = ev
parts = path.split(".")
for p in parts[:-1]:
target = getattr(target, p)
setattr(target, parts[-1], value)
return apply
def _delete(path):
def apply(ev):
target = ev
parts = path.split(".")
for p in parts[:-1]:
target = getattr(target, p)
delattr(target, parts[-1])
return apply
NOT_FORWARDED = [
("group chat_type", _set("source.chat_type", "group")),
("channel chat_type", _set("source.chat_type", "channel")),
("thread chat_type", _set("source.chat_type", "thread")),
("unknown chat_type", _set("source.chat_type", "something-new")),
("chat_type wrong case", _set("source.chat_type", "DM")),
("chat_type None", _set("source.chat_type", None)),
("chat_type missing", _delete("source.chat_type")),
("group chat id", _set("source.chat_id", "120363000000000000@g.us")),
("internal", _set("internal", True)),
("is_bot", _set("source.is_bot", True)),
("edit_revision set", _set("edit_revision", "2")),
("edit_revision int", _set("edit_revision", 1)),
("blank text", _set("text", "")),
("whitespace text", _set("text", " \n\t ")),
("text None", _set("text", None)),
("text not a string", _set("text", 1234)),
("missing message id", _set("message_id", None)),
("empty message id", _set("message_id", "")),
("missing sender", _set("source.user_id", None)),
("missing chat id", _set("source.chat_id", "")),
("no source", _set("source", None)),
("message_type missing", _set("message_type", None)),
] + [
("message_type " + t.value, _set("message_type", t))
for t in MessageType
if t not in (MessageType.TEXT, MessageType.PHOTO) # PhotoTests covers photos
]
class FilterTests(GateCase):
async def test_not_forwarded_but_still_skipped(self):
for name, mutate in NOT_FORWARDED:
with self.subTest(name):
ev = make_event()
mutate(ev)
self.assertIsNone(gate._payload(ev))
self.assertEqual(await self.call(ev), SKIP)
self.assertEqual(self.stub.requests, [])
async def test_photo_placeholder_text_is_not_forwarded(self):
ev = make_event("[image received]")
ev.message_type = MessageType.PHOTO
self.assertEqual(await self.call(ev), SKIP)
self.assertEqual(self.stub.requests, [])
# ---------------------------------------------------------------- photos
JPEG = b"\xff\xd8\xff\xe0 fake image bytes Pq9"
JPEG_B64 = base64.b64encode(JPEG).decode("ascii")
class PhotoTests(GateCase):
def setUp(self):
super().setUp()
for name, value in (("PHOTO_TRY_TIMEOUT_S", 1.0), ("PHOTO_OVERALL_TIMEOUT_S", 5.0)):
p = mock.patch.object(gate, name, value)
p.start()
self.addCleanup(p.stop)
self.cache = Path(self.tmp.name, "cache", "images")
self.cache.mkdir(parents=True)
def save(self, name="img_1.jpg", data=JPEG):
path = self.cache / name
path.write_bytes(data)
return str(path)
def photo_event(self, paths, text="[image received]"):
ev = make_event(text)
ev.message_type = MessageType.PHOTO
ev.media_urls = paths
return ev
def bodies(self):
return [json.loads(r["body"].decode("utf-8")) for r in self.stub.requests]
async def test_exact_payload(self):
self.assertEqual(await self.call(self.photo_event([self.save()])), SKIP)
self.assertEqual(
self.bodies(),
[
{
"kind": "image",
"message_id": MID,
"sender_jid": SENDER,
"chat_id": CHAT,
"image_base64": JPEG_B64,
"mime_type": "image/jpeg",
}
],
)
self.assertIn("status=200", self.gate_messages()[-1])
async def test_placeholder_is_never_forwarded_as_text(self):
for text in ("[image received]", " [image received]\n", "", None):
with self.subTest(text):
self.stub.requests.clear()
await self.call(self.photo_event([self.save()], text))
self.assertEqual([b["kind"] for b in self.bodies()], ["image"])
async def test_typed_text_follows_the_photo_as_a_typed_message(self):
await self.call(self.photo_event([self.save()], "4567 x 10"))
bodies = self.bodies()
self.assertEqual([b["kind"] for b in bodies], ["image", "text"])
self.assertEqual(bodies[1]["message_id"], MID + "#text")
self.assertEqual(bodies[1]["text"], "4567 x 10")
async def test_placeholder_is_removed_from_typed_text(self):
for text in ("[image received] 4567 x 10", "4567 x 10\n[image received]"):
with self.subTest(text):
self.stub.requests.clear()
await self.call(self.photo_event([self.save()], text))
self.assertEqual(self.bodies()[1]["text"], "4567 x 10")
async def test_several_paths_get_their_own_ids_and_text_comes_last(self):
paths = [self.save("a.jpg"), self.save("b.jpg"), self.save("c.jpg")]
await self.call(self.photo_event(paths, "4567 x 10"))
self.assertEqual(
[b["message_id"] for b in self.bodies()], [MID, MID + "#2", MID + "#3", MID + "#text"]
)
async def test_refused_files_are_not_sent_but_typed_text_is(self):
outside = Path(self.tmp.name, "secret.jpg")
outside.write_bytes(JPEG)
directory = self.cache / "folder"
directory.mkdir()
big = self.save("big.jpg", b"x" * 11)
refused = {
"outside the cache": (str(outside), "outside_cache"),
"dot dot out of the cache": (str(self.cache / ".." / ".." / "secret.jpg"), "outside_cache"),
"an absolute system path": (str(HERE / "__init__.py"), "outside_cache"),
"a directory": (str(directory), "not_a_file"),
"missing": (str(self.cache / "gone.jpg"), "unreadable"),
"too large": (big, "too_large"),
}
with mock.patch.object(gate, "MAX_PHOTO_BYTES", 10):
for name, (path, reason) in refused.items():
with self.subTest(name):
self.stub.requests.clear()
self.assertEqual(await self.call(self.photo_event([path], "4567 x 10")), SKIP)
self.assertEqual([b["kind"] for b in self.bodies()], ["text"])
self.assertIn(reason, " ".join(self.gate_messages()))
async def test_a_file_at_the_limit_is_sent(self):
with mock.patch.object(gate, "MAX_PHOTO_BYTES", len(JPEG)):
await self.call(self.photo_event([self.save()]))
self.assertEqual(len(self.stub.requests), 1)
async def test_a_symlink_out_of_the_cache_is_refused(self):
outside = Path(self.tmp.name, "secret.jpg")
outside.write_bytes(JPEG)
link = self.cache / "link.jpg"
try:
link.symlink_to(outside)
except OSError:
self.skipTest("symlinks are not allowed here")
await self.call(self.photo_event([str(link)]))
self.assertEqual(self.stub.requests, [])
self.assertIn("outside_cache", self.gate_messages()[-1])
async def test_filters_still_apply(self):
path = self.save()
for name, mutate in (
("group chat_type", _set("source.chat_type", "group")),
("group chat id", _set("source.chat_id", "120363000000000000@g.us")),
("internal", _set("internal", True)),
("is_bot", _set("source.is_bot", True)),
("edit", _set("edit_revision", "2")),
("no message id", _set("message_id", None)),
("no sender", _set("source.user_id", None)),
("sticker", _set("message_type", MessageType.STICKER)),
("video", _set("message_type", MessageType.VIDEO)),
("document", _set("message_type", MessageType.DOCUMENT)),
):
with self.subTest(name):
ev = self.photo_event([path], "4567 x 10")
mutate(ev)
self.assertEqual(await self.call(ev), SKIP)
self.assertEqual(self.stub.requests, [])
async def test_nothing_usable_sends_nothing(self):
for name, urls in (("empty", []), ("none", None), ("a string", "oops"), ("not strings", [1, None, ""])):
with self.subTest(name):
self.assertEqual(await self.call(self.photo_event(urls)), SKIP)
self.assertEqual(self.stub.requests, [])
async def test_logs_hold_no_path_bytes_or_text(self):
secret_dir = self.cache / "Xy3folder"
secret_dir.mkdir()
good = secret_dir / "good.jpg"
good.write_bytes(JPEG)
await self.call(self.photo_event([str(good), str(secret_dir / "gone.jpg")], "4567 x 10"))
logged = self.log_text()
for needle in ("Xy3folder", "good.jpg", "gone.jpg", JPEG_B64, "Pq9", self.tmp.name):
self.assertNotIn(needle, logged)
self.assertEqual(len(self.gate_messages()), 3)
async def test_a_photo_gets_longer_tries_than_text(self):
self.stub.script = [("status", 200, 0.4), ("status", 200, 0)]
with mock.patch.object(gate, "TRY_TIMEOUT_S", 0.1), mock.patch.object(gate, "PHOTO_TRY_TIMEOUT_S", 2.0):
await self.call(self.photo_event([self.save()]))
self.assertEqual(len(self.stub.requests), 1, "the slow upload should not have been retried")
async def test_overall_timeout_still_skips(self):
self.stub.script = [("status", 200, 0.5)]
with mock.patch.object(gate, "PHOTO_OVERALL_TIMEOUT_S", 0.15), mock.patch.object(gate, "TRIES", 1):
self.assertEqual(await self.call(self.photo_event([self.save()])), SKIP)
self.assertIn("TimeoutError", self.gate_messages()[-1])
async def test_srs_api_down_still_skips(self):
self.write_config(url=closed_port_url())
self.assertEqual(await self.call(self.photo_event([self.save()], "4567 x 10")), SKIP)
async def test_bad_config_sends_nothing(self):
self.write_config(url=self.stub.url, secret=None)
self.assertEqual(await self.call(self.photo_event([self.save()])), SKIP)
self.assertEqual(self.stub.requests, [])
def test_forward_never_raises_even_systemexit(self):
cfg = {"SRS_URL": self.stub.url, "SRS_SECRET": SECRET}
with mock.patch.object(gate, "_read_photo", side_effect=SystemExit(3)):
rows = gate._forward_photo(cfg, gate._photo_work(self.photo_event([self.save()])))
self.assertEqual(rows, [(MID, None, "SystemExit", None)])
async def test_unexpected_errors_still_skip(self):
with mock.patch.object(gate, "_forward_photo", side_effect=ValueError("x")):
self.assertEqual(await self.call(self.photo_event([self.save()])), SKIP)
with mock.patch.object(gate, "_photo_work", side_effect=ValueError("x")):
self.assertEqual(await self.call(self.photo_event([self.save()])), SKIP)
# ---------------------------------------------------------------- request
class RequestTests(GateCase):
async def test_headers_method_and_path(self):
self.assertEqual(await self.call(make_event()), SKIP)
self.assertEqual(len(self.stub.requests), 1)
req = self.stub.requests[0]
headers = {k.lower(): v for k, v in req["headers"].items()}
self.assertEqual(req["method"], "POST")
self.assertEqual(req["path"], "/ingest")
self.assertEqual(headers["x-srs-secret"], SECRET)
self.assertEqual(headers["content-type"], "application/json")
async def test_trailing_slash_in_url(self):
self.write_config(url=self.stub.url + "/")
await self.call(make_event())
self.assertEqual(self.stub.requests[0]["path"], "/ingest")
async def test_quoted_config_values(self):
self.write_config(url='"%s"' % self.stub.url, secret="'%s'" % SECRET)
await self.call(make_event())
headers = {k.lower(): v for k, v in self.stub.requests[0]["headers"].items()}
self.assertEqual(headers["x-srs-secret"], SECRET)
def test_proxy_environment_is_ignored(self):
dead = closed_port_url()
with mock.patch.dict(os.environ, {"http_proxy": dead, "https_proxy": dead, "no_proxy": ""}):
fresh = _load_plugin()
status, error = fresh._post(
{"SRS_URL": self.stub.url, "SRS_SECRET": SECRET}, fresh._payload(make_event())
)
self.assertEqual((status, error), (200, None))
self.assertEqual(len(self.stub.requests), 1)
async def test_success_log_has_id_and_status(self):
await self.call(make_event())
msgs = self.gate_messages()
self.assertEqual(len(msgs), 1)
self.assertIn("id=" + MID, msgs[0])
self.assertIn("status=200", msgs[0])
# ---------------------------------------------------------------- retries
class RetryTests(GateCase):
async def test_500_then_200_succeeds_on_second_try(self):
self.stub.script = [("status", 500, 0), ("status", 200, 0)]
self.assertEqual(await self.call(make_event()), SKIP)
self.assertEqual(len(self.stub.requests), 2)
self.assertIn("status=200", self.gate_messages()[-1])
async def test_500_three_times_gives_up(self):
self.stub.script = [("status", 500, 0)] * 5
self.assertEqual(await self.call(make_event()), SKIP)
self.assertEqual(len(self.stub.requests), 3)
self.assertIn("status=500", self.gate_messages()[-1])
async def test_4xx_and_3xx_are_not_retried(self):
for code in (400, 401, 404, 302):
with self.subTest(code):
self.stub.requests.clear()
self.stub.script = [("status", code, 0), ("status", 200, 0)]
self.assertEqual(await self.call(make_event()), SKIP)
self.assertEqual(len(self.stub.requests), 1, "retried or followed a redirect")
self.assertIn("status=%d" % code, self.gate_messages()[-1])
def test_connection_refused_ends_after_three_tries(self):
cfg = {"SRS_URL": closed_port_url(), "SRS_SECRET": SECRET}
with mock.patch.object(gate, "TRY_TIMEOUT_S", 0.5), mock.patch.object(
gate._OPENER, "open", wraps=gate._OPENER.open
) as opened:
status, error = gate._post(cfg, gate._payload(make_event()))
self.assertEqual(opened.call_count, 3)
self.assertIsNone(status)
self.assertIsNotNone(error)
async def test_slow_server_hits_per_try_timeout_then_retries(self):
self.stub.script = [("status", 200, 0.6), ("status", 200, 0)]
with mock.patch.object(gate, "TRY_TIMEOUT_S", 0.2):
self.assertEqual(await self.call(make_event()), SKIP)
self.assertEqual(len(self.stub.requests), 2)
self.assertIn("status=200", self.gate_messages()[-1])
# ---------------------------------------------------------------- fail closed
class FailClosedTests(GateCase):
async def test_opener_raises(self):
with mock.patch.object(gate._OPENER, "open", side_effect=RuntimeError("x")):
self.assertEqual(await self.call(make_event()), SKIP)
async def test_bad_events(self):
for name, ev in (("None", None), ("object", object()), ("exploding", ExplodingEvent()), ("string", "hi")):
with self.subTest(name):
self.assertEqual(await self.call(ev), SKIP)
self.assertEqual(await gate.on_inbound(), SKIP)
self.assertEqual(self.stub.requests, [])
async def test_bad_config(self):
cases = {
"missing file": lambda: self.config_path().unlink(),
"no secret": lambda: self.write_config(url=self.stub.url, secret=None),
"empty secret": lambda: self.write_config(url=self.stub.url, secret=""),
"no url": lambda: self.write_config(url=None),
"file scheme": lambda: self.write_config(url="file:///etc/passwd"),
"no scheme": lambda: self.write_config(url="127.0.0.1:8080"),
}
for name, breakit in cases.items():
with self.subTest(name):
breakit()
self.assertEqual(await self.call(make_event()), SKIP)
self.assertEqual(self.stub.requests, [])
async def test_config_error_logged_once_until_fixed(self):
self.write_config(url=self.stub.url, secret=None)
await self.call(make_event())
await self.call(make_event())
self.assertEqual(len(self.gate_messages()), 1)
self.write_config(url=self.stub.url)
await self.call(make_event())
self.write_config(url=self.stub.url, secret=None)
await self.call(make_event())
config_warnings = [m for m in self.gate_messages() if "config" in m]
self.assertEqual(len(config_warnings), 2)
async def test_garbage_reply(self):
self.stub.script = [("garbage",)] * 3
self.assertEqual(await self.call(make_event()), SKIP)
self.assertGreaterEqual(len(self.stub.requests), 1)
async def test_overall_timeout(self):
self.stub.script = [("status", 200, 0.5)]
with mock.patch.object(gate, "OVERALL_TIMEOUT_S", 0.15), mock.patch.object(
gate, "TRIES", 1
), mock.patch.object(gate, "TRY_TIMEOUT_S", 1.0):
started = time.monotonic()
self.assertEqual(await self.call(make_event()), SKIP)
self.assertLess(time.monotonic() - started, 0.45)
self.assertIn("TimeoutError", self.gate_messages()[-1])
def test_post_never_raises_even_systemexit(self):
cfg = {"SRS_URL": self.stub.url, "SRS_SECRET": SECRET}
with mock.patch.object(gate._OPENER, "open", side_effect=SystemExit(3)):
status, error = gate._post(cfg, gate._payload(make_event()))
self.assertIsNone(status)
self.assertEqual(error, "SystemExit")
async def test_systemexit_in_http_call(self):
with mock.patch.object(gate._OPENER, "open", side_effect=SystemExit(3)):
self.assertEqual(await self.call(make_event()), SKIP)
async def test_backstop_catches_systemexit_outside_the_thread(self):
with mock.patch.object(gate, "_load_config", side_effect=SystemExit(3)):
self.assertEqual(await self.call(make_event()), SKIP)
async def test_backstop_catches_unexpected_error(self):
with mock.patch.object(gate, "_payload", side_effect=ValueError("x")):
self.assertEqual(await self.call(make_event()), SKIP)
# ---------------------------------------------------------------- loop behaviour
class LoopTests(GateCase):
async def test_slow_srs_api_does_not_block_the_loop(self):
self.stub.script = [("status", 200, 0.8)]
ticks = 0
done = asyncio.Event()
async def ticker():
nonlocal ticks
while not done.is_set():
await asyncio.sleep(0.02)
ticks += 1
async def run():
try:
return await self.call(make_event())
finally:
done.set()
result, _ = await asyncio.gather(run(), ticker())
self.assertEqual(result, SKIP)
self.assertGreaterEqual(ticks, 15, "event loop was blocked while waiting for srs-api")
async def test_cancellation_is_re_raised(self):
self.stub.script = [("status", 200, 0.6)]
with mock.patch.object(gate, "TRIES", 1):
task = asyncio.ensure_future(self.call(make_event()))
await asyncio.sleep(0.15)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
# ---------------------------------------------------------------- registration
class RegistrationTests(unittest.TestCase):
def test_register_hooks_once(self):
calls = []
ctx = SimpleNamespace(register_hook=lambda name, cb: calls.append((name, cb)))
gate.register(ctx)
self.assertEqual(calls, [("pre_gateway_dispatch", gate.on_inbound)])
def test_callback_is_async(self):
self.assertTrue(inspect.iscoroutinefunction(gate.on_inbound))
def test_manifest(self):
text = (HERE / "plugin.yaml").read_text(encoding="utf-8")
self.assertIn("name: srs-gate", text)
self.assertIn("- pre_gateway_dispatch", text)
def test_returns_a_fresh_skip_each_time(self):
a = asyncio.run(gate.on_inbound(event=None))
a["action"] = "allow"
self.assertEqual(asyncio.run(gate.on_inbound(event=None)), SKIP)
if __name__ == "__main__":
unittest.main()