mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* feat(tg): P0 — dev mock bridge + Telegram-native foundations
Mini App V4 phase 0. The (tg) shell gains the groundwork every later
phase builds on:
- Dev mock bridge: outside Telegram, a development build falls back to a
no-op WebApp object and skips the webapp-auth POST (the regular panel
session cookie authorizes API calls), so the cockpit is workable in a
plain browser. Production keeps the "Open from Telegram" wall.
- Telegram theme adoption: themeParams map onto the shadcn CSS variables
scoped to #tg-shell (desktop dashboard untouched), colorScheme drives
the dark class, themeChanged re-applies live. Non-hex values are
dropped at the trust boundary.
- Viewport/swipe correctness: shell height rides Telegram's own
--tg-viewport-stable-height (100dvh fallback), vertical swipe-to-close
disabled so list scrolling can't dismiss the app.
- Native chrome bindings: TgWebAppProvider context plus useMainButton /
useBackButton declarative hooks and a null-safe haptics helper —
consumers never touch window.Telegram directly.
* feat(tg): P1 — Today home tab + one-round-trip /telegram/today brief
Mini App V4 phase 1: the cockpit now opens on a "Today" brief answering
"does anything need me?" in one glance.
Backend: GET /api/telegram/today (CEO-gated, rate-limited) returns the
whole brief in one round trip via the new TgCockpitService — needs-you
items (awaiting-CEO + blocked tasks capped for a phone screen, held-draft
counts across release/X/video/roadmap queues), a fleet snapshot with
per-agent current-task titles, today's spend from the day rollup
(degrading to zeros on a usage hiccup, mirroring the CEO overview), and
ship state. Deliberately DB-only: no live GitHub calls, no readiness
snapshot (that path clones), no orchestrator singleton — the CI
red/green proxy is the set of open ci_watch fix tasks.
Panel: TgTodayTab is the new default tab (Gauge icon) — needs-you rows
and draft chips deep-link into the tab that acts on them (with a haptic
tap), fleet/spend/ship render as dense cards, 45s refetch until the P3
WebSocket wiring lands.
* fix(tg): dev mock engages when the CDN bridge loads outside Telegram
Live browser smoke caught it: a bare tab still loads telegram-web-app.js,
so window.Telegram.WebApp EXISTS outside Telegram — just with empty
initData. The dev fallback keyed on a null bridge only, so a dev browser
went down the real-auth path and posted empty initData instead of
mounting the mock. The fallback now treats bridge-with-no-initData the
same as no bridge (a real Telegram launch always carries initData);
production behavior is unchanged.
* feat(tg): P2 — native approvals card stack
Mini App V4 phase 2: the Approvals tab stops stacking the four desktop
queue cards and becomes a phone-native flow — one normalized list across
release proposal / X drafts / video drafts / proposed roadmap items, and
a full-context detail per item:
- Release: version/bump/gate badges, changelog draft, gaps, migration
notes, in-flight + failed-execute banners; approve runs the fail-closed
executor, reject requires a substantive change request (10 chars).
- X: editable body with the live 280 counter, replied-to mention quoted;
approve sends the edited body only when actually edited.
- Video: cut-toggled player (blob-fetched through the authed client — a
bare <video src> would 401), per-platform caption edits with 280/2200
counters; approve sends only checked-in edits.
- Roadmap: the PO's full pitch (description, rationale, ACs); approve
materializes into the backlog per item.
The detail's primary action rides Telegram's native MainButton and back
navigation rides the BackButton, with visible fallbacks outside Telegram
(dev mock, old clients). Haptics fire on outcomes. An acted-on item
vanishes from the refetched queue, popping back to the list by
construction. A failed queue source is surfaced ("list may be
incomplete" / "couldn't load") instead of masquerading as an empty
queue — caught live in the browser smoke.
* feat(tg): dev demo mode — /tg?demo=1 renders canned cockpit data
Development-only: with the flag param present, the Today brief and the
four approval queues resolve typed fixtures (dynamically imported, so
production bundles never carry them) instead of hitting the backend —
the cockpit is fully browsable with zero stack running. Mutations still
go to the real API and fail loudly; it's a showroom, not a simulator.
* feat(tg): P3 — cockpit rides /ws/system live
Chat adopts the desktop A2A invalidate-on-frame idiom over the shared
ref-counted /ws/system socket: every a2a.message frame refreshes the
conversation list and the affected thread, missed-frame gaps are healed
by a reconnect refetch, and the 10s thread poll turns off entirely while
the socket is up (it remains the fallback). The Today brief refreshes on
each USAGE_SNAPSHOT push so the spend line tracks the sweeper live, with
the 45s poll as the socket-down fallback. No new sockets, no backend
changes — the WS gate already accepts the cloud-auth session cookie.
* feat(tg): P4 — deterministic bot command tier + self-syncing menu
Mini App V4 phase 4 (deterministic half): three new bot commands beside
/status /queue /task —
- /agents: who's mid-task right now, from the same TgCockpitService
fleet snapshot the Today brief renders (now public `fleet()`).
- /usage: today's spend from the day rollup.
- /blocked: awaiting-you + blocked tasks, deep-linked into the panel,
capped per section, titles HTML-escaped.
BOT_COMMANDS is the single registry driving /help AND a once-per-process
Bot API setMyCommands sync on the first poll cycle (new client method,
best-effort), so the Telegram command menu can never drift from what the
code implements. The interactive tier (/secretary, /newtask riding a
live Intake interview in-thread) is specced but not in this commit.
* feat(tg): direction-C styling pass — Telegram palette, RoboCo voice
The cockpit stops wearing default-shadcn and gets its own visual
language on top of the P0 themeParams bridge (colors stay CSS-variable
driven, so inside Telegram everything still adopts the user's theme):
- Shared primitives (components/tg/ui.tsx): TgSection grouped cards with
tracked micro-label headers, TgRow list rows (44px targets, press
feedback, 1/2-line clamp), TgRowIcon glyph tiles, TgStat tabular-nums
figures. Every tab composes the same three, so density and rhythm are
identical across the surface.
- Shell renders a centered 430px column (sm:border-x) — the phone UI no
longer stretches across a desktop dev browser.
- Tab bar: tighter type, active stroke-weight shift, backdrop blur.
- Today: needs-you count badge, divided task rows with inline blocked
marker, fleet as mono-named rows, spend/ship as stat tiles.
- Approvals rows as icon-tile cards; detail header gains the kind glyph.
- Inbox/Chat rows aligned to the same card language.
* feat(tg): P5 — /secretary and /newtask live-chat bridges
The bot's interactive tier: both commands bridge the CEO's Telegram chat
into the same in-process runtimes the panel drives — the persistent
Secretary container and the scoped Intake interview.
There is no synchronous send→reply seam (replies land on the session's
single-consumer relay queue), so each bridged session runs one long-lived
consumer task (roboco/services/telegram_bridge.py) that drains
PrompterLiveRegistry.stream and pushes one Telegram message per completed
turn. While a session is live, plain chat text IS the conversation;
/end closes it.
/newtask resolves the intake scope (single project auto-picked, multiple
offered as a tap-to-pick keyboard holding the initial text), and the
interview happens in-thread. A draft proposal renders as a card with
Send-to-Board / Discard buttons: confirm routes through the normal
board-review path (PrompterService.confirm_live_draft, route=board) and
PARKS the session — board feedback later streams straight back into the
same thread, closing the redraft loop from the phone. MegaTask batches
still confirm in the panel only.
The consumer's open stream arms the registry's 60s keepalive, so the
bridge runs its own idle TTL (same setting, parked sessions exempt).
State is per-process in-memory by design (the _PENDING_REPLIES posture);
intake/secretary containers are process-wide singletons, so a bridged
session preempts a live panel session of the same kind by construction.
* feat(tg): cockpit skin — RoboCo dark deck with a constant amber accent
The cockpit no longer inherits the dashboard's white default outside
Telegram: #tg-shell carries its own standing skin (deep slate surfaces,
amber primary) so the Mini App looks like RoboCo everywhere. Inside
Telegram the themeParams bridge now overrides SURFACE tokens only —
background/card/text/hint/border repaint to the user's Telegram theme
while --primary/--ring stay RoboCo amber: Telegram's surfaces, RoboCo's
voice. Demo fixtures also rewritten to neutral content (they previously
depicted unbuilt forge work and already-shipped roadmap items as live).
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
953 lines
30 KiB
Python
953 lines
30 KiB
Python
"""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 TYPE_CHECKING, 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
|
|
|
|
if TYPE_CHECKING:
|
|
from roboco.db.tables import TaskTable
|
|
|
|
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, chat_id="777", creds=CREDS)
|
|
|
|
|
|
@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, chat_id="777", creds=CREDS)
|
|
|
|
|
|
@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, chat_id="777", creds=CREDS)
|
|
|
|
|
|
@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.", parse_mode="HTML"
|
|
)
|
|
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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /queue rendering — pluralization + HTML formatting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_queue_empty_says_nothing_awaiting(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine = _engine()
|
|
monkeypatch.setattr(engine, "_collect_queue_items", AsyncMock(return_value=[]))
|
|
client = AsyncMock()
|
|
|
|
await engine._send_queue(client)
|
|
|
|
client.send_message.assert_awaited_once_with(
|
|
"✅ Nothing awaiting your approval.", parse_mode="HTML"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_queue_singular_item_pluralization(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine = _engine()
|
|
monkeypatch.setattr(
|
|
engine,
|
|
"_collect_queue_items",
|
|
AsyncMock(return_value=[("task", "a1b2c3d4", "", "Ship it")]),
|
|
)
|
|
client = AsyncMock()
|
|
|
|
await engine._send_queue(client)
|
|
|
|
header = client.send_message.await_args_list[0].args[0]
|
|
assert header == "<b>🔔 Awaiting your approval</b> — 1 item"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_queue_plural_items_pluralization(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine = _engine()
|
|
monkeypatch.setattr(
|
|
engine,
|
|
"_collect_queue_items",
|
|
AsyncMock(
|
|
return_value=[
|
|
("task", "a1b2c3d4", "", "Ship it"),
|
|
("release", "deadbeef", "", "v1.0.0 ready"),
|
|
]
|
|
),
|
|
)
|
|
client = AsyncMock()
|
|
|
|
await engine._send_queue(client)
|
|
|
|
header = client.send_message.await_args_list[0].args[0]
|
|
assert header == "<b>🔔 Awaiting your approval</b> — 2 items"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_queue_item_line_escapes_title_and_carries_keyboard(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Injection regression: a malicious task title must arrive HTML-escaped
|
|
— never as live markup — in the /queue item line's sent payload."""
|
|
engine = _engine()
|
|
monkeypatch.setattr(
|
|
engine,
|
|
"_collect_queue_items",
|
|
AsyncMock(return_value=[("task", "a1b2c3d4", "", "<b>bold&joke</b>")]),
|
|
)
|
|
client = AsyncMock()
|
|
|
|
await engine._send_queue(client)
|
|
|
|
item_call = client.send_message.await_args_list[1]
|
|
text = item_call.args[0]
|
|
assert "<b>bold&joke</b>" in text
|
|
assert "<b>bold&joke</b>" not in text
|
|
assert text.startswith("📋 <b>Task</b> — ")
|
|
assert item_call.kwargs["parse_mode"] == "HTML"
|
|
assert "reply_markup" in item_call.kwargs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /task — link preview disabled, title/status/team escaping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_command_task_disables_link_preview(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine = _engine()
|
|
monkeypatch.setattr(engine, "_render_task", AsyncMock(return_value="detail"))
|
|
client = AsyncMock()
|
|
|
|
await engine._dispatch_command("task", "a1b2c3d4", client)
|
|
|
|
client.send_message.assert_awaited_once_with(
|
|
"detail", parse_mode="HTML", disable_link_preview=True
|
|
)
|
|
|
|
|
|
def test_format_task_detail_escapes_html_in_title() -> None:
|
|
"""Injection regression: a task titled ``<b>bold&joke</b>`` must render
|
|
HTML-escaped, not as live markup, in /task's detail view."""
|
|
engine = _engine()
|
|
task = _fake_task(title="<b>bold&joke</b>")
|
|
|
|
rendered = engine._format_task_detail(cast("TaskTable", task))
|
|
|
|
assert "<b>bold&joke</b>" in rendered
|
|
assert "<b>bold&joke</b>" not in rendered
|
|
|
|
|
|
def test_format_task_detail_pr_url_is_a_named_link() -> None:
|
|
engine = _engine()
|
|
task = _fake_task()
|
|
task.pr_url = "https://github.com/example/repo/pull/1"
|
|
|
|
rendered = engine._format_task_detail(cast("TaskTable", task))
|
|
|
|
assert '<a href="https://github.com/example/repo/pull/1">View PR</a>' in rendered
|
|
|
|
|
|
def test_format_task_detail_pr_url_quote_cannot_break_out_of_href() -> None:
|
|
"""Injection regression: a pr_url containing a literal '"' must not be
|
|
able to close the href attribute early and inject a bogus attribute —
|
|
_esc_attr (quote=True) turns it into '"', keeping the whole value
|
|
inside the attribute."""
|
|
engine = _engine()
|
|
task = _fake_task()
|
|
task.pr_url = 'https://evil.example/x" onmouseover="alert(1)'
|
|
|
|
rendered = engine._format_task_detail(cast("TaskTable", task))
|
|
|
|
assert (
|
|
'<a href="https://evil.example/x" onmouseover="alert(1)">'
|
|
"View PR</a>" in rendered
|
|
)
|
|
assert 'onmouseover="alert(1)"' not in rendered
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# outcome confirmations (_finish_action / _consume_reply) — escaping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finish_action_escapes_text_and_edits_origin() -> None:
|
|
engine = _engine()
|
|
client = AsyncMock()
|
|
|
|
await engine._finish_action(client, 42, True, "Rejected: <script>xss</script>")
|
|
|
|
client.edit_message_reply_markup.assert_awaited_once_with(42, None)
|
|
call = client.edit_message_text.await_args
|
|
assert call.args == (42, "✅ Rejected: <script>xss</script>")
|
|
assert call.kwargs["parse_mode"] == "HTML"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finish_action_escapes_text_without_origin() -> None:
|
|
engine = _engine()
|
|
client = AsyncMock()
|
|
|
|
await engine._finish_action(client, None, False, "<script>alert(1)</script>")
|
|
|
|
call = client.send_message.await_args
|
|
assert call.args == ("❌ <script>alert(1)</script>",)
|
|
assert call.kwargs["parse_mode"] == "HTML"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consume_reply_reject_outcome_arrives_escaped(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""End-to-end regression: a reject reason that reaches the CEO through
|
|
whatever text a dispatch handler returns must never arrive as live HTML —
|
|
the funnel (_finish_action) escapes it regardless of the handler."""
|
|
engine = _engine()
|
|
client = AsyncMock()
|
|
dispatch = AsyncMock(return_value=(True, "Rejected: <script>xss</script>"))
|
|
monkeypatch.setattr(engine, "_dispatch_reject", dispatch)
|
|
pending = ti._PendingAction(
|
|
kind="xpost",
|
|
id8="a1b2c3d4",
|
|
extra="",
|
|
action="reject",
|
|
origin_message_id=None,
|
|
expires_at=time.monotonic() + 60,
|
|
)
|
|
|
|
await engine._consume_reply(pending, "<script>xss</script>", client)
|
|
|
|
dispatch.assert_awaited_once_with("xpost", "a1b2c3d4", "", "<script>xss</script>")
|
|
sent_text = client.send_message.await_args.args[0]
|
|
assert "<script>xss</script>" in sent_text
|
|
assert "<script>xss</script>" not in sent_text
|