Files
roboco/tests/unit/services/test_telegram_bridge.py
T
51de1df363 fix(tests): satisfy the full gate's tests/-scoped mypy (#577)
CI runs `mypy roboco/ tests/`; the bridge/cockpit suites were only gated
against `mypy roboco/` locally. Real credentials dataclass instead of
SimpleNamespace, AsyncMock casts where mocks sit behind typed fields, a
return annotation on the fake stream, a None-guard on the consumer task,
and a fresh registry lookup where mypy's literal narrowing read an
assert as always-false.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-19 07:40:18 +02:00

313 lines
10 KiB
Python

"""Telegram ↔ live-chat bridge (P5): session lifecycle, free-text routing,
the stream consumer's turn/draft forwarding, idle sweep, and the engine's
/secretary /newtask /end + intake-callback wiring.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services import telegram_bridge as bridge
from roboco.services import telegram_inbound as ti
from roboco.services.telegram_credentials import TelegramCredentialsData
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@pytest.fixture(autouse=True)
def _clean_state() -> Any:
bridge._SESSIONS.clear()
bridge._PENDING_NEWTASK.clear()
yield
bridge._SESSIONS.clear()
bridge._PENDING_NEWTASK.clear()
def _fake_session_db() -> MagicMock:
session = MagicMock()
session.commit = AsyncMock()
session.execute = AsyncMock()
return session
def _engine() -> ti.TelegramInboundEngine:
return ti.TelegramInboundEngine(_fake_session_db())
CREDS = TelegramCredentialsData(bot_token="123:ABC", chat_id="777")
class FakeRegistry:
def __init__(self, events: list[dict[str, Any]] | None = None) -> None:
self.events = events or []
self.delivered: list[tuple[str, str]] = []
self.parked: list[tuple[str, str]] = []
async def deliver(self, session_id: str, text: str) -> bool:
self.delivered.append((session_id, text))
return True
def park(self, session_id: str, task_id: str) -> bool:
self.parked.append((session_id, task_id))
return True
async def stream(self, _session_id: str) -> AsyncIterator[dict[str, Any]]:
for event in self.events:
yield event
def _bridge_session(
kind: str = "secretary", *, parked: bool = False
) -> bridge.BridgeSession:
sess = bridge.BridgeSession(kind=kind, session_id=uuid4().hex, client=AsyncMock())
sess.parked = parked
return sess
# ---------------------------------------------------------------------------
# bridge module — lifecycle + routing
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_deliver_text_routes_only_bridged_chats(
monkeypatch: pytest.MonkeyPatch,
) -> None:
registry = FakeRegistry()
monkeypatch.setattr(
"roboco.services.prompter_live.get_live_registry", lambda: registry
)
assert await bridge.deliver_text("777", "hello") is None
sess = _bridge_session()
bridge._SESSIONS["777"] = sess
assert await bridge.deliver_text("777", "hello") == ""
assert registry.delivered == [(sess.session_id, "hello")]
@pytest.mark.asyncio
async def test_end_session_reaps_by_kind(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = SimpleNamespace(
reap_secretary_session=AsyncMock(), reap_intake_session=AsyncMock()
)
monkeypatch.setattr(bridge, "_orchestrator", lambda: orch)
sess = _bridge_session("intake")
bridge._SESSIONS["777"] = sess
assert await bridge.end_session("777") == "Ended."
orch.reap_intake_session.assert_awaited_once_with(sess.session_id)
assert "777" not in bridge._SESSIONS
assert await bridge.end_session("777") == "No active session."
@pytest.mark.asyncio
async def test_sweep_idle_skips_parked_sessions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = SimpleNamespace(
reap_secretary_session=AsyncMock(), reap_intake_session=AsyncMock()
)
monkeypatch.setattr(bridge, "_orchestrator", lambda: orch)
monkeypatch.setattr(bridge.settings, "interactive_idle_reap_seconds", 100)
idle = _bridge_session("secretary")
idle.last_user_turn -= 1000
parked = _bridge_session("intake", parked=True)
parked.last_user_turn -= 1000
bridge._SESSIONS["idle-chat"] = idle
bridge._SESSIONS["parked-chat"] = parked
await bridge.sweep_idle()
assert "idle-chat" not in bridge._SESSIONS
assert "parked-chat" in bridge._SESSIONS
orch.reap_secretary_session.assert_awaited_once()
@pytest.mark.asyncio
async def test_consumer_accumulates_turns_and_surfaces_drafts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
draft = {"title": "Fix <thing>", "team": "backend", "description": "d"}
registry = FakeRegistry(
[
{"kind": "text", "text": "Hello "},
{"kind": "text", "text": "CEO."},
{"kind": "turn_end", "text": ""},
{"kind": "draft", "text": "", "data": draft},
]
)
monkeypatch.setattr(
"roboco.services.prompter_live.get_live_registry", lambda: registry
)
sess = _bridge_session("intake")
bridge._SESSIONS["777"] = sess
await bridge._consume("777", sess)
calls = cast("AsyncMock", sess.client).send_message.await_args_list
# Turn text, then the draft card, then the end-of-session note.
assert calls[0].args[0] == "Hello CEO."
assert "Fix &lt;thing&gt;" in calls[1].args[0]
keyboard = calls[1].kwargs["reply_markup"]["inline_keyboard"][0]
assert keyboard[0]["callback_data"].startswith("apv:intake:")
assert keyboard[1]["callback_data"].startswith("rej:intake:")
assert sess.pending_draft == draft
assert calls[-1].args[0] == "Session ended."
# Stream ended → session evicted, client closed.
assert "777" not in bridge._SESSIONS
cast("AsyncMock", sess.client).close.assert_awaited_once()
@pytest.mark.asyncio
async def test_start_secretary_opens_session_and_consumer(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = SimpleNamespace(start_secretary_session=AsyncMock())
monkeypatch.setattr(bridge, "_orchestrator", lambda: orch)
monkeypatch.setattr(bridge, "_build_client", lambda _c: AsyncMock())
consume = AsyncMock()
monkeypatch.setattr(bridge, "_consume", consume)
message = await bridge.start_secretary("777", "plan my day", CREDS)
assert "On it" in message
sess = bridge._SESSIONS["777"]
assert sess.kind == "secretary"
orch.start_secretary_session.assert_awaited_once_with(
sess.session_id, initial_message="plan my day"
)
assert sess.consumer is not None
await sess.consumer # the mocked _consume task completes cleanly
# ---------------------------------------------------------------------------
# engine wiring — commands + callbacks
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_secretary_command_starts_or_continues(
monkeypatch: pytest.MonkeyPatch,
) -> None:
start = AsyncMock(return_value="🎩 On it…")
monkeypatch.setattr(bridge, "start_secretary", start)
client = AsyncMock()
await _engine()._dispatch_command(
"secretary", "plan", client, chat_id="777", creds=CREDS
)
start.assert_awaited_once_with("777", "plan", CREDS)
bridge._SESSIONS["777"] = _bridge_session("intake")
await _engine()._dispatch_command(
"secretary", "plan", client, chat_id="777", creds=CREDS
)
assert "mid /newtask" in client.send_message.await_args.args[0]
@pytest.mark.asyncio
async def test_newtask_with_multiple_projects_asks_which(
monkeypatch: pytest.MonkeyPatch,
) -> None:
projects = [
SimpleNamespace(id=uuid4(), slug="roboco", name="RoboCo"),
SimpleNamespace(id=uuid4(), slug="website", name="Website"),
]
svc = MagicMock(list_all=AsyncMock(return_value=projects))
monkeypatch.setattr(ti, "get_project_service", lambda _s: svc)
client = AsyncMock()
await _engine()._dispatch_command(
"newtask", "ship a thing", client, chat_id="777", creds=CREDS
)
assert bridge._PENDING_NEWTASK["777"] == "ship a thing"
keyboard = client.send_message.await_args.kwargs["reply_markup"]
labels = [row[0]["text"] for row in keyboard["inline_keyboard"]]
assert labels == ["RoboCo", "Website"]
@pytest.mark.asyncio
async def test_project_pick_callback_starts_intake_with_stored_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
project = SimpleNamespace(id=uuid4(), slug="roboco", name="RoboCo")
svc = MagicMock(list_all=AsyncMock(return_value=[project]))
monkeypatch.setattr(ti, "get_project_service", lambda _s: svc)
start = AsyncMock(return_value="📝 Intake on RoboCo")
monkeypatch.setattr(bridge, "start_intake", start)
bridge._PENDING_NEWTASK["777"] = "ship a thing"
client = AsyncMock()
parsed = ti.parse_callback(f"sel:proj:{str(project.id)[:8]}")
assert parsed is not None
await _engine()._handle_bridge_callback(parsed, "777", 5, CREDS, client)
start.assert_awaited_once_with("777", "ship a thing", CREDS, project=project)
@pytest.mark.asyncio
async def test_intake_confirm_routes_board_and_parks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
task_id = uuid4()
prompter = MagicMock(confirm_live_draft=AsyncMock(return_value=task_id))
monkeypatch.setattr(
"roboco.services.prompter.get_prompter_service", lambda _s: prompter
)
registry = FakeRegistry()
monkeypatch.setattr(
"roboco.services.prompter_live.get_live_registry", lambda: registry
)
project_id = uuid4()
sess = _bridge_session("intake")
sess.project_id = str(project_id)
sess.pending_draft = {"title": "T"}
bridge._SESSIONS["777"] = sess
ok, text = await _engine()._confirm_intake_draft("777")
assert ok is True
assert "Board review" in text
prompter.confirm_live_draft.assert_awaited_once_with(
{"title": "T"}, ti._CEO_UUID, project_id=project_id, route="board"
)
assert registry.parked == [(sess.session_id, str(task_id))]
assert sess.parked is True
assert sess.pending_draft is None
@pytest.mark.asyncio
async def test_intake_discard_keeps_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
sess = _bridge_session("intake")
sess.pending_draft = {"title": "T"}
bridge._SESSIONS["777"] = sess
engine = _engine()
finish = AsyncMock()
monkeypatch.setattr(engine, "_finish_action", finish)
client = AsyncMock()
parsed = ti.parse_callback(f"rej:intake:{sess.session_id[:8]}")
assert parsed is not None
await engine._handle_bridge_callback(parsed, "777", 5, CREDS, client)
# Fresh registry lookup — asserting on the narrowed local would read as
# always-false to mypy after the literal assignment above.
assert bridge._SESSIONS["777"].pending_draft is None
last_call = finish.await_args
assert last_call is not None
assert last_call.args[2] is True