mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat: Telegram V2 — inbound commands + actionable approve/reject from chat (#551)
* feat(telegram): V2 inbound — command router, actionable approve/reject keyboards, chat-gated poll loop * fix(release,x,video,telegram): terminal-state guards on approve/reject; sender-identity check * docs(map,rag): Telegram V2 inbound surfaces and terminal-state approve/reject guards --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -284,7 +284,14 @@ async def test_notify_telegram_send_deferred_to_after_commit(
|
||||
sent: list[str] = []
|
||||
|
||||
class _FakeTelegramClient:
|
||||
async def send_message(self, text: str) -> TelegramSendResult:
|
||||
async def send_message(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
reply_markup: dict | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> TelegramSendResult:
|
||||
_ = (reply_markup, reply_to_message_id)
|
||||
sent.append(text)
|
||||
return TelegramSendResult(sent=True)
|
||||
|
||||
@@ -331,7 +338,14 @@ async def test_notify_telegram_rollback_drops_send(
|
||||
sent: list[str] = []
|
||||
|
||||
class _FakeTelegramClient:
|
||||
async def send_message(self, text: str) -> TelegramSendResult:
|
||||
async def send_message(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
reply_markup: dict | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> TelegramSendResult:
|
||||
_ = (reply_markup, reply_to_message_id)
|
||||
sent.append(text)
|
||||
return TelegramSendResult(sent=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Telegram inbound (V2) is gated by default-off config flags (mirrors the
|
||||
x_feature_spotlight sub-switch pattern)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from roboco.config import Settings
|
||||
from roboco.services.settings import (
|
||||
FEATURE_FLAGS,
|
||||
SettingValidationError,
|
||||
validate_setting,
|
||||
)
|
||||
|
||||
_DEFAULT_POLL_INTERVAL_SECONDS = 5.0
|
||||
_DEFAULT_POLL_TIMEOUT_SECONDS = 25
|
||||
_DEFAULT_MAX_UPDATES_PER_CYCLE = 50
|
||||
_OVERRIDE_POLL_INTERVAL_SECONDS = 10.0
|
||||
|
||||
|
||||
def test_telegram_inbound_disabled_by_default() -> None:
|
||||
s = Settings()
|
||||
assert s.telegram_inbound_enabled is False
|
||||
assert s.telegram_poll_interval_seconds == _DEFAULT_POLL_INTERVAL_SECONDS
|
||||
assert s.telegram_poll_timeout_seconds == _DEFAULT_POLL_TIMEOUT_SECONDS
|
||||
assert s.telegram_max_updates_per_cycle == _DEFAULT_MAX_UPDATES_PER_CYCLE
|
||||
|
||||
|
||||
def test_telegram_inbound_reads_env_var() -> None:
|
||||
with mock.patch.dict(os.environ, {"ROBOCO_TELEGRAM_INBOUND_ENABLED": "true"}):
|
||||
assert Settings().telegram_inbound_enabled is True
|
||||
|
||||
|
||||
def test_telegram_poll_interval_reads_env_var() -> None:
|
||||
with mock.patch.dict(os.environ, {"ROBOCO_TELEGRAM_POLL_INTERVAL_SECONDS": "10"}):
|
||||
assert (
|
||||
Settings().telegram_poll_interval_seconds == _OVERRIDE_POLL_INTERVAL_SECONDS
|
||||
)
|
||||
|
||||
|
||||
def test_telegram_inbound_flag_registered_in_feature_flags() -> None:
|
||||
assert "telegram_inbound_enabled" in [key for key, _ in FEATURE_FLAGS]
|
||||
|
||||
|
||||
def test_telegram_inbound_flag_validates_as_bool() -> None:
|
||||
validate_setting("telegram_inbound_enabled", "true")
|
||||
|
||||
|
||||
def test_telegram_last_update_id_validates_as_int() -> None:
|
||||
validate_setting("telegram_last_update_id", "12345")
|
||||
|
||||
|
||||
def test_telegram_last_update_id_rejects_non_int() -> None:
|
||||
with pytest.raises(SettingValidationError):
|
||||
validate_setting("telegram_last_update_id", "not-a-number")
|
||||
|
||||
|
||||
def test_telegram_last_update_id_rejects_negative() -> None:
|
||||
with pytest.raises(SettingValidationError):
|
||||
validate_setting("telegram_last_update_id", "-1")
|
||||
|
||||
|
||||
def test_telegram_last_update_id_not_a_feature_flag() -> None:
|
||||
"""It's an internal cursor, not a panel-tunable master switch."""
|
||||
assert "telegram_last_update_id" not in [key for key, _ in FEATURE_FLAGS]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""The Telegram inbound orchestrator loop is fully dormant when disabled
|
||||
(default) — mirrors ``test_x_engine_loop_dormant.py``.
|
||||
|
||||
With either ``telegram_enabled`` or ``telegram_inbound_enabled`` off,
|
||||
``_telegram_poll_loop`` must return immediately — no sleep, no HTTP, no DB —
|
||||
so a standard deployment (and even a V1-only deployment with just
|
||||
``telegram_enabled`` on) behaves exactly as today.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings as cfg
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_poll_loop_returns_immediately_when_both_off(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(cfg, "telegram_enabled", False)
|
||||
monkeypatch.setattr(cfg, "telegram_inbound_enabled", False)
|
||||
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
|
||||
await asyncio.wait_for(AgentOrchestrator._telegram_poll_loop(stub), timeout=1.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_poll_loop_dormant_when_only_v1_flag_on(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""V1-only deployment (notifications, no inbound): the poll loop still
|
||||
never runs."""
|
||||
monkeypatch.setattr(cfg, "telegram_enabled", True)
|
||||
monkeypatch.setattr(cfg, "telegram_inbound_enabled", False)
|
||||
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
|
||||
await asyncio.wait_for(AgentOrchestrator._telegram_poll_loop(stub), timeout=1.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_poll_loop_dormant_when_only_inbound_flag_on(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The sub-switch alone (V1 master switch off) is still fully inert."""
|
||||
monkeypatch.setattr(cfg, "telegram_enabled", False)
|
||||
monkeypatch.setattr(cfg, "telegram_inbound_enabled", True)
|
||||
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
|
||||
await asyncio.wait_for(AgentOrchestrator._telegram_poll_loop(stub), timeout=1.0)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""``approve()``/``reject()`` terminal-status guards.
|
||||
|
||||
The confirmed HIGH-severity bug: ``reject()`` sets a proposal CANCELLED, but
|
||||
(pre-fix) ``approve()`` checked nothing — a stale Approve (e.g. a Telegram
|
||||
button clicked after a reject) re-ran the fail-closed executor over a
|
||||
rejected proposal. This mirrors the analogous DB-backed regression tests in
|
||||
``test_x_post_service.py`` / ``test_video_post_service.py``, patching only
|
||||
the executor seam (``get_release_executor``) exactly like the other
|
||||
release-proposal hook test files (``test_release_proposal_x_hook.py`` et al.)
|
||||
do for their own seams.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.models.base import AgentRole, AgentStatus, TaskNature, TaskStatus, TaskType
|
||||
from roboco.models.base import Team as T
|
||||
from roboco.services.release_proposal import (
|
||||
ReleaseProposalService,
|
||||
TaskAlreadyCompletedError,
|
||||
)
|
||||
from roboco.services.release_readiness import ReleaseReadinessReport, report_to_dict
|
||||
from roboco.services.task import RELEASE_MANAGER_SOURCE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
_VERSION = "0.18.0"
|
||||
|
||||
|
||||
def _report() -> ReleaseReadinessReport:
|
||||
return ReleaseReadinessReport(
|
||||
proposed_version=_VERSION,
|
||||
bump_kind="minor",
|
||||
change_summary=["feat: a thing"],
|
||||
drafted_changelog=f"## [{_VERSION}]\n\n### Added\n- a thing\n",
|
||||
version_bump_plan=["pyproject.toml"],
|
||||
gaps=[],
|
||||
migration_notes=[],
|
||||
gate_state="green",
|
||||
)
|
||||
|
||||
|
||||
async def _seed_proposal(session: AsyncSession) -> TaskTable:
|
||||
system_uuid = _foundation.AGENTS["system"].uuid
|
||||
secretary_uuid = _foundation.AGENTS["secretary-1"].uuid
|
||||
for uuid_, slug, role in (
|
||||
(system_uuid, "system", AgentRole.SYSTEM),
|
||||
(secretary_uuid, "secretary-1", AgentRole.SECRETARY),
|
||||
):
|
||||
if await session.get(AgentTable, uuid_) is None:
|
||||
session.add(
|
||||
AgentTable(
|
||||
id=uuid_,
|
||||
name=slug,
|
||||
slug=slug,
|
||||
role=role,
|
||||
team=None,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="RoboCo",
|
||||
slug=f"roboco-{uuid4().hex[:6]}",
|
||||
git_url="https://example.com/roboco.git",
|
||||
assigned_cell=T.BACKEND,
|
||||
created_by=system_uuid,
|
||||
)
|
||||
session.add(project)
|
||||
await session.flush()
|
||||
task = TaskTable(
|
||||
id=uuid4(),
|
||||
title=f"Release proposal: v{_VERSION}",
|
||||
description="proposal body",
|
||||
acceptance_criteria=["CEO approves"],
|
||||
status=TaskStatus.PENDING,
|
||||
priority=2,
|
||||
task_type=TaskType.ADMINISTRATIVE,
|
||||
nature=TaskNature.NON_TECHNICAL,
|
||||
project_id=project.id,
|
||||
created_by=system_uuid,
|
||||
assigned_to=secretary_uuid,
|
||||
team=T.MAIN_PM,
|
||||
source=RELEASE_MANAGER_SOURCE,
|
||||
confirmed_by_human=False,
|
||||
orchestration_markers={"release_report": report_to_dict(_report())},
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
return task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_refuses_already_rejected_proposal(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""The chokepoint guard: approving a CANCELLED (already-rejected)
|
||||
proposal refuses and never invokes the executor — the reproduced bug."""
|
||||
task = await _seed_proposal(db_session)
|
||||
task.status = TaskStatus.CANCELLED
|
||||
await db_session.flush()
|
||||
fake_executor = AsyncMock()
|
||||
fake_executor.execute = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"roboco.services.release_proposal.get_release_executor",
|
||||
AsyncMock(return_value=fake_executor),
|
||||
):
|
||||
result = await ReleaseProposalService(db_session).approve(cast("UUID", task.id))
|
||||
|
||||
assert result is not None
|
||||
assert result.status == "already_rejected"
|
||||
fake_executor.execute.assert_not_awaited()
|
||||
await db_session.refresh(task)
|
||||
assert task.status == TaskStatus.CANCELLED # untouched, never re-run
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_refuses_already_published_proposal(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""The residual: a stale Approve on an already-COMPLETED (published)
|
||||
proposal must refuse without re-entering the executor (which could
|
||||
re-fire the post-publish draft hooks)."""
|
||||
task = await _seed_proposal(db_session)
|
||||
task.status = TaskStatus.COMPLETED
|
||||
await db_session.flush()
|
||||
fake_executor = AsyncMock()
|
||||
fake_executor.execute = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"roboco.services.release_proposal.get_release_executor",
|
||||
AsyncMock(return_value=fake_executor),
|
||||
):
|
||||
result = await ReleaseProposalService(db_session).approve(cast("UUID", task.id))
|
||||
|
||||
assert result is not None
|
||||
assert result.status == "already_published"
|
||||
fake_executor.execute.assert_not_awaited()
|
||||
await db_session.refresh(task)
|
||||
assert task.status == TaskStatus.COMPLETED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_raises_when_already_published(db_session: AsyncSession) -> None:
|
||||
"""The mirror hole: rejecting an already-COMPLETED (published) proposal
|
||||
must refuse — cancelling it would lie about an already-public release."""
|
||||
task = await _seed_proposal(db_session)
|
||||
task.status = TaskStatus.COMPLETED
|
||||
await db_session.flush()
|
||||
|
||||
with pytest.raises(TaskAlreadyCompletedError):
|
||||
await ReleaseProposalService(db_session).reject(
|
||||
cast("UUID", task.id), "needs another migration check"
|
||||
)
|
||||
await db_session.refresh(task)
|
||||
assert task.status == TaskStatus.COMPLETED # untouched, never cancelled
|
||||
@@ -64,3 +64,101 @@ async def test_live_client_send_message_http_error_is_graceful() -> None:
|
||||
assert result.sent is False
|
||||
assert "401" in result.detail
|
||||
await client.close()
|
||||
|
||||
|
||||
_REPLY_TO_MESSAGE_ID = 42
|
||||
_SENT_MESSAGE_ID = 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_client_send_message_with_reply_markup_and_message_id() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content.decode())
|
||||
assert body["reply_markup"] == {"force_reply": True}
|
||||
assert body["reply_to_message_id"] == _REPLY_TO_MESSAGE_ID
|
||||
return httpx.Response(
|
||||
200, json={"ok": True, "result": {"message_id": _SENT_MESSAGE_ID}}
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
http_client = httpx.AsyncClient(transport=transport)
|
||||
client = LiveTelegramClient(_CREDS, timeout=5.0, client=http_client)
|
||||
result = await client.send_message(
|
||||
"hi",
|
||||
reply_markup={"force_reply": True},
|
||||
reply_to_message_id=_REPLY_TO_MESSAGE_ID,
|
||||
)
|
||||
assert result.sent is True
|
||||
assert result.message_id == _SENT_MESSAGE_ID
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_client_get_updates_success() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/bot123456:ABC/getUpdates"
|
||||
assert request.url.params["timeout"] == "25"
|
||||
assert request.url.params["offset"] == "10"
|
||||
return httpx.Response(
|
||||
200, json={"ok": True, "result": [{"update_id": 10}, {"update_id": 11}]}
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
http_client = httpx.AsyncClient(transport=transport)
|
||||
client = LiveTelegramClient(_CREDS, timeout=5.0, client=http_client)
|
||||
updates = await client.get_updates(offset=10, timeout=25, limit=50)
|
||||
assert [u["update_id"] for u in updates] == [10, 11]
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_client_get_updates_network_error_returns_empty() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("boom")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
http_client = httpx.AsyncClient(transport=transport)
|
||||
client = LiveTelegramClient(_CREDS, timeout=5.0, client=http_client)
|
||||
updates = await client.get_updates(offset=None, timeout=25, limit=50)
|
||||
assert updates == []
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_client_answer_callback_query_posts_id() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/bot123456:ABC/answerCallbackQuery"
|
||||
body = json.loads(request.content.decode())
|
||||
assert body == {"callback_query_id": "cq1", "text": "ok"}
|
||||
return httpx.Response(200, json={"ok": True, "result": True})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
http_client = httpx.AsyncClient(transport=transport)
|
||||
client = LiveTelegramClient(_CREDS, timeout=5.0, client=http_client)
|
||||
await client.answer_callback_query("cq1", "ok")
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_client_edit_message_reply_markup_clears_keyboard() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/bot123456:ABC/editMessageReplyMarkup"
|
||||
body = json.loads(request.content.decode())
|
||||
assert body["reply_markup"] == {}
|
||||
return httpx.Response(200, json={"ok": True, "result": True})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
http_client = httpx.AsyncClient(transport=transport)
|
||||
client = LiveTelegramClient(_CREDS, timeout=5.0, client=http_client)
|
||||
await client.edit_message_reply_markup(7, None)
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_client_v2_methods_are_all_noops() -> None:
|
||||
client: NullTelegramClient = NullTelegramClient()
|
||||
assert await client.get_updates(offset=None, timeout=25, limit=50) == []
|
||||
# None of these raise — that's the whole contract.
|
||||
await client.answer_callback_query("cq1", "text")
|
||||
await client.edit_message_reply_markup(1, None)
|
||||
await client.edit_message_text(1, "done")
|
||||
|
||||
@@ -0,0 +1,745 @@
|
||||
"""TelegramInboundEngine coverage: chat-id rejection, offset advancement, and
|
||||
one test per approve/reject-kind dispatching to a mocked service (asserting
|
||||
CEO identity + reason threading). Every service factory the engine calls is
|
||||
monkeypatched module-level — no DB, no network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.models.base import TaskStatus
|
||||
from roboco.services import telegram_inbound as ti
|
||||
from roboco.services.roadmap_service import RoadmapItemResult
|
||||
from roboco.services.telegram_credentials import TelegramCredentialsData
|
||||
from roboco.services.video_post_service import VideoPostExecuteResult
|
||||
from roboco.services.x_post_service import XPostExecuteResult
|
||||
|
||||
CEO_UUID = ti._CEO_UUID
|
||||
|
||||
|
||||
def _uuid_with_prefix(prefix: str) -> UUID:
|
||||
"""A real UUID whose ``str(uuid)[:8] == prefix`` — the id8 convention."""
|
||||
return UUID(hex=prefix + uuid4().hex[len(prefix) :])
|
||||
|
||||
|
||||
def _fake_task(id8: str = "a1b2c3d4", title: str = "Test task") -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=_uuid_with_prefix(id8),
|
||||
title=title,
|
||||
description="",
|
||||
pr_url=None,
|
||||
status=SimpleNamespace(value="pending"),
|
||||
team=None,
|
||||
)
|
||||
|
||||
|
||||
def _fake_session() -> MagicMock:
|
||||
"""``session.add`` is sync in real SQLAlchemy (a plain MagicMock call, no
|
||||
"never awaited" warning); only the awaited methods this engine actually
|
||||
uses get an AsyncMock."""
|
||||
session = MagicMock()
|
||||
session.commit = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
def _engine() -> ti.TelegramInboundEngine:
|
||||
"""A bare engine over a mocked session — nothing in these tests touches
|
||||
real DB rows, only the monkeypatched service factories."""
|
||||
return ti.TelegramInboundEngine(_fake_session())
|
||||
|
||||
|
||||
CREDS = TelegramCredentialsData(bot_token="123:ABC", chat_id="777")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chat-id rejection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_chat_message_is_dropped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
dispatch = AsyncMock()
|
||||
monkeypatch.setattr(engine, "_dispatch_command", dispatch)
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._handle_message(
|
||||
{"chat": {"id": 999}, "text": "/status"}, CREDS, client
|
||||
)
|
||||
|
||||
dispatch.assert_not_called()
|
||||
client.send_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorized_chat_message_dispatches_command(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
dispatch = AsyncMock()
|
||||
monkeypatch.setattr(engine, "_dispatch_command", dispatch)
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._handle_message(
|
||||
{"chat": {"id": 777}, "text": "/status"}, CREDS, client
|
||||
)
|
||||
|
||||
dispatch.assert_awaited_once_with("status", "", client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_chat_callback_answers_not_authorized() -> None:
|
||||
engine = _engine()
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._handle_callback(
|
||||
{
|
||||
"id": "cq1",
|
||||
"data": "apv:xpost:a1b2c3d4",
|
||||
"message": {"chat": {"id": 999}, "message_id": 5},
|
||||
},
|
||||
CREDS,
|
||||
client,
|
||||
)
|
||||
|
||||
client.answer_callback_query.assert_awaited_once_with("cq1", "Not authorized")
|
||||
client.send_message.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sender identity — defense-in-depth on top of chat-id authorization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_with_mismatched_sender_is_dropped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The right chat, but a `from.id` that disagrees with it (would only
|
||||
happen if the "private" chat somehow carried a second poster) — dropped
|
||||
silently, same as an unauthorized chat."""
|
||||
engine = _engine()
|
||||
dispatch = AsyncMock()
|
||||
monkeypatch.setattr(engine, "_dispatch_command", dispatch)
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._handle_message(
|
||||
{"chat": {"id": 777}, "from": {"id": 999}, "text": "/status"}, CREDS, client
|
||||
)
|
||||
|
||||
dispatch.assert_not_called()
|
||||
client.send_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_with_matching_sender_dispatches(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
dispatch = AsyncMock()
|
||||
monkeypatch.setattr(engine, "_dispatch_command", dispatch)
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._handle_message(
|
||||
{"chat": {"id": 777}, "from": {"id": 777}, "text": "/status"}, CREDS, client
|
||||
)
|
||||
|
||||
dispatch.assert_awaited_once_with("status", "", client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_without_from_keeps_prior_behavior(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No `from` on the update at all — the pre-Fix-2 behavior (chat-id-only)
|
||||
is unchanged."""
|
||||
engine = _engine()
|
||||
dispatch = AsyncMock()
|
||||
monkeypatch.setattr(engine, "_dispatch_command", dispatch)
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._handle_message(
|
||||
{"chat": {"id": 777}, "text": "/status"}, CREDS, client
|
||||
)
|
||||
|
||||
dispatch.assert_awaited_once_with("status", "", client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_with_mismatched_sender_answers_not_authorized() -> None:
|
||||
engine = _engine()
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._handle_callback(
|
||||
{
|
||||
"id": "cq1",
|
||||
"data": "apv:xpost:a1b2c3d4",
|
||||
"from": {"id": 999},
|
||||
"message": {"chat": {"id": 777}, "message_id": 5},
|
||||
},
|
||||
CREDS,
|
||||
client,
|
||||
)
|
||||
|
||||
client.answer_callback_query.assert_awaited_once_with("cq1", "Not authorized")
|
||||
client.send_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_with_matching_sender_proceeds(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
client = AsyncMock()
|
||||
dispatch = AsyncMock(return_value=(True, "ok"))
|
||||
monkeypatch.setattr(engine, "_dispatch_approve", dispatch)
|
||||
|
||||
await engine._handle_callback(
|
||||
{
|
||||
"id": "cq1",
|
||||
"data": "apv:xpost:a1b2c3d4",
|
||||
"from": {"id": 777},
|
||||
"message": {"chat": {"id": 777}, "message_id": 5},
|
||||
},
|
||||
CREDS,
|
||||
client,
|
||||
)
|
||||
|
||||
dispatch.assert_awaited_once()
|
||||
client.answer_callback_query.assert_any_await("cq1", "Working...")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# expired force-reply prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_reply_prompt_sends_notice(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A popped-but-expired pending prompt must tell the CEO instead of
|
||||
silently doing nothing (the CEO otherwise has no idea why their reply had
|
||||
no effect)."""
|
||||
engine = _engine()
|
||||
client = AsyncMock()
|
||||
dispatch = AsyncMock()
|
||||
monkeypatch.setattr(engine, "_dispatch_command", dispatch)
|
||||
ti._PENDING_REPLIES[("777", 42)] = ti._PendingAction(
|
||||
kind="xpost",
|
||||
id8="a1b2c3d4",
|
||||
extra="",
|
||||
action="reject",
|
||||
origin_message_id=10,
|
||||
expires_at=time.monotonic() - 1, # already expired
|
||||
)
|
||||
|
||||
await engine._handle_message(
|
||||
{
|
||||
"chat": {"id": 777},
|
||||
"text": "some reason",
|
||||
"reply_to_message": {"message_id": 42},
|
||||
},
|
||||
CREDS,
|
||||
client,
|
||||
)
|
||||
|
||||
client.send_message.assert_awaited_once_with(
|
||||
"That prompt expired — tap the button again."
|
||||
)
|
||||
dispatch.assert_not_called()
|
||||
assert ("777", 42) not in ti._PENDING_REPLIES
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# offset advancement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cycle_noop_when_flags_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(ti.settings, "telegram_enabled", False)
|
||||
monkeypatch.setattr(ti.settings, "telegram_inbound_enabled", True)
|
||||
engine = _engine()
|
||||
creds_svc = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
ti, "get_telegram_credentials_service", lambda _session: creds_svc
|
||||
)
|
||||
|
||||
await engine.run_cycle()
|
||||
|
||||
creds_svc.get_decrypted.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cycle_noop_without_credentials(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(ti.settings, "telegram_enabled", True)
|
||||
monkeypatch.setattr(ti.settings, "telegram_inbound_enabled", True)
|
||||
engine = _engine()
|
||||
creds_svc = AsyncMock()
|
||||
creds_svc.get_decrypted = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(
|
||||
ti, "get_telegram_credentials_service", lambda _session: creds_svc
|
||||
)
|
||||
|
||||
await engine.run_cycle()
|
||||
|
||||
creds_svc.get_decrypted.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cycle_advances_offset_past_highest_update_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(ti.settings, "telegram_enabled", True)
|
||||
monkeypatch.setattr(ti.settings, "telegram_inbound_enabled", True)
|
||||
engine = _engine()
|
||||
|
||||
creds_svc = AsyncMock()
|
||||
creds_svc.get_decrypted = AsyncMock(return_value=CREDS)
|
||||
monkeypatch.setattr(
|
||||
ti, "get_telegram_credentials_service", lambda _session: creds_svc
|
||||
)
|
||||
|
||||
client = AsyncMock()
|
||||
client.configured = True
|
||||
# Two updates the engine ignores (no message/callback_query key) — only
|
||||
# the offset bookkeeping is under test here.
|
||||
client.get_updates = AsyncMock(
|
||||
return_value=[{"update_id": 100}, {"update_id": 105}]
|
||||
)
|
||||
monkeypatch.setattr(engine, "_client", AsyncMock(return_value=client))
|
||||
|
||||
settings_svc = AsyncMock()
|
||||
settings_svc.get_int = AsyncMock(return_value=0)
|
||||
monkeypatch.setattr(ti, "get_settings_service", lambda _session: settings_svc)
|
||||
|
||||
await engine.run_cycle()
|
||||
|
||||
client.get_updates.assert_awaited_once_with(offset=None, timeout=25, limit=50)
|
||||
settings_svc.set.assert_awaited_once_with("telegram_last_update_id", "106")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cycle_requests_stored_offset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(ti.settings, "telegram_enabled", True)
|
||||
monkeypatch.setattr(ti.settings, "telegram_inbound_enabled", True)
|
||||
engine = _engine()
|
||||
|
||||
creds_svc = AsyncMock()
|
||||
creds_svc.get_decrypted = AsyncMock(return_value=CREDS)
|
||||
monkeypatch.setattr(
|
||||
ti, "get_telegram_credentials_service", lambda _session: creds_svc
|
||||
)
|
||||
|
||||
client = AsyncMock()
|
||||
client.configured = True
|
||||
client.get_updates = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr(engine, "_client", AsyncMock(return_value=client))
|
||||
|
||||
settings_svc = AsyncMock()
|
||||
settings_svc.get_int = AsyncMock(return_value=42)
|
||||
monkeypatch.setattr(ti, "get_settings_service", lambda _session: settings_svc)
|
||||
|
||||
await engine.run_cycle()
|
||||
|
||||
client.get_updates.assert_awaited_once_with(offset=42, timeout=25, limit=50)
|
||||
# No updates seen -> offset must not regress/rewrite.
|
||||
settings_svc.set.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_task — exact id-prefix match, ambiguity handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_task_exact_prefix_match(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
engine = _engine()
|
||||
task = _fake_task("a1b2c3d4")
|
||||
task_svc = AsyncMock()
|
||||
task_svc.search_tasks = AsyncMock(return_value=[task])
|
||||
monkeypatch.setattr(ti, "get_task_service", lambda _session: task_svc)
|
||||
|
||||
resolved = await engine._resolve_task("a1b2c3d4")
|
||||
|
||||
# Widened from 10 -> 50: a real id-prefix hit can otherwise be pushed out
|
||||
# of a small window by title/description ILIKE hits on newer rows.
|
||||
task_svc.search_tasks.assert_awaited_once_with("a1b2c3d4", limit=50)
|
||||
assert resolved is task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_task_ambiguous_returns_none(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
# Both genuinely start with the same prefix -> ambiguous.
|
||||
t1 = _fake_task("a1b2c3d4")
|
||||
t2 = SimpleNamespace(id=UUID(hex="a1b2c3d4" + "0" * 24), title="dup")
|
||||
task_svc = AsyncMock()
|
||||
task_svc.search_tasks = AsyncMock(return_value=[t1, t2])
|
||||
monkeypatch.setattr(ti, "get_task_service", lambda _session: task_svc)
|
||||
|
||||
assert await engine._resolve_task("a1b2c3d4") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_task_filters_out_title_only_matches(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""search_tasks also OR-matches title/description substrings; a hit whose
|
||||
id does NOT start with the query must be filtered back out."""
|
||||
engine = _engine()
|
||||
real = _fake_task("a1b2c3d4")
|
||||
title_hit = _fake_task("ffffffff", title="mentions a1b2c3d4 in the title")
|
||||
task_svc = AsyncMock()
|
||||
task_svc.search_tasks = AsyncMock(return_value=[real, title_hit])
|
||||
monkeypatch.setattr(ti, "get_task_service", lambda _session: task_svc)
|
||||
|
||||
assert await engine._resolve_task("a1b2c3d4") is real
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dispatch: one per approve-kind
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stub_resolve(
|
||||
monkeypatch: pytest.MonkeyPatch, engine: ti.TelegramInboundEngine, task: Any
|
||||
) -> None:
|
||||
monkeypatch.setattr(engine, "_resolve_task", AsyncMock(return_value=task))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_approve_task_calls_ceo_approve_with_notes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
_stub_resolve(monkeypatch, engine, task)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.ceo_approve = AsyncMock(return_value=task)
|
||||
monkeypatch.setattr(ti, "get_task_service", lambda _session: task_svc)
|
||||
|
||||
notes = "Looks solid, shipping it now."
|
||||
ok, _text = await engine._dispatch_approve("task", "a1b2c3d4", "", notes=notes)
|
||||
|
||||
task_svc.ceo_approve.assert_awaited_once_with(task.id, notes)
|
||||
assert ok is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_approve_task_refuses_short_notes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
_stub_resolve(monkeypatch, engine, task)
|
||||
task_svc = AsyncMock()
|
||||
monkeypatch.setattr(ti, "get_task_service", lambda _session: task_svc)
|
||||
|
||||
ok, text = await engine._dispatch_approve("task", "a1b2c3d4", "", notes="too short")
|
||||
|
||||
task_svc.ceo_approve.assert_not_called()
|
||||
assert ok is False
|
||||
assert "20" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_reject_task_calls_ceo_reject_with_reason(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
_stub_resolve(monkeypatch, engine, task)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.ceo_reject = AsyncMock(return_value=task)
|
||||
monkeypatch.setattr(ti, "get_task_service", lambda _session: task_svc)
|
||||
|
||||
ok, _text = await engine._dispatch_reject("task", "a1b2c3d4", "", "not good enough")
|
||||
|
||||
task_svc.ceo_reject.assert_awaited_once_with(task.id, "not good enough")
|
||||
assert ok is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_approve_release_dispatches_background(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
_stub_resolve(monkeypatch, engine, task)
|
||||
dispatch_mock = MagicMock()
|
||||
monkeypatch.setattr(ti, "dispatch_approve", dispatch_mock)
|
||||
factory = object()
|
||||
monkeypatch.setattr(ti, "get_session_factory", lambda: factory)
|
||||
|
||||
ok, text = await engine._dispatch_approve("release", "a1b2c3d4", "", notes=None)
|
||||
|
||||
dispatch_mock.assert_called_once_with(task.id, factory)
|
||||
assert ok is True
|
||||
assert "background" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_approve_release_refuses_when_cancelled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""dispatch_approve fires the release execute in the background with no
|
||||
result to inspect, so a stale Approve on an already-rejected (CANCELLED)
|
||||
proposal must be caught HERE, before dispatch — else the CEO sees a false
|
||||
"dispatched" success while the service-level guard silently no-ops."""
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
task.status = TaskStatus.CANCELLED
|
||||
_stub_resolve(monkeypatch, engine, task)
|
||||
dispatch_mock = MagicMock()
|
||||
monkeypatch.setattr(ti, "dispatch_approve", dispatch_mock)
|
||||
|
||||
ok, text = await engine._dispatch_approve("release", "a1b2c3d4", "", notes=None)
|
||||
|
||||
dispatch_mock.assert_not_called()
|
||||
assert ok is False
|
||||
assert "already rejected" in text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_reject_release_surfaces_already_completed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The reject-after-approve mirror guard: the service raises when the
|
||||
proposal already published; the handler must map that to (False, ...)
|
||||
instead of an uncaught exception (which `run_cycle`'s broad except would
|
||||
swallow, leaving the CEO with no response at all)."""
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
_stub_resolve(monkeypatch, engine, task)
|
||||
release_svc = AsyncMock()
|
||||
release_svc.reject = AsyncMock(
|
||||
side_effect=ti._ReleaseDone(
|
||||
"release proposal already published (COMPLETED); cannot be rejected"
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ti, "get_release_proposal_service", lambda _session: release_svc
|
||||
)
|
||||
|
||||
ok, text = await engine._dispatch_reject(
|
||||
"release", "a1b2c3d4", "", "needs another migration check"
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert "already published" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_reject_release_calls_service_with_reason(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
_stub_resolve(monkeypatch, engine, task)
|
||||
release_svc = AsyncMock()
|
||||
release_svc.reject = AsyncMock(return_value=task)
|
||||
monkeypatch.setattr(
|
||||
ti, "get_release_proposal_service", lambda _session: release_svc
|
||||
)
|
||||
|
||||
ok, _text = await engine._dispatch_reject(
|
||||
"release", "a1b2c3d4", "", "needs another migration check"
|
||||
)
|
||||
|
||||
release_svc.reject.assert_awaited_once_with(
|
||||
task.id, "needs another migration check"
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_reject_release_enforces_ten_char_floor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
_stub_resolve(monkeypatch, engine, task)
|
||||
release_svc = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
ti, "get_release_proposal_service", lambda _session: release_svc
|
||||
)
|
||||
|
||||
ok, text = await engine._dispatch_reject("release", "a1b2c3d4", "", "short")
|
||||
|
||||
release_svc.reject.assert_not_called()
|
||||
assert ok is False
|
||||
assert "not recorded" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_approve_xpost_calls_service(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
_stub_resolve(monkeypatch, engine, task)
|
||||
x_svc = AsyncMock()
|
||||
x_svc.approve = AsyncMock(
|
||||
return_value=XPostExecuteResult(status="posted", tweet_id="1", detail="ok")
|
||||
)
|
||||
monkeypatch.setattr(ti, "get_x_post_service", lambda _session: x_svc)
|
||||
|
||||
ok, _text = await engine._dispatch_approve("xpost", "a1b2c3d4", "", notes=None)
|
||||
|
||||
x_svc.approve.assert_awaited_once_with(task.id)
|
||||
assert ok is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_reject_xpost_calls_service_with_reason(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
_stub_resolve(monkeypatch, engine, task)
|
||||
x_svc = AsyncMock()
|
||||
x_svc.reject = AsyncMock(return_value=task)
|
||||
monkeypatch.setattr(ti, "get_x_post_service", lambda _session: x_svc)
|
||||
|
||||
ok, _text = await engine._dispatch_reject("xpost", "a1b2c3d4", "", "off-brand tone")
|
||||
|
||||
x_svc.reject.assert_awaited_once_with(task.id, "off-brand tone")
|
||||
assert ok is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_approve_video_calls_real_video_service(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
_stub_resolve(monkeypatch, engine, task)
|
||||
video_svc = AsyncMock()
|
||||
video_svc.approve = AsyncMock(
|
||||
return_value=VideoPostExecuteResult(
|
||||
status="posted", posted={"x": "1"}, detail="ok"
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
engine, "_real_video_post_service", AsyncMock(return_value=video_svc)
|
||||
)
|
||||
|
||||
ok, _text = await engine._dispatch_approve("video", "a1b2c3d4", "", notes=None)
|
||||
|
||||
video_svc.approve.assert_awaited_once_with(task.id)
|
||||
assert ok is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_reject_video_calls_service_with_reason(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
_stub_resolve(monkeypatch, engine, task)
|
||||
video_svc = AsyncMock()
|
||||
video_svc.reject = AsyncMock(return_value=task)
|
||||
monkeypatch.setattr(ti, "get_video_post_service", lambda _session: video_svc)
|
||||
|
||||
ok, _text = await engine._dispatch_reject("video", "a1b2c3d4", "", "wrong caption")
|
||||
|
||||
video_svc.reject.assert_awaited_once_with(task.id, "wrong caption")
|
||||
assert ok is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_approve_roadmap_calls_service_with_ceo_identity(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
_stub_resolve(monkeypatch, engine, task)
|
||||
roadmap_svc = AsyncMock()
|
||||
roadmap_svc.approve_item = AsyncMock(
|
||||
return_value=RoadmapItemResult(
|
||||
status="approved", item_id="item-2", materialized_task_id="x", detail="ok"
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(ti, "get_roadmap_service", lambda _session: roadmap_svc)
|
||||
|
||||
ok, _text = await engine._dispatch_approve(
|
||||
"roadmap", "a1b2c3d4", "item-2", notes=None
|
||||
)
|
||||
|
||||
roadmap_svc.approve_item.assert_awaited_once_with(
|
||||
task.id, "item-2", created_by=CEO_UUID
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_reject_roadmap_calls_service_with_reason(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
_stub_resolve(monkeypatch, engine, task)
|
||||
roadmap_svc = AsyncMock()
|
||||
roadmap_svc.reject_item = AsyncMock(
|
||||
return_value=RoadmapItemResult(
|
||||
status="rejected", item_id="item-2", materialized_task_id=None, detail="ok"
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(ti, "get_roadmap_service", lambda _session: roadmap_svc)
|
||||
|
||||
ok, _text = await engine._dispatch_reject(
|
||||
"roadmap", "a1b2c3d4", "item-2", "not aligned with strategy"
|
||||
)
|
||||
|
||||
roadmap_svc.reject_item.assert_awaited_once_with(
|
||||
task.id, "item-2", "not aligned with strategy"
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_approve_unresolved_task_short_circuits(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
_stub_resolve(monkeypatch, engine, None)
|
||||
|
||||
ok, text = await engine._dispatch_approve("xpost", "ffffffff", "", notes=None)
|
||||
|
||||
assert ok is False
|
||||
assert "No such" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# audit marker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_audit_adds_via_telegram_row() -> None:
|
||||
engine = _engine()
|
||||
task_id = uuid4()
|
||||
|
||||
engine._mark_audit("xpost", task_id, "approve", item_id="")
|
||||
|
||||
added = cast("MagicMock", engine.session.add).call_args.args[0]
|
||||
assert added.event_type == "telegram.xpost.approve"
|
||||
assert added.agent_id == CEO_UUID
|
||||
assert added.target_id == task_id
|
||||
assert added.details["via"] == "telegram"
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Pure-function coverage for telegram_inbound: command parsing, callback
|
||||
build/parse round-trip, and chat-id authorization. No I/O, no DB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings as cfg
|
||||
from roboco.services.telegram_inbound import (
|
||||
ParsedCallback,
|
||||
_authorized_chat,
|
||||
build_action_keyboard,
|
||||
build_callback,
|
||||
parse_callback,
|
||||
parse_command,
|
||||
)
|
||||
|
||||
_CALLBACK_DATA_MAX_BYTES = 64 # mirrors Telegram's own callback_data cap
|
||||
_KEYBOARD_ROW_LEN_NO_OPEN_BUTTON = 2 # Approve + Reject, no panel_base_url
|
||||
|
||||
|
||||
class TestParseCommand:
|
||||
def test_plain_command(self) -> None:
|
||||
assert parse_command("/status") == ("status", "")
|
||||
|
||||
def test_command_with_args(self) -> None:
|
||||
assert parse_command("/task abc12345 extra words") == (
|
||||
"task",
|
||||
"abc12345 extra words",
|
||||
)
|
||||
|
||||
def test_strips_botname_suffix(self) -> None:
|
||||
assert parse_command("/status@my_roboco_bot") == ("status", "")
|
||||
|
||||
def test_non_command_text_is_empty(self) -> None:
|
||||
assert parse_command("hello there") == ("", "")
|
||||
|
||||
def test_empty_text_is_empty(self) -> None:
|
||||
assert parse_command("") == ("", "")
|
||||
|
||||
def test_case_insensitive(self) -> None:
|
||||
assert parse_command("/STATUS") == ("status", "")
|
||||
|
||||
|
||||
class TestCallbackRoundTrip:
|
||||
@pytest.mark.parametrize(
|
||||
"action,kind,id8,extra",
|
||||
[
|
||||
("apv", "task", "a1b2c3d4", ""),
|
||||
("rej", "release", "deadbeef", ""),
|
||||
("apv", "xpost", "12345678", ""),
|
||||
("rej", "video", "87654321", ""),
|
||||
("apv", "roadmap", "abcdef12", "item-3"),
|
||||
],
|
||||
)
|
||||
def test_build_then_parse_round_trips(
|
||||
self, action: str, kind: str, id8: str, extra: str
|
||||
) -> None:
|
||||
data = build_callback(action, kind, id8, extra)
|
||||
parsed = parse_callback(data)
|
||||
assert parsed == ParsedCallback(action=action, kind=kind, id8=id8, extra=extra)
|
||||
|
||||
def test_callback_data_stays_under_64_bytes(self) -> None:
|
||||
data = build_callback("apv", "roadmap", "a1b2c3d4", "item-99")
|
||||
assert len(data.encode()) <= _CALLBACK_DATA_MAX_BYTES
|
||||
|
||||
def test_oversized_callback_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="64 bytes"):
|
||||
build_callback("apv", "roadmap", "a1b2c3d4", "x" * 60)
|
||||
|
||||
def test_parse_rejects_unknown_action(self) -> None:
|
||||
assert parse_callback("nope:task:a1b2c3d4") is None
|
||||
|
||||
def test_parse_rejects_unknown_kind(self) -> None:
|
||||
assert parse_callback("apv:bogus:a1b2c3d4") is None
|
||||
|
||||
def test_parse_rejects_malformed_shape(self) -> None:
|
||||
assert parse_callback("apv:task") is None
|
||||
assert parse_callback("apv:task:a1:b2:c3") is None
|
||||
|
||||
def test_parse_rejects_empty_string(self) -> None:
|
||||
assert parse_callback("") is None
|
||||
|
||||
def test_parse_rejects_empty_id8(self) -> None:
|
||||
assert parse_callback("apv:task:") is None
|
||||
|
||||
|
||||
class TestActionKeyboard:
|
||||
def test_builds_approve_reject_row(self) -> None:
|
||||
kb = build_action_keyboard("task", "a1b2c3d4")
|
||||
row = kb["inline_keyboard"][0]
|
||||
assert row[0] == {
|
||||
"text": "Approve",
|
||||
"callback_data": "apv:task:a1b2c3d4",
|
||||
}
|
||||
assert row[1] == {
|
||||
"text": "Reject",
|
||||
"callback_data": "rej:task:a1b2c3d4",
|
||||
}
|
||||
|
||||
def test_omits_open_button_without_panel_base_url(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(cfg, "panel_base_url", "")
|
||||
kb = build_action_keyboard("task", "a1b2c3d4")
|
||||
assert len(kb["inline_keyboard"][0]) == _KEYBOARD_ROW_LEN_NO_OPEN_BUTTON
|
||||
|
||||
def test_includes_open_button_with_panel_base_url(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(cfg, "panel_base_url", "https://panel.example.com")
|
||||
kb = build_action_keyboard("task", "a1b2c3d4")
|
||||
row = kb["inline_keyboard"][0]
|
||||
assert row[2] == {
|
||||
"text": "Open",
|
||||
"url": "https://panel.example.com/tasks/a1b2c3d4",
|
||||
}
|
||||
|
||||
def test_roadmap_deep_link_carries_no_item_id_it_points_at_the_cycle(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(cfg, "panel_base_url", "https://panel.example.com")
|
||||
kb = build_action_keyboard("roadmap", "a1b2c3d4", "item-2")
|
||||
row = kb["inline_keyboard"][0]
|
||||
assert row[2]["url"] == "https://panel.example.com/overview"
|
||||
assert row[0]["callback_data"] == "apv:roadmap:a1b2c3d4:item-2"
|
||||
|
||||
|
||||
class TestAuthorizedChat:
|
||||
def test_matching_chat_id_authorized(self) -> None:
|
||||
assert _authorized_chat("12345", "12345") is True
|
||||
|
||||
def test_mismatched_chat_id_rejected(self) -> None:
|
||||
assert _authorized_chat("99999", "12345") is False
|
||||
|
||||
def test_empty_chat_id_rejected(self) -> None:
|
||||
assert _authorized_chat("", "12345") is False
|
||||
@@ -349,6 +349,69 @@ async def test_approve_is_idempotent_second_call_is_noop(
|
||||
assert len(tiktok_poster.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_refuses_already_rejected_draft(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""The chokepoint guard: approving a CANCELLED (already-rejected) draft
|
||||
refuses and never calls either poster — the reproduced bug (a stale
|
||||
Approve after reject re-posting)."""
|
||||
task = await _seed_video_post(db_session)
|
||||
x_poster = _StubXPoster()
|
||||
tiktok_poster = _StubTikTokPoster()
|
||||
fake_engine = MagicMock()
|
||||
fake_engine.reauthor_from_rejection = AsyncMock(return_value=None)
|
||||
with (
|
||||
_LOCKED[0],
|
||||
_LOCKED[1],
|
||||
patch(
|
||||
"roboco.services.video_engine.get_video_engine",
|
||||
return_value=fake_engine,
|
||||
),
|
||||
):
|
||||
await _svc(db_session, x_poster=x_poster, tiktok_poster=tiktok_poster).reject(
|
||||
_id(task), "not on-brand"
|
||||
)
|
||||
result = await _svc(
|
||||
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
|
||||
).approve(_id(task))
|
||||
assert result is not None
|
||||
assert result.status == "already_rejected"
|
||||
assert x_poster.calls == []
|
||||
assert tiktok_poster.calls == []
|
||||
await db_session.refresh(task)
|
||||
assert task.status == TS.CANCELLED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_rechecks_cancelled_under_lock_and_never_posts(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""TOCTOU parity with the COMPLETED re-check: a concurrent reject cancels
|
||||
the draft after our pre-lock read; the in-lock re-read must see CANCELLED
|
||||
and short-circuit — never posting a rejected draft."""
|
||||
task = await _seed_video_post(db_session)
|
||||
x_poster = _StubXPoster()
|
||||
tiktok_poster = _StubTikTokPoster()
|
||||
|
||||
async def _win_the_race(_self: HeartbeatMutex) -> str:
|
||||
task.status = TS.CANCELLED
|
||||
await db_session.flush()
|
||||
return "tok"
|
||||
|
||||
with (
|
||||
patch.object(HeartbeatMutex, "acquire", _win_the_race),
|
||||
patch.object(HeartbeatMutex, "release", AsyncMock(return_value=None)),
|
||||
):
|
||||
result = await _svc(
|
||||
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
|
||||
).approve(_id(task))
|
||||
assert result is not None
|
||||
assert result.status == "already_rejected"
|
||||
assert x_poster.calls == []
|
||||
assert tiktok_poster.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_concurrent_lock_held_returns_in_progress(
|
||||
db_session: AsyncSession,
|
||||
|
||||
@@ -318,6 +318,55 @@ async def test_approve_rechecks_completed_under_lock_and_never_reposts(
|
||||
assert client.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_refuses_already_rejected_draft(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""The chokepoint guard: approving a CANCELLED (already-rejected) draft
|
||||
refuses and never calls the X client — the reproduced bug (a stale
|
||||
Approve after reject re-posting)."""
|
||||
task = await _seed_draft(db_session)
|
||||
await _svc(db_session).reject(_id(task), "not on-brand")
|
||||
client = _StubClient()
|
||||
with (
|
||||
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
||||
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
||||
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
||||
):
|
||||
result = await _svc(db_session).approve(_id(task))
|
||||
assert result is not None
|
||||
assert result.status == "already_rejected"
|
||||
assert client.calls == []
|
||||
await db_session.refresh(task)
|
||||
assert task.status == TS.CANCELLED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_rechecks_cancelled_under_lock_and_never_posts(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""TOCTOU parity with the COMPLETED re-check: a concurrent reject cancels
|
||||
the draft after our pre-lock read; the in-lock re-read must see CANCELLED
|
||||
and short-circuit — never posting a rejected draft."""
|
||||
task = await _seed_draft(db_session)
|
||||
client = _StubClient()
|
||||
|
||||
async def _win_the_race(_self: XPostService, _key: str) -> str:
|
||||
task.status = TS.CANCELLED
|
||||
await db_session.flush()
|
||||
return "tok"
|
||||
|
||||
with (
|
||||
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
||||
patch.object(XPostService, "_acquire_lock", _win_the_race),
|
||||
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
||||
):
|
||||
result = await _svc(db_session).approve(_id(task))
|
||||
assert result is not None
|
||||
assert result.status == "already_rejected"
|
||||
assert client.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_concurrent_lock_held_returns_in_progress(
|
||||
db_session: AsyncSession,
|
||||
|
||||
Reference in New Issue
Block a user