From 4bcd8380019ca8cea2c080bf0b7212e95694a188 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Mon, 29 Dec 2025 14:49:38 +0400 Subject: [PATCH] test: add offline bridge and client coverage --- .../tests/test_exec_bridge.py | 221 +++++++++++++++++- .../tests/test_exec_render.py | 25 ++ .../tests/test_exec_runner.py | 31 +++ .../tests/test_telegram_client.py | 79 +++++++ 4 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 codex_telegram_bridge/tests/test_exec_runner.py create mode 100644 codex_telegram_bridge/tests/test_telegram_client.py diff --git a/codex_telegram_bridge/tests/test_exec_bridge.py b/codex_telegram_bridge/tests/test_exec_bridge.py index a98acbd..ffbe006 100644 --- a/codex_telegram_bridge/tests/test_exec_bridge.py +++ b/codex_telegram_bridge/tests/test_exec_bridge.py @@ -1,6 +1,11 @@ import asyncio -from codex_telegram_bridge.exec_bridge import extract_session_id, truncate_for_telegram +from codex_telegram_bridge.exec_bridge import ( + extract_session_id, + prepare_telegram, + resolve_resume_session, + truncate_for_telegram, +) def test_extract_session_id_finds_uuid_v7() -> None: @@ -17,6 +22,38 @@ def test_extract_session_id_requires_resume_line() -> None: assert extract_session_id(text) is None +def test_extract_session_id_uses_last_resume_line() -> None: + uuid_first = "019b66fc-64c2-7a71-81cd-081c504cfeb2" + uuid_last = "123e4567-e89b-12d3-a456-426614174000" + text = f"resume: `{uuid_first}`\n\nresume: `{uuid_last}`" + + assert extract_session_id(text) == uuid_last + + +def test_extract_session_id_ignores_malformed_resume_line() -> None: + text = "resume: not-a-uuid" + + assert extract_session_id(text) is None + + +def test_resolve_resume_session_prefers_message_text() -> None: + uuid_message = "123e4567-e89b-12d3-a456-426614174000" + uuid_reply = "019b66fc-64c2-7a71-81cd-081c504cfeb2" + + assert ( + resolve_resume_session( + f"resume: `{uuid_message}`", f"resume: `{uuid_reply}`" + ) + == uuid_message + ) + + +def test_resolve_resume_session_uses_reply_when_missing() -> None: + uuid_reply = "019b66fc-64c2-7a71-81cd-081c504cfeb2" + + assert resolve_resume_session("no resume here", f"resume: `{uuid_reply}`") == uuid_reply + + def test_truncate_for_telegram_preserves_resume_line() -> None: uuid = "019b66fc-64c2-7a71-81cd-081c504cfeb2" md = ("x" * 10_000) + f"\nresume: `{uuid}`" @@ -28,6 +65,24 @@ def test_truncate_for_telegram_preserves_resume_line() -> None: assert out.rstrip().endswith(f"resume: `{uuid}`") +def test_truncate_for_telegram_keeps_last_non_empty_line() -> None: + md = "intro\n\n" + ("x" * 500) + "\nlast line" + + out = truncate_for_telegram(md, 120) + + assert len(out) <= 120 + assert out.rstrip().endswith("last line") + + +def test_prepare_telegram_drops_entities_on_truncate() -> None: + md = ("**bold** " * 200).strip() + + rendered, entities = prepare_telegram(md, limit=40) + + assert len(rendered) <= 40 + assert entities is None + + class _FakeBot: def __init__(self) -> None: self._next_id = 1 @@ -91,6 +146,43 @@ class _FakeRunner: return ("019b66fc-64c2-7a71-81cd-081c504cfeb2", self._answer, self._saw_agent_message) +class _FakeClock: + def __init__(self, start: float = 0.0) -> None: + self._now = start + + def __call__(self) -> float: + return self._now + + def set(self, value: float) -> None: + self._now = value + + +class _FakeRunnerWithEvents: + def __init__( + self, + *, + events: list[dict], + times: list[float], + clock: _FakeClock, + answer: str = "ok", + session_id: str = "019b66fc-64c2-7a71-81cd-081c504cfeb2", + ) -> None: + self._events = events + self._times = times + self._clock = clock + self._answer = answer + self._session_id = session_id + + async def run_serialized(self, *_args, **kwargs) -> tuple[str, str, bool]: + on_event = kwargs.get("on_event") + if on_event is not None: + for when, event in zip(self._times, self._events, strict=False): + self._clock.set(when) + await on_event(event) + await asyncio.sleep(0) + return (self._session_id, self._answer, True) + + def test_final_notify_sends_loud_final_message() -> None: from codex_telegram_bridge.exec_bridge import BridgeConfig, _handle_message @@ -147,3 +239,130 @@ def test_new_final_message_forces_notification_when_too_long_to_edit() -> None: assert len(bot.send_calls) == 2 assert bot.send_calls[0]["disable_notification"] is True assert bot.send_calls[1]["disable_notification"] is False + + +def test_progress_edits_are_rate_limited() -> None: + from codex_telegram_bridge.exec_bridge import BridgeConfig, _handle_message + + bot = _FakeBot() + clock = _FakeClock() + events = [ + { + "type": "item.started", + "item": { + "id": "item_0", + "type": "command_execution", + "command": "echo 1", + "status": "in_progress", + }, + }, + { + "type": "item.completed", + "item": { + "id": "item_0", + "type": "command_execution", + "command": "echo 1", + "exit_code": 0, + "status": "completed", + }, + }, + { + "type": "item.started", + "item": { + "id": "item_1", + "type": "command_execution", + "command": "echo 2", + "status": "in_progress", + }, + }, + ] + runner = _FakeRunnerWithEvents( + events=events, + times=[0.2, 0.4, 1.2], + clock=clock, + ) + cfg = BridgeConfig( + bot=bot, # type: ignore[arg-type] + runner=runner, # type: ignore[arg-type] + chat_id=123, + final_notify=True, + startup_msg="", + max_concurrency=1, + ) + + asyncio.run( + _handle_message( + cfg, + chat_id=123, + user_msg_id=10, + text="hi", + resume_session=None, + clock=clock, + progress_edit_every=1.0, + ) + ) + + assert len(bot.edit_calls) == 1 + + +def test_bridge_flow_sends_progress_edits_and_final_resume() -> None: + from codex_telegram_bridge.exec_bridge import BridgeConfig, _handle_message + + bot = _FakeBot() + clock = _FakeClock() + events = [ + { + "type": "item.started", + "item": { + "id": "item_0", + "type": "command_execution", + "command": "echo ok", + "status": "in_progress", + }, + }, + { + "type": "item.completed", + "item": { + "id": "item_0", + "type": "command_execution", + "command": "echo ok", + "exit_code": 0, + "status": "completed", + }, + }, + ] + session_id = "123e4567-e89b-12d3-a456-426614174000" + runner = _FakeRunnerWithEvents( + events=events, + times=[0.0, 2.1], + clock=clock, + answer="done", + session_id=session_id, + ) + cfg = BridgeConfig( + bot=bot, # type: ignore[arg-type] + runner=runner, # type: ignore[arg-type] + chat_id=123, + final_notify=True, + startup_msg="", + max_concurrency=1, + ) + + asyncio.run( + _handle_message( + cfg, + chat_id=123, + user_msg_id=42, + text="do it", + resume_session=None, + clock=clock, + progress_edit_every=1.0, + ) + ) + + assert bot.send_calls[0]["reply_to_message_id"] == 42 + assert "working" in bot.send_calls[0]["text"] + assert len(bot.edit_calls) >= 1 + assert session_id in bot.send_calls[-1]["text"] + assert "resume:" in bot.send_calls[-1]["text"].lower() + assert len(bot.delete_calls) == 1 diff --git a/codex_telegram_bridge/tests/test_exec_render.py b/codex_telegram_bridge/tests/test_exec_render.py index 3fe150c..069bdd9 100644 --- a/codex_telegram_bridge/tests/test_exec_render.py +++ b/codex_telegram_bridge/tests/test_exec_render.py @@ -75,3 +75,28 @@ def test_progress_renderer_renders_progress_and_final() -> None: assert "running:" not in final assert "ran:" not in final assert final.endswith("answer") + + +def test_progress_renderer_clamps_actions_and_ignores_unknown() -> None: + r = ExecProgressRenderer(max_actions=3, command_width=20) + events = [ + { + "type": "item.completed", + "item": { + "id": f"item_{i}", + "type": "command_execution", + "command": f"echo {i}", + "exit_code": 0, + "status": "completed", + }, + } + for i in range(6) + ] + + for evt in events: + assert r.note_event(evt) is True + + assert len(r.recent_actions) == 3 + assert r.recent_actions[0].startswith("3 ") + assert r.recent_actions[-1].startswith("5 ") + assert r.note_event({"type": "mystery"}) is False diff --git a/codex_telegram_bridge/tests/test_exec_runner.py b/codex_telegram_bridge/tests/test_exec_runner.py new file mode 100644 index 0000000..60dabea --- /dev/null +++ b/codex_telegram_bridge/tests/test_exec_runner.py @@ -0,0 +1,31 @@ +import asyncio + +from codex_telegram_bridge.exec_bridge import CodexExecRunner + + +def test_run_serialized_serializes_same_session() -> None: + runner = CodexExecRunner(codex_cmd="codex", workspace=None, extra_args=[]) + gate = asyncio.Event() + in_flight = 0 + max_in_flight = 0 + + async def run_stub(*_args, **_kwargs): + nonlocal in_flight, max_in_flight + in_flight += 1 + max_in_flight = max(max_in_flight, in_flight) + await gate.wait() + in_flight -= 1 + return ("sid", "ok", True) + + runner.run = run_stub # type: ignore[assignment] + + async def run_test() -> None: + t1 = asyncio.create_task(runner.run_serialized("a", "sid")) + t2 = asyncio.create_task(runner.run_serialized("b", "sid")) + await asyncio.sleep(0) + gate.set() + await asyncio.gather(t1, t2) + + asyncio.run(run_test()) + + assert max_in_flight == 1 diff --git a/codex_telegram_bridge/tests/test_telegram_client.py b/codex_telegram_bridge/tests/test_telegram_client.py new file mode 100644 index 0000000..8a7064f --- /dev/null +++ b/codex_telegram_bridge/tests/test_telegram_client.py @@ -0,0 +1,79 @@ +import asyncio +import logging + +import httpx +import pytest + +from codex_telegram_bridge.logging import RedactTokenFilter +from codex_telegram_bridge.telegram_client import TelegramClient + + +def test_telegram_429_retry_after_calls_sleep() -> None: + calls: list[int] = [] + sleeps: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + sleeps.append(seconds) + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(1) + if len(calls) == 1: + return httpx.Response( + 429, + json={ + "ok": False, + "description": "retry", + "parameters": {"retry_after": 3}, + }, + request=request, + ) + return httpx.Response( + 200, + json={"ok": True, "result": {"message_id": 1}}, + request=request, + ) + + transport = httpx.MockTransport(handler) + + async def run() -> dict: + client = httpx.AsyncClient(transport=transport) + try: + tg = TelegramClient("123:abcDEF_ghij", client=client, sleep=fake_sleep) + return await tg._post("sendMessage", {"chat_id": 1, "text": "hi"}) + finally: + await client.aclose() + + result = asyncio.run(run()) + + assert result == {"message_id": 1} + assert sleeps == [3] + assert len(calls) == 2 + + +def test_no_token_in_logs_on_http_error(caplog: pytest.LogCaptureFixture) -> None: + token = "123:abcDEF_ghij" + redactor = RedactTokenFilter() + root_logger = logging.getLogger() + root_logger.addFilter(redactor) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, text="oops", request=request) + + transport = httpx.MockTransport(handler) + + async def run() -> None: + client = httpx.AsyncClient(transport=transport) + try: + tg = TelegramClient(token, client=client) + await tg._post("getUpdates", {"timeout": 1}) + finally: + await client.aclose() + + caplog.set_level(logging.ERROR) + with pytest.raises(httpx.HTTPStatusError): + asyncio.run(run()) + + root_logger.removeFilter(redactor) + + assert token not in caplog.text + assert "bot[REDACTED]" in caplog.text