"""feat/telegram-live-tracker: tests for the live Telegram task tracker. Covers (per DEV_TASK_TELEGRAM_TRACKER.md): * short_model_name: provider/claude- prefix trimming. * render_task_tracker: per-stage line format (in↓/out↑, model, cost, minutes), the "⏸️ Ревью БРД · твоё время" line, the 💰 totals, and the finish block (⏱️ three times + 🔗/📦). * first message -> sendMessage stores message_id; transition -> editMessageText. * fallback: editMessageText fails -> a NEW message is sent and the id updated. * which alerts go out SEPARATELY (approve-gate / deploy-fail / agent-fail / error) vs which do NOT (QG-pending / agent-start / stage-transition). Isolated temp DB; no network (httpx is patched). """ import os import tempfile os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") _test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_tracker.db") os.environ["ORCH_DB_PATH"] = _test_db from unittest.mock import MagicMock, patch # noqa: E402 import pytest # noqa: E402 import src.db as db_module # noqa: E402 from src.db import init_db, get_db # noqa: E402 from src import notifications as N # noqa: E402 from src import usage as U # noqa: E402 @pytest.fixture(autouse=True) def setup_db(monkeypatch): monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False) if os.path.exists(_test_db): os.unlink(_test_db) init_db() # Re-enable send_telegram (conftest stubs it to a no-op); these tests patch # httpx / the lower-level helpers explicitly per case. yield if os.path.exists(_test_db): os.unlink(_test_db) # --------------------------------------------------------------------------- # # helpers to build a task + runs in the DB # --------------------------------------------------------------------------- # def _mk_task(stage="development", title="\u0422\u0440\u0435\u043a\u0438 \u0441 \u0437\u0443\u043c\u0430 z5", wid="ET-012", brd_start=None, brd_end=None): conn = get_db() cur = conn.execute( "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, " "brd_review_started_at, brd_review_ended_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ("p1", wid, "enduro-trails", "feature/ET-012-x", stage, title, brd_start, brd_end), ) tid = cur.lastrowid conn.commit() conn.close() return tid def _mk_run(task_id, agent, started, finished, in_tok, out_tok, cache_read=0, cache_creation=0, cost=0.0, model=None, exit_code=0): conn = get_db() cur = conn.execute( "INSERT INTO agent_runs (task_id, agent, started_at, finished_at, " "exit_code, input_tokens, output_tokens, cache_read_tokens, " "cache_creation_tokens, cost_usd, model) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (task_id, agent, started, finished, exit_code, in_tok, out_tok, cache_read, cache_creation, cost, model), ) rid = cur.lastrowid conn.commit() conn.close() return rid # --------------------------------------------------------------------------- # # short_model_name # --------------------------------------------------------------------------- # def test_short_model_name(): assert U.short_model_name("tokenator/claude-opus-4-8") == "opus-4-8" assert U.short_model_name("vibecode/claude-sonnet-4.6") == "sonnet-4.6" assert U.short_model_name("claude-opus-4-8") == "opus-4-8" assert U.short_model_name("opus-4-8") == "opus-4-8" assert U.short_model_name(None) == "" assert U.short_model_name("") == "" def test_parse_usage_extracts_model_from_modelusage(): blob = ( '{"total_cost_usd":0.01,' '"usage":{"input_tokens":10,"output_tokens":5},' '"modelUsage":{"claude-opus-4-8":{"inputTokens":10,"outputTokens":5}}}' ) u = U.parse_usage_from_text(blob) assert u["model"] == "claude-opus-4-8" # --------------------------------------------------------------------------- # # render_task_tracker # --------------------------------------------------------------------------- # def test_render_in_progress_stage_lines_and_totals(): tid = _mk_task(stage="deploy", brd_start="2026-06-04 10:00:00", brd_end="2026-06-04 10:08:00") # Analysis: 10м, 1.1M in (mostly cache) / 39.6k out, $2.38, opus-4-8 _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=1000, out_tok=39600, cache_read=1_100_000, cost=2.38, model="tokenator/claude-opus-4-8") _mk_run(tid, "architect", "2026-06-04 10:08:00", "2026-06-04 10:17:00", in_tok=500, out_tok=34400, cache_read=1_500_000, cost=2.24, model="tokenator/claude-opus-4-8") _mk_run(tid, "developer", "2026-06-04 10:17:00", "2026-06-04 10:28:00", in_tok=400, out_tok=45800, cache_read=8_400_000, cost=7.29, model="tokenator/claude-opus-4-8") _mk_run(tid, "reviewer", "2026-06-04 10:28:00", "2026-06-04 10:31:00", in_tok=300, out_tok=12900, cache_read=1_200_000, cost=1.53, model="vibecode/claude-sonnet-4.6") _mk_run(tid, "tester", "2026-06-04 10:31:00", "2026-06-04 10:36:00", in_tok=200, out_tok=19500, cache_read=1_200_000, cost=1.51, model="vibecode/claude-sonnet-4.6") # deployer started but not finished -> active "идёт" line. _mk_run(tid, "deployer", "2026-06-04 10:36:00", None, in_tok=0, out_tok=0, model=None, exit_code=None) text = N.render_task_tracker(tid) # Header in-progress assert text.startswith("\U0001f6e0\ufe0f ET-012 \u00b7 \u0422\u0440\u0435\u043a\u0438") # Per-stage format: in↓/out↑ · cost · model assert "\u2705 Analysis" in text assert "10\u043c" in text # analysis duration assert "39.6k\u2191" in text # analysis out assert "$2.38" in text assert "opus-4-8" in text assert "sonnet-4.6" in text # reviewer/tester model # BRD review line (human time, ended) assert "\u0420\u0435\u0432\u044c\u044e \u0411\u0420\u0414" in text assert "\u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f" in text # Active stage assert "\U0001f504 Deploy" in text assert "\u0438\u0434\u0451\u0442" in text # Totals line present with 💰 assert "\U0001f4b0" in text # In-progress: no final ⏱️ line assert "\u0412\u0441\u0435\u0433\u043e" not in text def test_render_brd_review_waiting_shows_hourglass(): tid = _mk_task(stage="analysis", brd_start="2026-06-04 10:00:00", brd_end=None) _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=1000, out_tok=39600, cache_read=1_100_000, cost=2.38, model="tokenator/claude-opus-4-8") text = N.render_task_tracker(tid) assert "\u0420\u0435\u0432\u044c\u044e \u0411\u0420\u0414" in text assert "\u23f3" in text # hourglass while waiting def test_render_done_has_times_and_links(): tid = _mk_task(stage="done", brd_start="2026-06-04 10:00:00", brd_end="2026-06-04 10:08:00") # set created/updated to compute wall clock conn = get_db() conn.execute( "UPDATE tasks SET created_at='2026-06-04 09:00:00', " "updated_at='2026-06-04 09:56:00' WHERE id=?", (tid,)) conn.commit() conn.close() _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=1000, out_tok=39600, cache_read=1_100_000, cost=2.38, model="tokenator/claude-opus-4-8") _mk_run(tid, "deployer", "2026-06-04 09:50:00", "2026-06-04 09:56:00", in_tok=400, out_tok=22400, cache_read=1_600_000, cost=1.73, model="tokenator/claude-opus-4-8") with patch("src.notifications.httpx") as _hx: # No PR found -> just "📦 deployed" _resp = MagicMock(status_code=200) _resp.json.return_value = [] _hx.get.return_value = _resp text = N.render_task_tracker(tid) assert text.startswith("\U0001f389 ET-012") assert "\u0413\u041e\u0422\u041e\u0412\u041e" in text # ⏱️ with three times assert "\u23f1\ufe0f" in text assert "\u0412\u0441\u0435\u0433\u043e" in text assert "\u0430\u0433\u0435\u043d\u0442\u044b" in text assert "\u0442\u0432\u043e\u0451" in text # 📦 deployed line assert "\U0001f4e6" in text def test_render_escapes_html_in_title(): tid = _mk_task(stage="analysis", title="A & B") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.0) text = N.render_task_tracker(tid) assert "<b>" in text assert "&" in text def test_render_omits_model_when_unknown(): tid = _mk_task(stage="analysis") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.0, model=None) text = N.render_task_tracker(tid) # No trailing " · " — line ends at cost. line = [l for l in text.splitlines() if l.startswith("\u2705 Analysis")][0] assert line.rstrip().endswith("$0.00") # --------------------------------------------------------------------------- # # tracker send / edit / fallback # --------------------------------------------------------------------------- # def test_first_call_sends_message_and_stores_id(monkeypatch): tid = _mk_task(stage="analysis") _mk_run(tid, "analyst", "2026-06-04 09:00:00", None, in_tok=0, out_tok=0, exit_code=None) sent = {} def _fake_send(text, disable_notification=False): sent["text"] = text sent["silent"] = disable_notification return 555 monkeypatch.setattr(N, "send_telegram", _fake_send) monkeypatch.setattr(N, "edit_telegram", lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not edit on first call"))) N.update_task_tracker(tid) from src.db import get_tracker_message_id assert get_tracker_message_id(tid) == 555 assert sent["silent"] is True # tracker is silent def test_second_call_edits_existing_message(monkeypatch): tid = _mk_task(stage="development") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1) from src.db import set_tracker_message_id set_tracker_message_id(tid, 777) edited = {} monkeypatch.setattr(N, "edit_telegram", lambda mid, text: edited.update(mid=mid) or True) monkeypatch.setattr(N, "send_telegram", lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not send when edit succeeds"))) N.update_task_tracker(tid) assert edited["mid"] == 777 def test_fallback_to_new_message_when_edit_fails(monkeypatch): tid = _mk_task(stage="development") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1) from src.db import set_tracker_message_id, get_tracker_message_id set_tracker_message_id(tid, 100) monkeypatch.setattr(N, "edit_telegram", lambda mid, text: False) # edit fails monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: 200) N.update_task_tracker(tid) assert get_tracker_message_id(tid) == 200 # id updated to the new message # --------------------------------------------------------------------------- # # which alerts are SEPARATE vs tracker-only # --------------------------------------------------------------------------- # def test_approve_gate_sends_separate_message_and_starts_brd_clock(monkeypatch): tid = _mk_task(stage="analysis") calls = [] monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: calls.append((text, disable_notification)) or 1) monkeypatch.setattr(N, "update_task_tracker", lambda task_id: None) N.notify_approve_requested(tid) # exactly one SEPARATE (notifying) send for the approve gate assert len(calls) == 1 assert calls[0][1] is False # notifying assert "Approved" in calls[0][0] # BRD clock started conn = get_db() row = conn.execute("SELECT brd_review_started_at FROM tasks WHERE id=?", (tid,)).fetchone() conn.close() assert row[0] is not None def test_error_sends_separate_message(monkeypatch): tid = _mk_task(stage="development") calls = [] monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: calls.append((text, disable_notification)) or 1) N.notify_error(tid, "boom") assert len(calls) == 1 assert calls[0][1] is False # notifying assert "ERROR" in calls[0][0] def test_stage_change_does_not_send_separate_message(monkeypatch): tid = _mk_task(stage="development") sent = [] monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: sent.append(text) or 1) # tracker refresh is allowed (edit/send silent) but must NOT use send_telegram # for a separate notification; stub update to isolate. refreshed = [] monkeypatch.setattr(N, "update_task_tracker", lambda task_id: refreshed.append(task_id)) N.notify_stage_change(tid, "development", "review") assert sent == [] # no separate message assert refreshed == [tid] # tracker refreshed instead def test_agent_started_does_not_send_separate_message(monkeypatch): tid = _mk_task(stage="analysis") sent = [] monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: sent.append(text) or 1) refreshed = [] monkeypatch.setattr(N, "update_task_tracker", lambda task_id: refreshed.append(task_id)) N.notify_agent_started(1, "analyst", tid) assert sent == [] assert refreshed == [tid] def test_qg_failure_does_not_send_separate_message(monkeypatch): tid = _mk_task(stage="development") sent = [] monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: sent.append(text) or 1) N.notify_qg_failure(tid, "development", "check_ci_green", "CI state: pending") assert sent == [] # QG-pending is log-only, never a separate ping